Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
4.1 MiB
Plaintext
1 line
4.1 MiB
Plaintext
{"repo": "avabbbb/flovart", "files": {"services/aiGateway.ts": {"893c949d95bc5476435c2008243e457307675cc2": {"t": 1785747463.1345725, "n": 159797, "h": "04fc229ecc5fe88d", "c": "import type { AICapability, AIProvider, ProductModelMode, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\nimport { explainReferenceCompatibility, sanitizeProductGenerationParams } from './productModelCatalog';\nimport { getRouteSchema, getRouteDurations, type RouteCapabilitySchema, type RouteMediaSpec } from './runningHubRouteCatalog';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport type ProviderTaskLifecycleEvent =\n | { phase: 'submitted'; providerTaskId: string; submittedAt: number }\n | { phase: 'running'; providerTaskId: string; remoteStatus?: string }\n | { phase: 'cancelled'; providerTaskId: string; canceled: boolean; upstreamStillRunning?: boolean; message?: string }\n | { phase: 'usage'; providerTaskId: string; status: SeedanceVideoReconcileResult['status']; amount?: number; currency?: string; totalTokens?: number };\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n productModelId?: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n quality?: string;\n generationSubmode?: ProductModelMode;\n generateAudio?: boolean;\n watermark?: boolean;\n webSearch?: boolean;\n realPersonCheck?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image', 'gemini-3.1-flash-lite-image', 'gemini-3-pro-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-fast-generate-preview', 'veo-3.1-lite-generate-preview'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n xai: {\n text: ['grok-4-fast', 'grok-4'],\n image: ['grok-2-image'],\n video: ['grok-imagine-video', 'grok-imagine-video-1.5'],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '3:2' | '2:3' | '4:5' | '5:4' | '1:4' | '4:1' | '1:8' | '8:1' | '21:9' | '9:21' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\nconst OPENAI_IMAGE_SIZES: Record<'1K' | '2K' | '4K', Record<string, string>> = {\n '1K': { '1:1': '1024x1024', '3:2': '1536x1024', '2:3': '1024x1536', '16:9': '1792x1024', '9:16': '1024x1792', '4:3': '1344x1008', '3:4': '1008x1344' },\n '2K': { '1:1': '2048x2048', '3:2': '2048x1360', '2:3': '1360x2048', '16:9': '2048x1152', '9:16': '1152x2048', '4:3': '2048x1536', '3:4': '1536x2048' },\n '4K': { '1:1': '2880x2880', '3:2': '3520x2352', '2:3': '2352x3520', '16:9': '3840x2160', '9:16': '2160x3840', '4:3': '3312x2480', '3:4': '2480x3312' },\n};\n\nfunction getOpenAIImageSize(resolution?: string, aspectRatio?: VideoAspectRatio): string {\n const tier = resolution?.toUpperCase() === '4K' ? '4K' : resolution?.toUpperCase() === '2K' ? '2K' : '1K';\n return OPENAI_IMAGE_SIZES[tier][aspectRatio || '1:1'] || OPENAI_IMAGE_SIZES[tier]['1:1'];\n}\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance') || isRunningHubSeedance20Model(normalized)) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'runningHub' && (inferCapabilityFromModelName(model) === 'video' || isRunningHubVideoEndpoint(normalized))) {\n return { ...DEFAULT_VIDEO_CAPABILITY, durations: resolveRunningHubVideoDurations(normalized) };\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地工作流节点。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16'], // Veo 3.1 documented ratios\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n xai: ['1:1', '16:9', '9:16', '4:3', '3:4', '2:3', '3:2'], // Grok Imagine Video\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n xai: 'https://api.x.ai/v1',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^xai-/i.test(trimmed)) return 'xai';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n xai: 'xAI Grok',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction assertResolvedUpstreamModel(model: string): void {\n if (stripModelSelectionRef(model).trim().toLowerCase().startsWith('flovart:')) {\n throw new Error(`产品模型 ${model} 尚未解析为供应商上游模型。请检查该产品模型的 BYOK 映射。`);\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie') || normalized.includes('motion-control')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction adaptVideoSlotsForMode(slots: MultimodalSlot[], mode?: ProductModelMode, provider?: AIProvider, routeId?: string): MultimodalSlot[] {\n if (!mode) return slots;\n const images = slots.filter(slot => slot.kind === 'image');\n if (mode === 'text-to-video') return [];\n if (mode === 'image-to-video') {\n if (!images[0]) throw new Error('图生视频至少需要 1 张图片。');\n const routeImageSpec = provider === 'runningHub'\n ? getRouteSchema(routeId || '')?.media.find(spec => spec.kind === 'image' && spec.mapping === 'all_references')\n : undefined;\n if (routeImageSpec) return images.slice(0, routeImageSpec.max).map((image, index) => ({ ...image, role: index === 0 ? 'first_frame' : 'reference_image' }));\n return [{ ...images[0], role: 'first_frame' }];\n }\n if (mode === 'first-last-frame') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配首尾帧,请切换模式或重新映射线路。');\n if (images.length < 2) throw new Error('首尾帧模式需要 2 张图片。');\n return [{ ...images[0], role: 'first_frame' }, { ...images[1], role: 'last_frame' }];\n }\n if (mode === 'reference-to-video') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配全能参考,请切换模式或重新映射线路。');\n const references = provider === 'google' ? images.slice(0, 3) : slots;\n if (!references.length) throw new Error(provider === 'google' ? 'Veo 全能参考至少需要 1 张图片。' : '全能参考至少需要 1 个媒体素材。');\n return references.map(slot => ({\n ...slot,\n role: slot.kind === 'video' ? 'reference_video' : slot.kind === 'audio' ? 'reference_audio' : 'reference_image',\n }));\n }\n return slots;\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName) || `@${ordinal}`;\n const nameNote = reference.sourceName && reference.sourceName !== mention.replace(/^@/, '')\n ? `(节点: ${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction resolveRunningHubVideoDurations(model: string): number[] {\n const catalogDurations = getRouteDurations(model);\n return catalogDurations ?? [];\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRouteMediaFields(\n schema: RouteCapabilitySchema,\n references: VideoImage[],\n slots: MultimodalSlot[],\n): Record<string, unknown> {\n const output: Record<string, unknown> = {};\n for (const spec of schema.media) {\n if (spec.mapping === 'all_references') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (spec.serialization === 'array') {\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (urls[0]) {\n output[spec.field] = assertRunningHubPublicUrl(urls[0], spec.field);\n }\n } else if (spec.mapping === 'first_last_frame') {\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n if (spec.field === 'firstFrameUrl') {\n if (spec.required && !first?.href) {\n throw new Error(`RunningHub ${schema.routeId} 需要首帧图片。`);\n }\n if (first?.href) output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n } else if (spec.field === 'lastFrameUrl') {\n if (last?.href) output[spec.field] = assertRunningHubPublicUrl(last.href, spec.field);\n }\n } else if (spec.mapping === 'first_frame_only') {\n const first = references[0];\n if (first?.href) {\n output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n }\n } else if (spec.mapping === 'reference_images') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (spec.mapping === 'multimodal') {\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, i) => assertRunningHubPublicUrl(href, `${spec.field}[${i}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n if (spec.kind === 'image' && imageUrls.length > 0) output[spec.field] = uniqueLimitedUrls(imageUrls, spec.max);\n if (spec.kind === 'video' && videoUrls.length > 0) output[spec.field] = uniqueLimitedUrls(videoUrls, spec.max);\n if (spec.kind === 'audio' && audioUrls.length > 0) output[spec.field] = uniqueLimitedUrls(audioUrls, spec.max);\n }\n }\n return output;\n}\n\nfunction snapDuration(requested: number, allowed: number[]): number {\n return allowed.reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, allowed[0]);\n}\n\nfunction buildRunningHubRoutePayload(\n prompt: string,\n schema: RouteCapabilitySchema,\n key: UserApiKey | undefined,\n options: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const promptField = extra.runningHubPromptField || schema.promptField;\n\n const payload: Record<string, unknown> = {};\n\n if (!disableDefaults) {\n if (schema.resolutionDefault) payload.resolution = schema.resolutionDefault;\n if (schema.durationDefault !== undefined) payload.duration = schema.durationDefault;\n if (schema.aspectRatioField && schema.aspectRatioValues?.[0]) payload[schema.aspectRatioField] = schema.aspectRatioValues[0];\n for (const param of schema.params) {\n if (param.default !== undefined) {\n if (param.type === 'string' && param.default === 'all') {\n payload[param.field] = ['all'];\n } else {\n payload[param.field] = param.default;\n }\n }\n }\n }\n\n Object.assign(payload, configuredPayload);\n\n payload[promptField] = prompt;\n\n if (schema.aspectRatioField && options?.aspectRatio) {\n payload[schema.aspectRatioField] = options.aspectRatio;\n }\n\n if (schema.durationType && options?.durationSec && options.durationSec > 0) {\n if (schema.durationValues && schema.durationValues.length > 0) {\n const allowed = schema.durationValues.map(v => Number(v)).filter(n => !isNaN(n));\n if (allowed.length > 0) {\n if (schema.durationType === 'string') {\n payload.duration = String(snapDuration(options.durationSec, allowed));\n } else {\n payload.duration = snapDuration(options.durationSec, allowed);\n }\n }\n } else if (schema.durationType === 'string') {\n payload.duration = String(options.durationSec);\n } else {\n payload.duration = options.durationSec;\n }\n }\n\n if (options?.resolution) {\n payload.resolution = options.resolution;\n }\n\n if (options?.userParams) {\n const schemaFields = new Set(schema.params.map(p => p.field));\n for (const [key, value] of Object.entries(options.userParams)) {\n if (value !== undefined && schemaFields.has(key)) {\n payload[key] = value;\n }\n }\n }\n\n Object.assign(payload, buildRouteMediaFields(schema, options?.references || [], options?.slots || []));\n\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n\n return payload;\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const routeSchema = getRouteSchema(modelEndpoint);\n if (routeSchema) {\n return buildRunningHubRoutePayload(prompt, routeSchema, key, options || {});\n }\n throw new Error(`RunningHub 路由 ${modelEndpoint} 未通过验证(未在 Route Catalog 中登记),暂不可执行。请先在 Route Catalog 中补齐该路由的 Capability Schema 后再使用。`);\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(grok-|xai)/i.test(model)) return 'xai';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal; aspectRatio?: VideoAspectRatio; resolution?: string; quality?: string; webSearch?: boolean },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (isGoogleImageEditModel(model)) {\n return editImage(refs, prompt, undefined, key?.key, options?.signal, {\n model,\n aspectRatio: options?.aspectRatio === 'adaptive' ? undefined : options?.aspectRatio,\n imageSize: options?.resolution,\n webSearch: options?.webSearch,\n baseUrl: key?.baseUrl,\n });\n }\n return generateImageFromText(prompt, key?.key, options?.signal, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n aspectRatio: options?.aspectRatio,\n resolution: options?.resolution,\n userParams: {\n ...(options?.webSearch !== undefined ? { webSearch: options.webSearch } : {}),\n ...(options?.quality !== undefined ? { hd: options.quality === 'hd' || options.quality === 'high' } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const imageSize = getOpenAIImageSize(options?.resolution, options?.aspectRatio);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key, undefined, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport class SeedanceSubmissionUnknownError extends Error {\n readonly code = 'SEEDANCE_SUBMISSION_UNKNOWN';\n constructor(cause?: unknown) {\n super('Seedance 提交结果未知:供应商可能已经创建并计费。已禁止自动重试或切换线路,请先到任务记录核对。', { cause });\n this.name = 'SeedanceSubmissionUnknownError';\n }\n}\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const rawSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n const slots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, 'volcengine');\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n let createRes: Response;\n try {\n createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n } catch (error) {\n throw new SeedanceSubmissionUnknownError(error);\n }\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 区分官方 Ark 与第三方 OpenAI-compatible 网关:\n * - Ark 官方:baseUrl 含 `ark.*.volces.com` 或路径含 `/api/v3`,惯例 DELETE `/contents/generations/tasks/{id}`\n * - 网关(如 Scnet):路径含 `/api/llm/v1`,惯例 POST `/tasks/{id}/cancel`\n * - 其他:fallback 到 Ark 风格(保留向后兼容)。\n */\nfunction detectSeedanceCancelRoute(baseUrl: string): 'ark' | 'gateway' {\n const lower = (baseUrl || '').toLowerCase();\n if (lower.includes('/api/llm/v1') || lower.includes('/v1/tasks')) return 'gateway';\n if (lower.includes('volces.com') || lower.includes('/api/v3')) return 'ark';\n return 'ark';\n}\n\nexport type SeedanceVideoCancelResult = {\n canceled: boolean;\n reason: 'ok' | 'not_queued' | 'not_cancellable' | 'unsupported' | 'network_error';\n upstreamStillRunning?: boolean;\n message?: string;\n raw?: unknown;\n};\n\n/**\n * 取消 Seedance 上游任务。仅对 queued 阶段的成功取消返回 `canceled: true`。\n * 上游已开始 processing、或上游不支持取消时返回 `canceled:false, upstreamStillRunning:true`,\n * 让前端走“放弃等待”的退路(仅本地 abortSignal 不再轮询上游)。\n */\nexport async function cancelSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal; reason?: string },\n): Promise<SeedanceVideoCancelResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const route = detectSeedanceCancelRoute(baseUrl);\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n if (route === 'ark') {\n // 官方 Ark:DELETE /contents/generations/tasks/{id}。409=不可取消;200/204=已取消。\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } else {\n // 网关风格:POST /tasks/{id}/cancel;body 可携带 reason。\n res = await fetch(`${baseUrl}/tasks/${taskId}/cancel`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },\n body: JSON.stringify({ reason: options?.reason || 'user_abort' }),\n signal: options?.signal,\n });\n }\n } catch (error) {\n return {\n canceled: false,\n reason: 'network_error',\n upstreamStillRunning: true,\n message: error instanceof Error ? error.message : '网络错误,无法确认取消结果',\n };\n }\n\n if (res.status === 405 || res.status === 404) {\n // 网关/官方未实现取消端点\n return {\n canceled: false,\n reason: 'unsupported',\n upstreamStillRunning: true,\n message: '当前 Seedance 渠道不支持取消,请等待完成或放弃等待',\n };\n }\n\n let raw: any = undefined;\n try {\n raw = await res.json();\n } catch {\n // 204 No Content 等成功无 body 的情况\n }\n\n if (res.ok) {\n // Scnet 风格可能返回 { code: 0 } 或 { success: true }; Ark 200 返回 { cancelled: true }\n // 兼容两种解析:cancelled=true / code===0 / success===true 视为成功\n const okFlag =\n raw?.cancelled === true\n || raw?.data?.cancelled === true\n || raw?.code === 0\n || raw?.success === true\n || (route === 'ark' && res.status === 200 && raw == null);\n return {\n canceled: Boolean(okFlag),\n reason: okFlag ? 'ok' : 'not_cancellable',\n upstreamStillRunning: !okFlag,\n message: okFlag ? undefined : (raw?.message || raw?.msg || '上游响应未确认取消'),\n raw,\n };\n }\n\n if (res.status === 409 || res.status === 400) {\n // Ark: 409 task_not_cancellable (已 processing);网关: 400 已开始\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || '任务已开始执行,上游无法取消',\n raw,\n };\n }\n\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || `上游返回 ${res.status}`,\n raw,\n };\n}\n\nexport type SeedanceVideoReconcileResult = {\n status: 'succeeded' | 'failed' | 'cancelled' | 'expired' | 'unknown';\n amount?: number;\n currency?: string;\n totalTokens?: number;\n reason?: string;\n raw?: unknown;\n};\n\nfunction parseSeedanceVideoUsage(raw: any, fallbackStatus: SeedanceVideoReconcileResult['status'] = 'unknown'): SeedanceVideoReconcileResult {\n const remoteStatus = String(raw?.status || raw?.data?.status || raw?.data?.task_status || raw?.task?.status || raw?.state || '').toLowerCase();\n let status: SeedanceVideoReconcileResult['status'] = fallbackStatus;\n if (['succeeded', 'succeed', 'success', 'completed', 'complete', 'done'].includes(remoteStatus)) status = 'succeeded';\n else if (['cancelled', 'canceled'].includes(remoteStatus)) status = 'cancelled';\n else if (['failed', 'fail', 'error'].includes(remoteStatus)) status = 'failed';\n else if (['expired', 'timeout'].includes(remoteStatus)) status = 'expired';\n const billingRaw = raw?.data?.billing || raw?.data?.usage?.billing || raw?.billing || raw?.usage?.billing;\n const amount = typeof billingRaw?.amount === 'number' ? billingRaw.amount : undefined;\n const currency = typeof billingRaw?.currency === 'string' ? billingRaw.currency : raw?.data?.currency || raw?.currency;\n const totalTokens = typeof raw?.usage?.total_tokens === 'number' ? raw.usage.total_tokens\n : typeof raw?.data?.usage?.total_tokens === 'number' ? raw.data.usage.total_tokens\n : typeof raw?.usage?.token_usage?.total_tokens === 'number' ? raw.usage.token_usage.total_tokens\n : undefined;\n return { status, amount, currency, totalTokens, raw };\n}\n\n/**\n * 查询上游任务的真实账单/token 使用量。复用 GET 任务查询接口;不修改 handle。\n *\n * - 网关(Scnet/Lemondata)常见字段:`data.billing.amount` / `data.usage.billing.amount` + `currency`\n * - Volcengine Ark 官方常见字段:`usage.total_tokens`(仅 token,无 billing 金额)\n *\n * 调用方拿到返回值后:\n * - amount 存在 → actualCost = amount,billableState = 'actual'\n * - 仅 totalTokens → actualTokens = totalTokens,billableState 保持 'unknown'\n * - status === 'unknown' → 不变更 usageRecord\n */\nexport async function reconcileSeedanceVideoUsage(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoReconcileResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } catch (error) {\n return { status: 'unknown', reason: error instanceof Error ? error.message : '网络错误' };\n }\n if (!res.ok) {\n return { status: 'unknown', reason: `上游返回 ${res.status}` };\n }\n let raw: any;\n try {\n raw = await res.json();\n } catch {\n return { status: 'unknown', reason: '响应不是合法 JSON' };\n }\n\n return parseSeedanceVideoUsage(raw);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const runningHubEndpoint = provider === 'runningHub'\n ? assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model)\n : undefined;\n const legacyReferences = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const rawSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(legacyReferences);\n const multimodalSlots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, provider, runningHubEndpoint);\n const references: VideoImage[] = multimodalSlots.filter(slot => slot.kind === 'image').map(slot => ({\n href: slot.href,\n mimeType: slot.mimeType,\n slotRole: String(slot.role || 'unassigned'),\n label: slot.label,\n }));\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const usesFirstFrame = !options?.generationSubmode || options.generationSubmode === 'image-to-video' || options.generationSubmode === 'first-last-frame';\n const firstFrame = usesFirstFrame\n ? references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined)\n : undefined;\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = runningHubEndpoint!;\n const allImageRefs = references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n userParams: {\n ...(options?.generateAudio !== undefined ? { generateAudio: options.generateAudio } : {}),\n ...(options?.seed !== undefined ? { seed: options.seed } : {}),\n ...(options?.returnLastFrame !== undefined ? { returnLastFrame: options.returnLastFrame } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n const lastFrameSlot = options?.generationSubmode === 'first-last-frame'\n ? multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'last_frame')\n : undefined;\n const referenceImages = options?.generationSubmode === 'reference-to-video'\n ? multimodalSlots.filter(slot => slot.kind === 'image' && slot.role !== 'first_frame' && slot.role !== 'last_frame').slice(0, 3)\n : [];\n return generateVideo(prompt, aspectRatio === '9:16' ? '9:16' : '16:9', onProgress, firstFrame, key?.key, {\n model,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n generateAudio: options?.generateAudio,\n signal: options?.signal,\n lastFrame: lastFrameSlot ? { href: lastFrameSlot.href, mimeType: lastFrameSlot.mimeType } : undefined,\n referenceImages: referenceImages.map(reference => ({ href: reference.href, mimeType: reference.mimeType })),\n baseUrl: key?.baseUrl,\n });\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl, { signal: options?.signal });\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: options?.resolution?.toLowerCase() === '1080p' ? 'pro' : 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: String(options?.durationSec || 5),\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl, { signal: options?.signal });\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'xai') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const submode: ProductModelMode = options?.generationSubmode || 'text-to-video';\n\n onProgress('Submitting video generation task...');\n let createPath: string;\n const createBody: Record<string, unknown> = { model, prompt };\n\n if (submode === 'video-extension') {\n createPath = `${baseUrl}/videos/extensions`;\n const videoSlot = multimodalSlots.find(slot => slot.kind === 'video');\n if (!videoSlot) {\n throw new Error('xAI 视频扩展至少需要 1 个视频素材作为驱动视频。');\n }\n createBody.video = { url: videoSlot.href };\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n } else {\n createPath = `${baseUrl}/videos/generations`;\n if (aspectRatio) {\n createBody.aspect_ratio = aspectRatio;\n }\n if (options?.resolution) {\n createBody.resolution = options.resolution;\n }\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n if (firstFrame) {\n createBody.image = { url: firstFrame.href };\n }\n }\n\n const createRes = await fetch(createPath, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'xAI 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'xAI 视频生成创建响应');\n const requestId = createJson?.request_id;\n if (!requestId) {\n throw new Error('xAI 视频生成未返回 request_id');\n }\n\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const xaiPollStart = Date.now();\n while (true) {\n if (Date.now() - xaiPollStart > 600_000) {\n throw new Error('xAI 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'xAI 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'xAI 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'failed') {\n throw new Error(`xAI 视频生成失败: ${queryJson?.error || queryJson?.message || 'Unknown error'}`);\n }\n if (status === 'done') {\n videoUrl = queryJson?.output?.url;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!videoUrl) {\n throw new Error('xAI 视频生成完成但未返回视频 URL');\n }\n\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n await options?.onProviderTaskLifecycle?.({ phase: 'submitted', providerTaskId: handle.taskId, submittedAt: handle.createdAt });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n let cancelPromise: Promise<SeedanceVideoCancelResult> | undefined;\n const requestUpstreamCancel = () => cancelPromise ||= cancelSeedanceVideoTask(handle, key, { reason: 'user_abort' }).then(async result => {\n await options?.onProviderTaskLifecycle?.({ phase: 'cancelled', providerTaskId: handle.taskId, canceled: result.canceled, upstreamStillRunning: result.upstreamStillRunning, message: result.message });\n const usage = await reconcileSeedanceVideoUsage(handle, key);\n if (usage.status !== 'unknown' || usage.amount !== undefined || usage.totalTokens !== undefined) {\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n }\n return result;\n });\n const handleAbort = () => { void requestUpstreamCancel(); };\n options?.signal?.addEventListener('abort', handleAbort, { once: true });\n\n try {\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n const usage = parseSeedanceVideoUsage(result.raw, 'failed');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n const usage = parseSeedanceVideoUsage(result.raw, 'succeeded');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n await options?.onProviderTaskLifecycle?.({ phase: 'running', providerTaskId: handle.taskId, remoteStatus: result.remoteStatus });\n }\n } catch (error) {\n if (options?.signal?.aborted || error instanceof Error && error.name === 'AbortError') await requestUpstreamCancel();\n throw error;\n } finally {\n options?.signal?.removeEventListener('abort', handleAbort);\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const params = sanitizeProductGenerationParams(input.productModelId, {\n mode: input.generationSubmode,\n aspectRatio: input.aspectRatio,\n resolution: input.resolution,\n quality: input.quality,\n durationSec: input.durationSec,\n generateAudio: input.generateAudio,\n webSearch: input.webSearch,\n realPersonCheck: input.realPersonCheck,\n referenceCount: input.references?.length || 0,\n }, { provider: input.apiKeyPayload?.provider, routeId: input.modelId });\n if (capability === 'video') {\n const referenceKinds = (input.references || [])\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio' } => reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n .map(reference => reference.type);\n const issue = explainReferenceCompatibility(input.productModelId, params.mode as ProductModelMode, referenceKinds, { provider: input.apiKeyPayload?.provider, routeId: input.modelId });\n if (issue) throw new Error(issue);\n }\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: params.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: params.durationSec,\n resolution: params.resolution,\n generateAudio: params.generateAudio,\n watermark: input.watermark,\n generationSubmode: params.mode,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n onProviderTaskLifecycle: input.onProviderTaskLifecycle,\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, {\n signal: input.signal,\n aspectRatio: params.aspectRatio,\n resolution: params.resolution,\n quality: params.quality,\n webSearch: params.webSearch,\n });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "f7fce8c30709da3f74510e978582ccae0959bbf5": {"t": 1785773450.3966272, "n": 159797, "h": "04fc229ecc5fe88d", "c": "import type { AICapability, AIProvider, ProductModelMode, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\nimport { explainReferenceCompatibility, sanitizeProductGenerationParams } from './productModelCatalog';\nimport { getRouteSchema, getRouteDurations, type RouteCapabilitySchema, type RouteMediaSpec } from './runningHubRouteCatalog';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport type ProviderTaskLifecycleEvent =\n | { phase: 'submitted'; providerTaskId: string; submittedAt: number }\n | { phase: 'running'; providerTaskId: string; remoteStatus?: string }\n | { phase: 'cancelled'; providerTaskId: string; canceled: boolean; upstreamStillRunning?: boolean; message?: string }\n | { phase: 'usage'; providerTaskId: string; status: SeedanceVideoReconcileResult['status']; amount?: number; currency?: string; totalTokens?: number };\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n productModelId?: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n quality?: string;\n generationSubmode?: ProductModelMode;\n generateAudio?: boolean;\n watermark?: boolean;\n webSearch?: boolean;\n realPersonCheck?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image', 'gemini-3.1-flash-lite-image', 'gemini-3-pro-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-fast-generate-preview', 'veo-3.1-lite-generate-preview'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n xai: {\n text: ['grok-4-fast', 'grok-4'],\n image: ['grok-2-image'],\n video: ['grok-imagine-video', 'grok-imagine-video-1.5'],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '3:2' | '2:3' | '4:5' | '5:4' | '1:4' | '4:1' | '1:8' | '8:1' | '21:9' | '9:21' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\nconst OPENAI_IMAGE_SIZES: Record<'1K' | '2K' | '4K', Record<string, string>> = {\n '1K': { '1:1': '1024x1024', '3:2': '1536x1024', '2:3': '1024x1536', '16:9': '1792x1024', '9:16': '1024x1792', '4:3': '1344x1008', '3:4': '1008x1344' },\n '2K': { '1:1': '2048x2048', '3:2': '2048x1360', '2:3': '1360x2048', '16:9': '2048x1152', '9:16': '1152x2048', '4:3': '2048x1536', '3:4': '1536x2048' },\n '4K': { '1:1': '2880x2880', '3:2': '3520x2352', '2:3': '2352x3520', '16:9': '3840x2160', '9:16': '2160x3840', '4:3': '3312x2480', '3:4': '2480x3312' },\n};\n\nfunction getOpenAIImageSize(resolution?: string, aspectRatio?: VideoAspectRatio): string {\n const tier = resolution?.toUpperCase() === '4K' ? '4K' : resolution?.toUpperCase() === '2K' ? '2K' : '1K';\n return OPENAI_IMAGE_SIZES[tier][aspectRatio || '1:1'] || OPENAI_IMAGE_SIZES[tier]['1:1'];\n}\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance') || isRunningHubSeedance20Model(normalized)) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'runningHub' && (inferCapabilityFromModelName(model) === 'video' || isRunningHubVideoEndpoint(normalized))) {\n return { ...DEFAULT_VIDEO_CAPABILITY, durations: resolveRunningHubVideoDurations(normalized) };\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地工作流节点。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16'], // Veo 3.1 documented ratios\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n xai: ['1:1', '16:9', '9:16', '4:3', '3:4', '2:3', '3:2'], // Grok Imagine Video\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n xai: 'https://api.x.ai/v1',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^xai-/i.test(trimmed)) return 'xai';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n xai: 'xAI Grok',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction assertResolvedUpstreamModel(model: string): void {\n if (stripModelSelectionRef(model).trim().toLowerCase().startsWith('flovart:')) {\n throw new Error(`产品模型 ${model} 尚未解析为供应商上游模型。请检查该产品模型的 BYOK 映射。`);\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie') || normalized.includes('motion-control')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction adaptVideoSlotsForMode(slots: MultimodalSlot[], mode?: ProductModelMode, provider?: AIProvider, routeId?: string): MultimodalSlot[] {\n if (!mode) return slots;\n const images = slots.filter(slot => slot.kind === 'image');\n if (mode === 'text-to-video') return [];\n if (mode === 'image-to-video') {\n if (!images[0]) throw new Error('图生视频至少需要 1 张图片。');\n const routeImageSpec = provider === 'runningHub'\n ? getRouteSchema(routeId || '')?.media.find(spec => spec.kind === 'image' && spec.mapping === 'all_references')\n : undefined;\n if (routeImageSpec) return images.slice(0, routeImageSpec.max).map((image, index) => ({ ...image, role: index === 0 ? 'first_frame' : 'reference_image' }));\n return [{ ...images[0], role: 'first_frame' }];\n }\n if (mode === 'first-last-frame') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配首尾帧,请切换模式或重新映射线路。');\n if (images.length < 2) throw new Error('首尾帧模式需要 2 张图片。');\n return [{ ...images[0], role: 'first_frame' }, { ...images[1], role: 'last_frame' }];\n }\n if (mode === 'reference-to-video') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配全能参考,请切换模式或重新映射线路。');\n const references = provider === 'google' ? images.slice(0, 3) : slots;\n if (!references.length) throw new Error(provider === 'google' ? 'Veo 全能参考至少需要 1 张图片。' : '全能参考至少需要 1 个媒体素材。');\n return references.map(slot => ({\n ...slot,\n role: slot.kind === 'video' ? 'reference_video' : slot.kind === 'audio' ? 'reference_audio' : 'reference_image',\n }));\n }\n return slots;\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName) || `@${ordinal}`;\n const nameNote = reference.sourceName && reference.sourceName !== mention.replace(/^@/, '')\n ? `(节点: ${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction resolveRunningHubVideoDurations(model: string): number[] {\n const catalogDurations = getRouteDurations(model);\n return catalogDurations ?? [];\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRouteMediaFields(\n schema: RouteCapabilitySchema,\n references: VideoImage[],\n slots: MultimodalSlot[],\n): Record<string, unknown> {\n const output: Record<string, unknown> = {};\n for (const spec of schema.media) {\n if (spec.mapping === 'all_references') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (spec.serialization === 'array') {\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (urls[0]) {\n output[spec.field] = assertRunningHubPublicUrl(urls[0], spec.field);\n }\n } else if (spec.mapping === 'first_last_frame') {\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n if (spec.field === 'firstFrameUrl') {\n if (spec.required && !first?.href) {\n throw new Error(`RunningHub ${schema.routeId} 需要首帧图片。`);\n }\n if (first?.href) output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n } else if (spec.field === 'lastFrameUrl') {\n if (last?.href) output[spec.field] = assertRunningHubPublicUrl(last.href, spec.field);\n }\n } else if (spec.mapping === 'first_frame_only') {\n const first = references[0];\n if (first?.href) {\n output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n }\n } else if (spec.mapping === 'reference_images') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (spec.mapping === 'multimodal') {\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, i) => assertRunningHubPublicUrl(href, `${spec.field}[${i}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n if (spec.kind === 'image' && imageUrls.length > 0) output[spec.field] = uniqueLimitedUrls(imageUrls, spec.max);\n if (spec.kind === 'video' && videoUrls.length > 0) output[spec.field] = uniqueLimitedUrls(videoUrls, spec.max);\n if (spec.kind === 'audio' && audioUrls.length > 0) output[spec.field] = uniqueLimitedUrls(audioUrls, spec.max);\n }\n }\n return output;\n}\n\nfunction snapDuration(requested: number, allowed: number[]): number {\n return allowed.reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, allowed[0]);\n}\n\nfunction buildRunningHubRoutePayload(\n prompt: string,\n schema: RouteCapabilitySchema,\n key: UserApiKey | undefined,\n options: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const promptField = extra.runningHubPromptField || schema.promptField;\n\n const payload: Record<string, unknown> = {};\n\n if (!disableDefaults) {\n if (schema.resolutionDefault) payload.resolution = schema.resolutionDefault;\n if (schema.durationDefault !== undefined) payload.duration = schema.durationDefault;\n if (schema.aspectRatioField && schema.aspectRatioValues?.[0]) payload[schema.aspectRatioField] = schema.aspectRatioValues[0];\n for (const param of schema.params) {\n if (param.default !== undefined) {\n if (param.type === 'string' && param.default === 'all') {\n payload[param.field] = ['all'];\n } else {\n payload[param.field] = param.default;\n }\n }\n }\n }\n\n Object.assign(payload, configuredPayload);\n\n payload[promptField] = prompt;\n\n if (schema.aspectRatioField && options?.aspectRatio) {\n payload[schema.aspectRatioField] = options.aspectRatio;\n }\n\n if (schema.durationType && options?.durationSec && options.durationSec > 0) {\n if (schema.durationValues && schema.durationValues.length > 0) {\n const allowed = schema.durationValues.map(v => Number(v)).filter(n => !isNaN(n));\n if (allowed.length > 0) {\n if (schema.durationType === 'string') {\n payload.duration = String(snapDuration(options.durationSec, allowed));\n } else {\n payload.duration = snapDuration(options.durationSec, allowed);\n }\n }\n } else if (schema.durationType === 'string') {\n payload.duration = String(options.durationSec);\n } else {\n payload.duration = options.durationSec;\n }\n }\n\n if (options?.resolution) {\n payload.resolution = options.resolution;\n }\n\n if (options?.userParams) {\n const schemaFields = new Set(schema.params.map(p => p.field));\n for (const [key, value] of Object.entries(options.userParams)) {\n if (value !== undefined && schemaFields.has(key)) {\n payload[key] = value;\n }\n }\n }\n\n Object.assign(payload, buildRouteMediaFields(schema, options?.references || [], options?.slots || []));\n\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n\n return payload;\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const routeSchema = getRouteSchema(modelEndpoint);\n if (routeSchema) {\n return buildRunningHubRoutePayload(prompt, routeSchema, key, options || {});\n }\n throw new Error(`RunningHub 路由 ${modelEndpoint} 未通过验证(未在 Route Catalog 中登记),暂不可执行。请先在 Route Catalog 中补齐该路由的 Capability Schema 后再使用。`);\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(grok-|xai)/i.test(model)) return 'xai';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal; aspectRatio?: VideoAspectRatio; resolution?: string; quality?: string; webSearch?: boolean },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (isGoogleImageEditModel(model)) {\n return editImage(refs, prompt, undefined, key?.key, options?.signal, {\n model,\n aspectRatio: options?.aspectRatio === 'adaptive' ? undefined : options?.aspectRatio,\n imageSize: options?.resolution,\n webSearch: options?.webSearch,\n baseUrl: key?.baseUrl,\n });\n }\n return generateImageFromText(prompt, key?.key, options?.signal, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n aspectRatio: options?.aspectRatio,\n resolution: options?.resolution,\n userParams: {\n ...(options?.webSearch !== undefined ? { webSearch: options.webSearch } : {}),\n ...(options?.quality !== undefined ? { hd: options.quality === 'hd' || options.quality === 'high' } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const imageSize = getOpenAIImageSize(options?.resolution, options?.aspectRatio);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key, undefined, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport class SeedanceSubmissionUnknownError extends Error {\n readonly code = 'SEEDANCE_SUBMISSION_UNKNOWN';\n constructor(cause?: unknown) {\n super('Seedance 提交结果未知:供应商可能已经创建并计费。已禁止自动重试或切换线路,请先到任务记录核对。', { cause });\n this.name = 'SeedanceSubmissionUnknownError';\n }\n}\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const rawSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n const slots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, 'volcengine');\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n let createRes: Response;\n try {\n createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n } catch (error) {\n throw new SeedanceSubmissionUnknownError(error);\n }\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 区分官方 Ark 与第三方 OpenAI-compatible 网关:\n * - Ark 官方:baseUrl 含 `ark.*.volces.com` 或路径含 `/api/v3`,惯例 DELETE `/contents/generations/tasks/{id}`\n * - 网关(如 Scnet):路径含 `/api/llm/v1`,惯例 POST `/tasks/{id}/cancel`\n * - 其他:fallback 到 Ark 风格(保留向后兼容)。\n */\nfunction detectSeedanceCancelRoute(baseUrl: string): 'ark' | 'gateway' {\n const lower = (baseUrl || '').toLowerCase();\n if (lower.includes('/api/llm/v1') || lower.includes('/v1/tasks')) return 'gateway';\n if (lower.includes('volces.com') || lower.includes('/api/v3')) return 'ark';\n return 'ark';\n}\n\nexport type SeedanceVideoCancelResult = {\n canceled: boolean;\n reason: 'ok' | 'not_queued' | 'not_cancellable' | 'unsupported' | 'network_error';\n upstreamStillRunning?: boolean;\n message?: string;\n raw?: unknown;\n};\n\n/**\n * 取消 Seedance 上游任务。仅对 queued 阶段的成功取消返回 `canceled: true`。\n * 上游已开始 processing、或上游不支持取消时返回 `canceled:false, upstreamStillRunning:true`,\n * 让前端走“放弃等待”的退路(仅本地 abortSignal 不再轮询上游)。\n */\nexport async function cancelSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal; reason?: string },\n): Promise<SeedanceVideoCancelResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const route = detectSeedanceCancelRoute(baseUrl);\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n if (route === 'ark') {\n // 官方 Ark:DELETE /contents/generations/tasks/{id}。409=不可取消;200/204=已取消。\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } else {\n // 网关风格:POST /tasks/{id}/cancel;body 可携带 reason。\n res = await fetch(`${baseUrl}/tasks/${taskId}/cancel`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },\n body: JSON.stringify({ reason: options?.reason || 'user_abort' }),\n signal: options?.signal,\n });\n }\n } catch (error) {\n return {\n canceled: false,\n reason: 'network_error',\n upstreamStillRunning: true,\n message: error instanceof Error ? error.message : '网络错误,无法确认取消结果',\n };\n }\n\n if (res.status === 405 || res.status === 404) {\n // 网关/官方未实现取消端点\n return {\n canceled: false,\n reason: 'unsupported',\n upstreamStillRunning: true,\n message: '当前 Seedance 渠道不支持取消,请等待完成或放弃等待',\n };\n }\n\n let raw: any = undefined;\n try {\n raw = await res.json();\n } catch {\n // 204 No Content 等成功无 body 的情况\n }\n\n if (res.ok) {\n // Scnet 风格可能返回 { code: 0 } 或 { success: true }; Ark 200 返回 { cancelled: true }\n // 兼容两种解析:cancelled=true / code===0 / success===true 视为成功\n const okFlag =\n raw?.cancelled === true\n || raw?.data?.cancelled === true\n || raw?.code === 0\n || raw?.success === true\n || (route === 'ark' && res.status === 200 && raw == null);\n return {\n canceled: Boolean(okFlag),\n reason: okFlag ? 'ok' : 'not_cancellable',\n upstreamStillRunning: !okFlag,\n message: okFlag ? undefined : (raw?.message || raw?.msg || '上游响应未确认取消'),\n raw,\n };\n }\n\n if (res.status === 409 || res.status === 400) {\n // Ark: 409 task_not_cancellable (已 processing);网关: 400 已开始\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || '任务已开始执行,上游无法取消',\n raw,\n };\n }\n\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || `上游返回 ${res.status}`,\n raw,\n };\n}\n\nexport type SeedanceVideoReconcileResult = {\n status: 'succeeded' | 'failed' | 'cancelled' | 'expired' | 'unknown';\n amount?: number;\n currency?: string;\n totalTokens?: number;\n reason?: string;\n raw?: unknown;\n};\n\nfunction parseSeedanceVideoUsage(raw: any, fallbackStatus: SeedanceVideoReconcileResult['status'] = 'unknown'): SeedanceVideoReconcileResult {\n const remoteStatus = String(raw?.status || raw?.data?.status || raw?.data?.task_status || raw?.task?.status || raw?.state || '').toLowerCase();\n let status: SeedanceVideoReconcileResult['status'] = fallbackStatus;\n if (['succeeded', 'succeed', 'success', 'completed', 'complete', 'done'].includes(remoteStatus)) status = 'succeeded';\n else if (['cancelled', 'canceled'].includes(remoteStatus)) status = 'cancelled';\n else if (['failed', 'fail', 'error'].includes(remoteStatus)) status = 'failed';\n else if (['expired', 'timeout'].includes(remoteStatus)) status = 'expired';\n const billingRaw = raw?.data?.billing || raw?.data?.usage?.billing || raw?.billing || raw?.usage?.billing;\n const amount = typeof billingRaw?.amount === 'number' ? billingRaw.amount : undefined;\n const currency = typeof billingRaw?.currency === 'string' ? billingRaw.currency : raw?.data?.currency || raw?.currency;\n const totalTokens = typeof raw?.usage?.total_tokens === 'number' ? raw.usage.total_tokens\n : typeof raw?.data?.usage?.total_tokens === 'number' ? raw.data.usage.total_tokens\n : typeof raw?.usage?.token_usage?.total_tokens === 'number' ? raw.usage.token_usage.total_tokens\n : undefined;\n return { status, amount, currency, totalTokens, raw };\n}\n\n/**\n * 查询上游任务的真实账单/token 使用量。复用 GET 任务查询接口;不修改 handle。\n *\n * - 网关(Scnet/Lemondata)常见字段:`data.billing.amount` / `data.usage.billing.amount` + `currency`\n * - Volcengine Ark 官方常见字段:`usage.total_tokens`(仅 token,无 billing 金额)\n *\n * 调用方拿到返回值后:\n * - amount 存在 → actualCost = amount,billableState = 'actual'\n * - 仅 totalTokens → actualTokens = totalTokens,billableState 保持 'unknown'\n * - status === 'unknown' → 不变更 usageRecord\n */\nexport async function reconcileSeedanceVideoUsage(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoReconcileResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } catch (error) {\n return { status: 'unknown', reason: error instanceof Error ? error.message : '网络错误' };\n }\n if (!res.ok) {\n return { status: 'unknown', reason: `上游返回 ${res.status}` };\n }\n let raw: any;\n try {\n raw = await res.json();\n } catch {\n return { status: 'unknown', reason: '响应不是合法 JSON' };\n }\n\n return parseSeedanceVideoUsage(raw);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const runningHubEndpoint = provider === 'runningHub'\n ? assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model)\n : undefined;\n const legacyReferences = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const rawSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(legacyReferences);\n const multimodalSlots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, provider, runningHubEndpoint);\n const references: VideoImage[] = multimodalSlots.filter(slot => slot.kind === 'image').map(slot => ({\n href: slot.href,\n mimeType: slot.mimeType,\n slotRole: String(slot.role || 'unassigned'),\n label: slot.label,\n }));\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const usesFirstFrame = !options?.generationSubmode || options.generationSubmode === 'image-to-video' || options.generationSubmode === 'first-last-frame';\n const firstFrame = usesFirstFrame\n ? references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined)\n : undefined;\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = runningHubEndpoint!;\n const allImageRefs = references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n userParams: {\n ...(options?.generateAudio !== undefined ? { generateAudio: options.generateAudio } : {}),\n ...(options?.seed !== undefined ? { seed: options.seed } : {}),\n ...(options?.returnLastFrame !== undefined ? { returnLastFrame: options.returnLastFrame } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n const lastFrameSlot = options?.generationSubmode === 'first-last-frame'\n ? multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'last_frame')\n : undefined;\n const referenceImages = options?.generationSubmode === 'reference-to-video'\n ? multimodalSlots.filter(slot => slot.kind === 'image' && slot.role !== 'first_frame' && slot.role !== 'last_frame').slice(0, 3)\n : [];\n return generateVideo(prompt, aspectRatio === '9:16' ? '9:16' : '16:9', onProgress, firstFrame, key?.key, {\n model,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n generateAudio: options?.generateAudio,\n signal: options?.signal,\n lastFrame: lastFrameSlot ? { href: lastFrameSlot.href, mimeType: lastFrameSlot.mimeType } : undefined,\n referenceImages: referenceImages.map(reference => ({ href: reference.href, mimeType: reference.mimeType })),\n baseUrl: key?.baseUrl,\n });\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl, { signal: options?.signal });\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: options?.resolution?.toLowerCase() === '1080p' ? 'pro' : 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: String(options?.durationSec || 5),\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl, { signal: options?.signal });\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'xai') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const submode: ProductModelMode = options?.generationSubmode || 'text-to-video';\n\n onProgress('Submitting video generation task...');\n let createPath: string;\n const createBody: Record<string, unknown> = { model, prompt };\n\n if (submode === 'video-extension') {\n createPath = `${baseUrl}/videos/extensions`;\n const videoSlot = multimodalSlots.find(slot => slot.kind === 'video');\n if (!videoSlot) {\n throw new Error('xAI 视频扩展至少需要 1 个视频素材作为驱动视频。');\n }\n createBody.video = { url: videoSlot.href };\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n } else {\n createPath = `${baseUrl}/videos/generations`;\n if (aspectRatio) {\n createBody.aspect_ratio = aspectRatio;\n }\n if (options?.resolution) {\n createBody.resolution = options.resolution;\n }\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n if (firstFrame) {\n createBody.image = { url: firstFrame.href };\n }\n }\n\n const createRes = await fetch(createPath, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'xAI 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'xAI 视频生成创建响应');\n const requestId = createJson?.request_id;\n if (!requestId) {\n throw new Error('xAI 视频生成未返回 request_id');\n }\n\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const xaiPollStart = Date.now();\n while (true) {\n if (Date.now() - xaiPollStart > 600_000) {\n throw new Error('xAI 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'xAI 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'xAI 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'failed') {\n throw new Error(`xAI 视频生成失败: ${queryJson?.error || queryJson?.message || 'Unknown error'}`);\n }\n if (status === 'done') {\n videoUrl = queryJson?.output?.url;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!videoUrl) {\n throw new Error('xAI 视频生成完成但未返回视频 URL');\n }\n\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n await options?.onProviderTaskLifecycle?.({ phase: 'submitted', providerTaskId: handle.taskId, submittedAt: handle.createdAt });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n let cancelPromise: Promise<SeedanceVideoCancelResult> | undefined;\n const requestUpstreamCancel = () => cancelPromise ||= cancelSeedanceVideoTask(handle, key, { reason: 'user_abort' }).then(async result => {\n await options?.onProviderTaskLifecycle?.({ phase: 'cancelled', providerTaskId: handle.taskId, canceled: result.canceled, upstreamStillRunning: result.upstreamStillRunning, message: result.message });\n const usage = await reconcileSeedanceVideoUsage(handle, key);\n if (usage.status !== 'unknown' || usage.amount !== undefined || usage.totalTokens !== undefined) {\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n }\n return result;\n });\n const handleAbort = () => { void requestUpstreamCancel(); };\n options?.signal?.addEventListener('abort', handleAbort, { once: true });\n\n try {\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n const usage = parseSeedanceVideoUsage(result.raw, 'failed');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n const usage = parseSeedanceVideoUsage(result.raw, 'succeeded');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n await options?.onProviderTaskLifecycle?.({ phase: 'running', providerTaskId: handle.taskId, remoteStatus: result.remoteStatus });\n }\n } catch (error) {\n if (options?.signal?.aborted || error instanceof Error && error.name === 'AbortError') await requestUpstreamCancel();\n throw error;\n } finally {\n options?.signal?.removeEventListener('abort', handleAbort);\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const params = sanitizeProductGenerationParams(input.productModelId, {\n mode: input.generationSubmode,\n aspectRatio: input.aspectRatio,\n resolution: input.resolution,\n quality: input.quality,\n durationSec: input.durationSec,\n generateAudio: input.generateAudio,\n webSearch: input.webSearch,\n realPersonCheck: input.realPersonCheck,\n referenceCount: input.references?.length || 0,\n }, { provider: input.apiKeyPayload?.provider, routeId: input.modelId });\n if (capability === 'video') {\n const referenceKinds = (input.references || [])\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio' } => reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n .map(reference => reference.type);\n const issue = explainReferenceCompatibility(input.productModelId, params.mode as ProductModelMode, referenceKinds, { provider: input.apiKeyPayload?.provider, routeId: input.modelId });\n if (issue) throw new Error(issue);\n }\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: params.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: params.durationSec,\n resolution: params.resolution,\n generateAudio: params.generateAudio,\n watermark: input.watermark,\n generationSubmode: params.mode,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n onProviderTaskLifecycle: input.onProviderTaskLifecycle,\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, {\n signal: input.signal,\n aspectRatio: params.aspectRatio,\n resolution: params.resolution,\n quality: params.quality,\n webSearch: params.webSearch,\n });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "3a0e1c83309a33a22dac36b767f16368bb9040b3": {"t": 1785773453.488015, "n": 159961, "h": "686a79b0f16a6238", "c": "import type { AICapability, AIProvider, ProductModelMode, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\nimport { explainReferenceCompatibility, sanitizeProductGenerationParams } from './productModelCatalog';\nimport { getRouteSchema, getRouteDurations, type RouteCapabilitySchema, type RouteMediaSpec } from './runningHubRouteCatalog';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport type ProviderTaskLifecycleEvent =\n | { phase: 'submitted'; providerTaskId: string; submittedAt: number }\n | { phase: 'running'; providerTaskId: string; remoteStatus?: string }\n | { phase: 'cancelled'; providerTaskId: string; canceled: boolean; upstreamStillRunning?: boolean; message?: string }\n | { phase: 'usage'; providerTaskId: string; status: SeedanceVideoReconcileResult['status']; amount?: number; currency?: string; totalTokens?: number };\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n productModelId?: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n quality?: string;\n generationSubmode?: ProductModelMode;\n generateAudio?: boolean;\n watermark?: boolean;\n webSearch?: boolean;\n realPersonCheck?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image', 'gemini-3.1-flash-lite-image', 'gemini-3-pro-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-fast-generate-preview', 'veo-3.1-lite-generate-preview'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n xai: {\n text: ['grok-4-fast', 'grok-4'],\n image: ['grok-2-image'],\n video: ['grok-imagine-video', 'grok-imagine-video-1.5'],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '3:2' | '2:3' | '4:5' | '5:4' | '1:4' | '4:1' | '1:8' | '8:1' | '21:9' | '9:21' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\nconst OPENAI_IMAGE_SIZES: Record<'1K' | '2K' | '4K', Record<string, string>> = {\n '1K': { '1:1': '1024x1024', '3:2': '1536x1024', '2:3': '1024x1536', '16:9': '1792x1024', '9:16': '1024x1792', '4:3': '1344x1008', '3:4': '1008x1344' },\n '2K': { '1:1': '2048x2048', '3:2': '2048x1360', '2:3': '1360x2048', '16:9': '2048x1152', '9:16': '1152x2048', '4:3': '2048x1536', '3:4': '1536x2048' },\n '4K': { '1:1': '2880x2880', '3:2': '3520x2352', '2:3': '2352x3520', '16:9': '3840x2160', '9:16': '2160x3840', '4:3': '3312x2480', '3:4': '2480x3312' },\n};\n\nfunction getOpenAIImageSize(resolution?: string, aspectRatio?: VideoAspectRatio): string {\n const tier = resolution?.toUpperCase() === '4K' ? '4K' : resolution?.toUpperCase() === '2K' ? '2K' : '1K';\n return OPENAI_IMAGE_SIZES[tier][aspectRatio || '1:1'] || OPENAI_IMAGE_SIZES[tier]['1:1'];\n}\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance') || isRunningHubSeedance20Model(normalized)) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'runningHub' && (inferCapabilityFromModelName(model) === 'video' || isRunningHubVideoEndpoint(normalized))) {\n return { ...DEFAULT_VIDEO_CAPABILITY, durations: resolveRunningHubVideoDurations(normalized) };\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地工作流节点。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16'], // Veo 3.1 documented ratios\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n xai: ['1:1', '16:9', '9:16', '4:3', '3:4', '2:3', '3:2'], // Grok Imagine Video\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n xai: 'https://api.x.ai/v1',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^xai-/i.test(trimmed)) return 'xai';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n xai: 'xAI Grok',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction assertResolvedUpstreamModel(model: string): void {\n if (stripModelSelectionRef(model).trim().toLowerCase().startsWith('flovart:')) {\n throw new Error(`产品模型 ${model} 尚未解析为供应商上游模型。请检查该产品模型的 BYOK 映射。`);\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie') || normalized.includes('motion-control')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction adaptVideoSlotsForMode(slots: MultimodalSlot[], mode?: ProductModelMode, provider?: AIProvider, routeId?: string): MultimodalSlot[] {\n if (!mode) return slots;\n const images = slots.filter(slot => slot.kind === 'image');\n if (mode === 'text-to-video') return [];\n if (mode === 'image-to-video') {\n if (!images[0]) throw new Error('图生视频至少需要 1 张图片。');\n const routeImageSpec = provider === 'runningHub'\n ? getRouteSchema(routeId || '')?.media.find(spec => spec.kind === 'image' && spec.mapping === 'all_references')\n : undefined;\n if (routeImageSpec) return images.slice(0, routeImageSpec.max).map((image, index) => ({ ...image, role: index === 0 ? 'first_frame' : 'reference_image' }));\n return [{ ...images[0], role: 'first_frame' }];\n }\n if (mode === 'first-last-frame') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配首尾帧,请切换模式或重新映射线路。');\n if (images.length < 2) throw new Error('首尾帧模式需要 2 张图片。');\n return [{ ...images[0], role: 'first_frame' }, { ...images[1], role: 'last_frame' }];\n }\n if (mode === 'reference-to-video') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配全能参考,请切换模式或重新映射线路。');\n const references = provider === 'google' ? images.slice(0, 3) : slots;\n if (!references.length) throw new Error(provider === 'google' ? 'Veo 全能参考至少需要 1 张图片。' : '全能参考至少需要 1 个媒体素材。');\n return references.map(slot => ({\n ...slot,\n role: slot.kind === 'video' ? 'reference_video' : slot.kind === 'audio' ? 'reference_audio' : 'reference_image',\n }));\n }\n return slots;\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction resolveRunningHubVideoDurations(model: string): number[] {\n const catalogDurations = getRouteDurations(model);\n return catalogDurations ?? [];\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRouteMediaFields(\n schema: RouteCapabilitySchema,\n references: VideoImage[],\n slots: MultimodalSlot[],\n): Record<string, unknown> {\n const output: Record<string, unknown> = {};\n for (const spec of schema.media) {\n if (spec.mapping === 'all_references') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (spec.serialization === 'array') {\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (urls[0]) {\n output[spec.field] = assertRunningHubPublicUrl(urls[0], spec.field);\n }\n } else if (spec.mapping === 'first_last_frame') {\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n if (spec.field === 'firstFrameUrl') {\n if (spec.required && !first?.href) {\n throw new Error(`RunningHub ${schema.routeId} 需要首帧图片。`);\n }\n if (first?.href) output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n } else if (spec.field === 'lastFrameUrl') {\n if (last?.href) output[spec.field] = assertRunningHubPublicUrl(last.href, spec.field);\n }\n } else if (spec.mapping === 'first_frame_only') {\n const first = references[0];\n if (first?.href) {\n output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n }\n } else if (spec.mapping === 'reference_images') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (spec.mapping === 'multimodal') {\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, i) => assertRunningHubPublicUrl(href, `${spec.field}[${i}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n if (spec.kind === 'image' && imageUrls.length > 0) output[spec.field] = uniqueLimitedUrls(imageUrls, spec.max);\n if (spec.kind === 'video' && videoUrls.length > 0) output[spec.field] = uniqueLimitedUrls(videoUrls, spec.max);\n if (spec.kind === 'audio' && audioUrls.length > 0) output[spec.field] = uniqueLimitedUrls(audioUrls, spec.max);\n }\n }\n return output;\n}\n\nfunction snapDuration(requested: number, allowed: number[]): number {\n return allowed.reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, allowed[0]);\n}\n\nfunction buildRunningHubRoutePayload(\n prompt: string,\n schema: RouteCapabilitySchema,\n key: UserApiKey | undefined,\n options: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const promptField = extra.runningHubPromptField || schema.promptField;\n\n const payload: Record<string, unknown> = {};\n\n if (!disableDefaults) {\n if (schema.resolutionDefault) payload.resolution = schema.resolutionDefault;\n if (schema.durationDefault !== undefined) payload.duration = schema.durationDefault;\n if (schema.aspectRatioField && schema.aspectRatioValues?.[0]) payload[schema.aspectRatioField] = schema.aspectRatioValues[0];\n for (const param of schema.params) {\n if (param.default !== undefined) {\n if (param.type === 'string' && param.default === 'all') {\n payload[param.field] = ['all'];\n } else {\n payload[param.field] = param.default;\n }\n }\n }\n }\n\n Object.assign(payload, configuredPayload);\n\n payload[promptField] = prompt;\n\n if (schema.aspectRatioField && options?.aspectRatio) {\n payload[schema.aspectRatioField] = options.aspectRatio;\n }\n\n if (schema.durationType && options?.durationSec && options.durationSec > 0) {\n if (schema.durationValues && schema.durationValues.length > 0) {\n const allowed = schema.durationValues.map(v => Number(v)).filter(n => !isNaN(n));\n if (allowed.length > 0) {\n if (schema.durationType === 'string') {\n payload.duration = String(snapDuration(options.durationSec, allowed));\n } else {\n payload.duration = snapDuration(options.durationSec, allowed);\n }\n }\n } else if (schema.durationType === 'string') {\n payload.duration = String(options.durationSec);\n } else {\n payload.duration = options.durationSec;\n }\n }\n\n if (options?.resolution) {\n payload.resolution = options.resolution;\n }\n\n if (options?.userParams) {\n const schemaFields = new Set(schema.params.map(p => p.field));\n for (const [key, value] of Object.entries(options.userParams)) {\n if (value !== undefined && schemaFields.has(key)) {\n payload[key] = value;\n }\n }\n }\n\n Object.assign(payload, buildRouteMediaFields(schema, options?.references || [], options?.slots || []));\n\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n\n return payload;\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const routeSchema = getRouteSchema(modelEndpoint);\n if (routeSchema) {\n return buildRunningHubRoutePayload(prompt, routeSchema, key, options || {});\n }\n throw new Error(`RunningHub 路由 ${modelEndpoint} 未通过验证(未在 Route Catalog 中登记),暂不可执行。请先在 Route Catalog 中补齐该路由的 Capability Schema 后再使用。`);\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(grok-|xai)/i.test(model)) return 'xai';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal; aspectRatio?: VideoAspectRatio; resolution?: string; quality?: string; webSearch?: boolean },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (isGoogleImageEditModel(model)) {\n return editImage(refs, prompt, undefined, key?.key, options?.signal, {\n model,\n aspectRatio: options?.aspectRatio === 'adaptive' ? undefined : options?.aspectRatio,\n imageSize: options?.resolution,\n webSearch: options?.webSearch,\n baseUrl: key?.baseUrl,\n });\n }\n return generateImageFromText(prompt, key?.key, options?.signal, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n aspectRatio: options?.aspectRatio,\n resolution: options?.resolution,\n userParams: {\n ...(options?.webSearch !== undefined ? { webSearch: options.webSearch } : {}),\n ...(options?.quality !== undefined ? { hd: options.quality === 'hd' || options.quality === 'high' } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const imageSize = getOpenAIImageSize(options?.resolution, options?.aspectRatio);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key, undefined, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport class SeedanceSubmissionUnknownError extends Error {\n readonly code = 'SEEDANCE_SUBMISSION_UNKNOWN';\n constructor(cause?: unknown) {\n super('Seedance 提交结果未知:供应商可能已经创建并计费。已禁止自动重试或切换线路,请先到任务记录核对。', { cause });\n this.name = 'SeedanceSubmissionUnknownError';\n }\n}\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const rawSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n const slots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, 'volcengine');\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n let createRes: Response;\n try {\n createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n } catch (error) {\n throw new SeedanceSubmissionUnknownError(error);\n }\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 区分官方 Ark 与第三方 OpenAI-compatible 网关:\n * - Ark 官方:baseUrl 含 `ark.*.volces.com` 或路径含 `/api/v3`,惯例 DELETE `/contents/generations/tasks/{id}`\n * - 网关(如 Scnet):路径含 `/api/llm/v1`,惯例 POST `/tasks/{id}/cancel`\n * - 其他:fallback 到 Ark 风格(保留向后兼容)。\n */\nfunction detectSeedanceCancelRoute(baseUrl: string): 'ark' | 'gateway' {\n const lower = (baseUrl || '').toLowerCase();\n if (lower.includes('/api/llm/v1') || lower.includes('/v1/tasks')) return 'gateway';\n if (lower.includes('volces.com') || lower.includes('/api/v3')) return 'ark';\n return 'ark';\n}\n\nexport type SeedanceVideoCancelResult = {\n canceled: boolean;\n reason: 'ok' | 'not_queued' | 'not_cancellable' | 'unsupported' | 'network_error';\n upstreamStillRunning?: boolean;\n message?: string;\n raw?: unknown;\n};\n\n/**\n * 取消 Seedance 上游任务。仅对 queued 阶段的成功取消返回 `canceled: true`。\n * 上游已开始 processing、或上游不支持取消时返回 `canceled:false, upstreamStillRunning:true`,\n * 让前端走“放弃等待”的退路(仅本地 abortSignal 不再轮询上游)。\n */\nexport async function cancelSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal; reason?: string },\n): Promise<SeedanceVideoCancelResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const route = detectSeedanceCancelRoute(baseUrl);\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n if (route === 'ark') {\n // 官方 Ark:DELETE /contents/generations/tasks/{id}。409=不可取消;200/204=已取消。\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } else {\n // 网关风格:POST /tasks/{id}/cancel;body 可携带 reason。\n res = await fetch(`${baseUrl}/tasks/${taskId}/cancel`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },\n body: JSON.stringify({ reason: options?.reason || 'user_abort' }),\n signal: options?.signal,\n });\n }\n } catch (error) {\n return {\n canceled: false,\n reason: 'network_error',\n upstreamStillRunning: true,\n message: error instanceof Error ? error.message : '网络错误,无法确认取消结果',\n };\n }\n\n if (res.status === 405 || res.status === 404) {\n // 网关/官方未实现取消端点\n return {\n canceled: false,\n reason: 'unsupported',\n upstreamStillRunning: true,\n message: '当前 Seedance 渠道不支持取消,请等待完成或放弃等待',\n };\n }\n\n let raw: any = undefined;\n try {\n raw = await res.json();\n } catch {\n // 204 No Content 等成功无 body 的情况\n }\n\n if (res.ok) {\n // Scnet 风格可能返回 { code: 0 } 或 { success: true }; Ark 200 返回 { cancelled: true }\n // 兼容两种解析:cancelled=true / code===0 / success===true 视为成功\n const okFlag =\n raw?.cancelled === true\n || raw?.data?.cancelled === true\n || raw?.code === 0\n || raw?.success === true\n || (route === 'ark' && res.status === 200 && raw == null);\n return {\n canceled: Boolean(okFlag),\n reason: okFlag ? 'ok' : 'not_cancellable',\n upstreamStillRunning: !okFlag,\n message: okFlag ? undefined : (raw?.message || raw?.msg || '上游响应未确认取消'),\n raw,\n };\n }\n\n if (res.status === 409 || res.status === 400) {\n // Ark: 409 task_not_cancellable (已 processing);网关: 400 已开始\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || '任务已开始执行,上游无法取消',\n raw,\n };\n }\n\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || `上游返回 ${res.status}`,\n raw,\n };\n}\n\nexport type SeedanceVideoReconcileResult = {\n status: 'succeeded' | 'failed' | 'cancelled' | 'expired' | 'unknown';\n amount?: number;\n currency?: string;\n totalTokens?: number;\n reason?: string;\n raw?: unknown;\n};\n\nfunction parseSeedanceVideoUsage(raw: any, fallbackStatus: SeedanceVideoReconcileResult['status'] = 'unknown'): SeedanceVideoReconcileResult {\n const remoteStatus = String(raw?.status || raw?.data?.status || raw?.data?.task_status || raw?.task?.status || raw?.state || '').toLowerCase();\n let status: SeedanceVideoReconcileResult['status'] = fallbackStatus;\n if (['succeeded', 'succeed', 'success', 'completed', 'complete', 'done'].includes(remoteStatus)) status = 'succeeded';\n else if (['cancelled', 'canceled'].includes(remoteStatus)) status = 'cancelled';\n else if (['failed', 'fail', 'error'].includes(remoteStatus)) status = 'failed';\n else if (['expired', 'timeout'].includes(remoteStatus)) status = 'expired';\n const billingRaw = raw?.data?.billing || raw?.data?.usage?.billing || raw?.billing || raw?.usage?.billing;\n const amount = typeof billingRaw?.amount === 'number' ? billingRaw.amount : undefined;\n const currency = typeof billingRaw?.currency === 'string' ? billingRaw.currency : raw?.data?.currency || raw?.currency;\n const totalTokens = typeof raw?.usage?.total_tokens === 'number' ? raw.usage.total_tokens\n : typeof raw?.data?.usage?.total_tokens === 'number' ? raw.data.usage.total_tokens\n : typeof raw?.usage?.token_usage?.total_tokens === 'number' ? raw.usage.token_usage.total_tokens\n : undefined;\n return { status, amount, currency, totalTokens, raw };\n}\n\n/**\n * 查询上游任务的真实账单/token 使用量。复用 GET 任务查询接口;不修改 handle。\n *\n * - 网关(Scnet/Lemondata)常见字段:`data.billing.amount` / `data.usage.billing.amount` + `currency`\n * - Volcengine Ark 官方常见字段:`usage.total_tokens`(仅 token,无 billing 金额)\n *\n * 调用方拿到返回值后:\n * - amount 存在 → actualCost = amount,billableState = 'actual'\n * - 仅 totalTokens → actualTokens = totalTokens,billableState 保持 'unknown'\n * - status === 'unknown' → 不变更 usageRecord\n */\nexport async function reconcileSeedanceVideoUsage(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoReconcileResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } catch (error) {\n return { status: 'unknown', reason: error instanceof Error ? error.message : '网络错误' };\n }\n if (!res.ok) {\n return { status: 'unknown', reason: `上游返回 ${res.status}` };\n }\n let raw: any;\n try {\n raw = await res.json();\n } catch {\n return { status: 'unknown', reason: '响应不是合法 JSON' };\n }\n\n return parseSeedanceVideoUsage(raw);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const runningHubEndpoint = provider === 'runningHub'\n ? assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model)\n : undefined;\n const legacyReferences = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const rawSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(legacyReferences);\n const multimodalSlots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, provider, runningHubEndpoint);\n const references: VideoImage[] = multimodalSlots.filter(slot => slot.kind === 'image').map(slot => ({\n href: slot.href,\n mimeType: slot.mimeType,\n slotRole: String(slot.role || 'unassigned'),\n label: slot.label,\n }));\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const usesFirstFrame = !options?.generationSubmode || options.generationSubmode === 'image-to-video' || options.generationSubmode === 'first-last-frame';\n const firstFrame = usesFirstFrame\n ? references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined)\n : undefined;\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = runningHubEndpoint!;\n const allImageRefs = references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n userParams: {\n ...(options?.generateAudio !== undefined ? { generateAudio: options.generateAudio } : {}),\n ...(options?.seed !== undefined ? { seed: options.seed } : {}),\n ...(options?.returnLastFrame !== undefined ? { returnLastFrame: options.returnLastFrame } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n const lastFrameSlot = options?.generationSubmode === 'first-last-frame'\n ? multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'last_frame')\n : undefined;\n const referenceImages = options?.generationSubmode === 'reference-to-video'\n ? multimodalSlots.filter(slot => slot.kind === 'image' && slot.role !== 'first_frame' && slot.role !== 'last_frame').slice(0, 3)\n : [];\n return generateVideo(prompt, aspectRatio === '9:16' ? '9:16' : '16:9', onProgress, firstFrame, key?.key, {\n model,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n generateAudio: options?.generateAudio,\n signal: options?.signal,\n lastFrame: lastFrameSlot ? { href: lastFrameSlot.href, mimeType: lastFrameSlot.mimeType } : undefined,\n referenceImages: referenceImages.map(reference => ({ href: reference.href, mimeType: reference.mimeType })),\n baseUrl: key?.baseUrl,\n });\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl, { signal: options?.signal });\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: options?.resolution?.toLowerCase() === '1080p' ? 'pro' : 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: String(options?.durationSec || 5),\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl, { signal: options?.signal });\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'xai') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const submode: ProductModelMode = options?.generationSubmode || 'text-to-video';\n\n onProgress('Submitting video generation task...');\n let createPath: string;\n const createBody: Record<string, unknown> = { model, prompt };\n\n if (submode === 'video-extension') {\n createPath = `${baseUrl}/videos/extensions`;\n const videoSlot = multimodalSlots.find(slot => slot.kind === 'video');\n if (!videoSlot) {\n throw new Error('xAI 视频扩展至少需要 1 个视频素材作为驱动视频。');\n }\n createBody.video = { url: videoSlot.href };\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n } else {\n createPath = `${baseUrl}/videos/generations`;\n if (aspectRatio) {\n createBody.aspect_ratio = aspectRatio;\n }\n if (options?.resolution) {\n createBody.resolution = options.resolution;\n }\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n if (firstFrame) {\n createBody.image = { url: firstFrame.href };\n }\n }\n\n const createRes = await fetch(createPath, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'xAI 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'xAI 视频生成创建响应');\n const requestId = createJson?.request_id;\n if (!requestId) {\n throw new Error('xAI 视频生成未返回 request_id');\n }\n\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const xaiPollStart = Date.now();\n while (true) {\n if (Date.now() - xaiPollStart > 600_000) {\n throw new Error('xAI 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'xAI 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'xAI 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'failed') {\n throw new Error(`xAI 视频生成失败: ${queryJson?.error || queryJson?.message || 'Unknown error'}`);\n }\n if (status === 'done') {\n videoUrl = queryJson?.output?.url;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!videoUrl) {\n throw new Error('xAI 视频生成完成但未返回视频 URL');\n }\n\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n await options?.onProviderTaskLifecycle?.({ phase: 'submitted', providerTaskId: handle.taskId, submittedAt: handle.createdAt });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n let cancelPromise: Promise<SeedanceVideoCancelResult> | undefined;\n const requestUpstreamCancel = () => cancelPromise ||= cancelSeedanceVideoTask(handle, key, { reason: 'user_abort' }).then(async result => {\n await options?.onProviderTaskLifecycle?.({ phase: 'cancelled', providerTaskId: handle.taskId, canceled: result.canceled, upstreamStillRunning: result.upstreamStillRunning, message: result.message });\n const usage = await reconcileSeedanceVideoUsage(handle, key);\n if (usage.status !== 'unknown' || usage.amount !== undefined || usage.totalTokens !== undefined) {\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n }\n return result;\n });\n const handleAbort = () => { void requestUpstreamCancel(); };\n options?.signal?.addEventListener('abort', handleAbort, { once: true });\n\n try {\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n const usage = parseSeedanceVideoUsage(result.raw, 'failed');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n const usage = parseSeedanceVideoUsage(result.raw, 'succeeded');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n await options?.onProviderTaskLifecycle?.({ phase: 'running', providerTaskId: handle.taskId, remoteStatus: result.remoteStatus });\n }\n } catch (error) {\n if (options?.signal?.aborted || error instanceof Error && error.name === 'AbortError') await requestUpstreamCancel();\n throw error;\n } finally {\n options?.signal?.removeEventListener('abort', handleAbort);\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const params = sanitizeProductGenerationParams(input.productModelId, {\n mode: input.generationSubmode,\n aspectRatio: input.aspectRatio,\n resolution: input.resolution,\n quality: input.quality,\n durationSec: input.durationSec,\n generateAudio: input.generateAudio,\n webSearch: input.webSearch,\n realPersonCheck: input.realPersonCheck,\n referenceCount: input.references?.length || 0,\n }, { provider: input.apiKeyPayload?.provider, routeId: input.modelId });\n if (capability === 'video') {\n const referenceKinds = (input.references || [])\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio' } => reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n .map(reference => reference.type);\n const issue = explainReferenceCompatibility(input.productModelId, params.mode as ProductModelMode, referenceKinds, { provider: input.apiKeyPayload?.provider, routeId: input.modelId });\n if (issue) throw new Error(issue);\n }\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: params.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: params.durationSec,\n resolution: params.resolution,\n generateAudio: params.generateAudio,\n watermark: input.watermark,\n generationSubmode: params.mode,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n onProviderTaskLifecycle: input.onProviderTaskLifecycle,\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, {\n signal: input.signal,\n aspectRatio: params.aspectRatio,\n resolution: params.resolution,\n quality: params.quality,\n webSearch: params.webSearch,\n });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "0d3dd8529a990b5203660435370872d37bbb8b7e": {"t": 1785773455.3714094, "n": 158733, "h": "e40ff9d1e7508cb5", "c": "import type { AICapability, AIProvider, ProductModelMode, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\nimport { sanitizeProductGenerationParams } from './productModelCatalog';\nimport { getRouteSchema, getRouteDurations, type RouteCapabilitySchema, type RouteMediaSpec } from './runningHubRouteCatalog';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport type ProviderTaskLifecycleEvent =\n | { phase: 'submitted'; providerTaskId: string; submittedAt: number }\n | { phase: 'running'; providerTaskId: string; remoteStatus?: string }\n | { phase: 'cancelled'; providerTaskId: string; canceled: boolean; upstreamStillRunning?: boolean; message?: string }\n | { phase: 'usage'; providerTaskId: string; status: SeedanceVideoReconcileResult['status']; amount?: number; currency?: string; totalTokens?: number };\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n productModelId?: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n quality?: string;\n generationSubmode?: ProductModelMode;\n generateAudio?: boolean;\n watermark?: boolean;\n webSearch?: boolean;\n realPersonCheck?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image', 'gemini-3.1-flash-lite-image', 'gemini-3-pro-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-fast-generate-preview', 'veo-3.1-lite-generate-preview'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n xai: {\n text: ['grok-4-fast', 'grok-4'],\n image: ['grok-2-image'],\n video: ['grok-imagine-video', 'grok-imagine-video-1.5'],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '3:2' | '2:3' | '4:5' | '5:4' | '1:4' | '4:1' | '1:8' | '8:1' | '21:9' | '9:21' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\nconst OPENAI_IMAGE_SIZES: Record<'1K' | '2K' | '4K', Record<string, string>> = {\n '1K': { '1:1': '1024x1024', '3:2': '1536x1024', '2:3': '1024x1536', '16:9': '1792x1024', '9:16': '1024x1792', '4:3': '1344x1008', '3:4': '1008x1344' },\n '2K': { '1:1': '2048x2048', '3:2': '2048x1360', '2:3': '1360x2048', '16:9': '2048x1152', '9:16': '1152x2048', '4:3': '2048x1536', '3:4': '1536x2048' },\n '4K': { '1:1': '2880x2880', '3:2': '3520x2352', '2:3': '2352x3520', '16:9': '3840x2160', '9:16': '2160x3840', '4:3': '3312x2480', '3:4': '2480x3312' },\n};\n\nfunction getOpenAIImageSize(resolution?: string, aspectRatio?: VideoAspectRatio): string {\n const tier = resolution?.toUpperCase() === '4K' ? '4K' : resolution?.toUpperCase() === '2K' ? '2K' : '1K';\n return OPENAI_IMAGE_SIZES[tier][aspectRatio || '1:1'] || OPENAI_IMAGE_SIZES[tier]['1:1'];\n}\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance') || isRunningHubSeedance20Model(normalized)) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'runningHub' && (inferCapabilityFromModelName(model) === 'video' || isRunningHubVideoEndpoint(normalized))) {\n return { ...DEFAULT_VIDEO_CAPABILITY, durations: resolveRunningHubVideoDurations(normalized) };\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地工作流节点。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n xai: ['1:1', '16:9', '9:16', '4:3', '3:4', '2:3', '3:2'], // Grok Imagine Video\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n xai: 'https://api.x.ai/v1',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^xai-/i.test(trimmed)) return 'xai';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n xai: 'xAI Grok',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction assertResolvedUpstreamModel(model: string): void {\n if (stripModelSelectionRef(model).trim().toLowerCase().startsWith('flovart:')) {\n throw new Error(`产品模型 ${model} 尚未解析为供应商上游模型。请检查该产品模型的 BYOK 映射。`);\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie') || normalized.includes('motion-control')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction adaptVideoSlotsForMode(slots: MultimodalSlot[], mode?: ProductModelMode, provider?: AIProvider): MultimodalSlot[] {\n if (!mode) return slots;\n const images = slots.filter(slot => slot.kind === 'image');\n if (mode === 'text-to-video') return [];\n if (mode === 'image-to-video') {\n if (!images[0]) throw new Error('图生视频至少需要 1 张图片。');\n return [{ ...images[0], role: 'first_frame' }];\n }\n if (mode === 'first-last-frame') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配首尾帧,请切换模式或重新映射线路。');\n if (images.length < 2) throw new Error('首尾帧模式需要 2 张图片。');\n return [{ ...images[0], role: 'first_frame' }, { ...images[1], role: 'last_frame' }];\n }\n if (mode === 'reference-to-video') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配全能参考,请切换模式或重新映射线路。');\n const references = provider === 'google' ? images.slice(0, 3) : slots;\n if (!references.length) throw new Error(provider === 'google' ? 'Veo 全能参考至少需要 1 张图片。' : '全能参考至少需要 1 个媒体素材。');\n return references.map(slot => ({\n ...slot,\n role: slot.kind === 'video' ? 'reference_video' : slot.kind === 'audio' ? 'reference_audio' : 'reference_image',\n }));\n }\n return slots;\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction resolveRunningHubVideoDurations(model: string): number[] {\n const catalogDurations = getRouteDurations(model);\n return catalogDurations ?? [];\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRouteMediaFields(\n schema: RouteCapabilitySchema,\n references: VideoImage[],\n slots: MultimodalSlot[],\n): Record<string, unknown> {\n const output: Record<string, unknown> = {};\n for (const spec of schema.media) {\n if (spec.mapping === 'all_references') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (spec.serialization === 'array') {\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (urls[0]) {\n output[spec.field] = assertRunningHubPublicUrl(urls[0], spec.field);\n }\n } else if (spec.mapping === 'first_last_frame') {\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n if (spec.field === 'firstFrameUrl') {\n if (spec.required && !first?.href) {\n throw new Error(`RunningHub ${schema.routeId} 需要首帧图片。`);\n }\n if (first?.href) output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n } else if (spec.field === 'lastFrameUrl') {\n if (last?.href) output[spec.field] = assertRunningHubPublicUrl(last.href, spec.field);\n }\n } else if (spec.mapping === 'first_frame_only') {\n const first = references[0];\n if (first?.href) {\n output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n }\n } else if (spec.mapping === 'reference_images') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (spec.mapping === 'multimodal') {\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, i) => assertRunningHubPublicUrl(href, `${spec.field}[${i}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n if (spec.kind === 'image' && imageUrls.length > 0) output[spec.field] = uniqueLimitedUrls(imageUrls, spec.max);\n if (spec.kind === 'video' && videoUrls.length > 0) output[spec.field] = uniqueLimitedUrls(videoUrls, spec.max);\n if (spec.kind === 'audio' && audioUrls.length > 0) output[spec.field] = uniqueLimitedUrls(audioUrls, spec.max);\n }\n }\n return output;\n}\n\nfunction snapDuration(requested: number, allowed: number[]): number {\n return allowed.reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, allowed[0]);\n}\n\nfunction buildRunningHubRoutePayload(\n prompt: string,\n schema: RouteCapabilitySchema,\n key: UserApiKey | undefined,\n options: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const promptField = extra.runningHubPromptField || schema.promptField;\n\n const payload: Record<string, unknown> = {};\n\n if (!disableDefaults) {\n if (schema.resolutionDefault) payload.resolution = schema.resolutionDefault;\n if (schema.durationDefault !== undefined) payload.duration = schema.durationDefault;\n if (schema.aspectRatioField && schema.aspectRatioValues?.[0]) payload[schema.aspectRatioField] = schema.aspectRatioValues[0];\n for (const param of schema.params) {\n if (param.default !== undefined) {\n if (param.type === 'string' && param.default === 'all') {\n payload[param.field] = ['all'];\n } else {\n payload[param.field] = param.default;\n }\n }\n }\n }\n\n Object.assign(payload, configuredPayload);\n\n payload[promptField] = prompt;\n\n if (schema.aspectRatioField && options?.aspectRatio) {\n payload[schema.aspectRatioField] = options.aspectRatio;\n }\n\n if (schema.durationType && options?.durationSec && options.durationSec > 0) {\n if (schema.durationValues && schema.durationValues.length > 0) {\n const allowed = schema.durationValues.map(v => Number(v)).filter(n => !isNaN(n));\n if (allowed.length > 0) {\n if (schema.durationType === 'string') {\n payload.duration = String(snapDuration(options.durationSec, allowed));\n } else {\n payload.duration = snapDuration(options.durationSec, allowed);\n }\n }\n } else if (schema.durationType === 'string') {\n payload.duration = String(options.durationSec);\n } else {\n payload.duration = options.durationSec;\n }\n }\n\n if (options?.resolution) {\n payload.resolution = options.resolution;\n }\n\n if (options?.userParams) {\n const schemaFields = new Set(schema.params.map(p => p.field));\n for (const [key, value] of Object.entries(options.userParams)) {\n if (value !== undefined && schemaFields.has(key)) {\n payload[key] = value;\n }\n }\n }\n\n Object.assign(payload, buildRouteMediaFields(schema, options?.references || [], options?.slots || []));\n\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n\n return payload;\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const routeSchema = getRouteSchema(modelEndpoint);\n if (routeSchema) {\n return buildRunningHubRoutePayload(prompt, routeSchema, key, options || {});\n }\n throw new Error(`RunningHub 路由 ${modelEndpoint} 未通过验证(未在 Route Catalog 中登记),暂不可执行。请先在 Route Catalog 中补齐该路由的 Capability Schema 后再使用。`);\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(grok-|xai)/i.test(model)) return 'xai';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal; aspectRatio?: VideoAspectRatio; resolution?: string; quality?: string; webSearch?: boolean },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (isGoogleImageEditModel(model)) {\n return editImage(refs, prompt, undefined, key?.key, options?.signal, {\n model,\n aspectRatio: options?.aspectRatio === 'adaptive' ? undefined : options?.aspectRatio,\n imageSize: options?.resolution,\n webSearch: options?.webSearch,\n baseUrl: key?.baseUrl,\n });\n }\n return generateImageFromText(prompt, key?.key, options?.signal, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n aspectRatio: options?.aspectRatio,\n resolution: options?.resolution,\n userParams: {\n ...(options?.webSearch !== undefined ? { webSearch: options.webSearch } : {}),\n ...(options?.quality !== undefined ? { hd: options.quality === 'hd' || options.quality === 'high' } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const imageSize = getOpenAIImageSize(options?.resolution, options?.aspectRatio);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key, undefined, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport class SeedanceSubmissionUnknownError extends Error {\n readonly code = 'SEEDANCE_SUBMISSION_UNKNOWN';\n constructor(cause?: unknown) {\n super('Seedance 提交结果未知:供应商可能已经创建并计费。已禁止自动重试或切换线路,请先到任务记录核对。', { cause });\n this.name = 'SeedanceSubmissionUnknownError';\n }\n}\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const rawSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n const slots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, 'volcengine');\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n let createRes: Response;\n try {\n createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n } catch (error) {\n throw new SeedanceSubmissionUnknownError(error);\n }\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 区分官方 Ark 与第三方 OpenAI-compatible 网关:\n * - Ark 官方:baseUrl 含 `ark.*.volces.com` 或路径含 `/api/v3`,惯例 DELETE `/contents/generations/tasks/{id}`\n * - 网关(如 Scnet):路径含 `/api/llm/v1`,惯例 POST `/tasks/{id}/cancel`\n * - 其他:fallback 到 Ark 风格(保留向后兼容)。\n */\nfunction detectSeedanceCancelRoute(baseUrl: string): 'ark' | 'gateway' {\n const lower = (baseUrl || '').toLowerCase();\n if (lower.includes('/api/llm/v1') || lower.includes('/v1/tasks')) return 'gateway';\n if (lower.includes('volces.com') || lower.includes('/api/v3')) return 'ark';\n return 'ark';\n}\n\nexport type SeedanceVideoCancelResult = {\n canceled: boolean;\n reason: 'ok' | 'not_queued' | 'not_cancellable' | 'unsupported' | 'network_error';\n upstreamStillRunning?: boolean;\n message?: string;\n raw?: unknown;\n};\n\n/**\n * 取消 Seedance 上游任务。仅对 queued 阶段的成功取消返回 `canceled: true`。\n * 上游已开始 processing、或上游不支持取消时返回 `canceled:false, upstreamStillRunning:true`,\n * 让前端走“放弃等待”的退路(仅本地 abortSignal 不再轮询上游)。\n */\nexport async function cancelSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal; reason?: string },\n): Promise<SeedanceVideoCancelResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const route = detectSeedanceCancelRoute(baseUrl);\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n if (route === 'ark') {\n // 官方 Ark:DELETE /contents/generations/tasks/{id}。409=不可取消;200/204=已取消。\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } else {\n // 网关风格:POST /tasks/{id}/cancel;body 可携带 reason。\n res = await fetch(`${baseUrl}/tasks/${taskId}/cancel`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },\n body: JSON.stringify({ reason: options?.reason || 'user_abort' }),\n signal: options?.signal,\n });\n }\n } catch (error) {\n return {\n canceled: false,\n reason: 'network_error',\n upstreamStillRunning: true,\n message: error instanceof Error ? error.message : '网络错误,无法确认取消结果',\n };\n }\n\n if (res.status === 405 || res.status === 404) {\n // 网关/官方未实现取消端点\n return {\n canceled: false,\n reason: 'unsupported',\n upstreamStillRunning: true,\n message: '当前 Seedance 渠道不支持取消,请等待完成或放弃等待',\n };\n }\n\n let raw: any = undefined;\n try {\n raw = await res.json();\n } catch {\n // 204 No Content 等成功无 body 的情况\n }\n\n if (res.ok) {\n // Scnet 风格可能返回 { code: 0 } 或 { success: true }; Ark 200 返回 { cancelled: true }\n // 兼容两种解析:cancelled=true / code===0 / success===true 视为成功\n const okFlag =\n raw?.cancelled === true\n || raw?.data?.cancelled === true\n || raw?.code === 0\n || raw?.success === true\n || (route === 'ark' && res.status === 200 && raw == null);\n return {\n canceled: Boolean(okFlag),\n reason: okFlag ? 'ok' : 'not_cancellable',\n upstreamStillRunning: !okFlag,\n message: okFlag ? undefined : (raw?.message || raw?.msg || '上游响应未确认取消'),\n raw,\n };\n }\n\n if (res.status === 409 || res.status === 400) {\n // Ark: 409 task_not_cancellable (已 processing);网关: 400 已开始\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || '任务已开始执行,上游无法取消',\n raw,\n };\n }\n\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || `上游返回 ${res.status}`,\n raw,\n };\n}\n\nexport type SeedanceVideoReconcileResult = {\n status: 'succeeded' | 'failed' | 'cancelled' | 'expired' | 'unknown';\n amount?: number;\n currency?: string;\n totalTokens?: number;\n reason?: string;\n raw?: unknown;\n};\n\nfunction parseSeedanceVideoUsage(raw: any, fallbackStatus: SeedanceVideoReconcileResult['status'] = 'unknown'): SeedanceVideoReconcileResult {\n const remoteStatus = String(raw?.status || raw?.data?.status || raw?.data?.task_status || raw?.task?.status || raw?.state || '').toLowerCase();\n let status: SeedanceVideoReconcileResult['status'] = fallbackStatus;\n if (['succeeded', 'succeed', 'success', 'completed', 'complete', 'done'].includes(remoteStatus)) status = 'succeeded';\n else if (['cancelled', 'canceled'].includes(remoteStatus)) status = 'cancelled';\n else if (['failed', 'fail', 'error'].includes(remoteStatus)) status = 'failed';\n else if (['expired', 'timeout'].includes(remoteStatus)) status = 'expired';\n const billingRaw = raw?.data?.billing || raw?.data?.usage?.billing || raw?.billing || raw?.usage?.billing;\n const amount = typeof billingRaw?.amount === 'number' ? billingRaw.amount : undefined;\n const currency = typeof billingRaw?.currency === 'string' ? billingRaw.currency : raw?.data?.currency || raw?.currency;\n const totalTokens = typeof raw?.usage?.total_tokens === 'number' ? raw.usage.total_tokens\n : typeof raw?.data?.usage?.total_tokens === 'number' ? raw.data.usage.total_tokens\n : typeof raw?.usage?.token_usage?.total_tokens === 'number' ? raw.usage.token_usage.total_tokens\n : undefined;\n return { status, amount, currency, totalTokens, raw };\n}\n\n/**\n * 查询上游任务的真实账单/token 使用量。复用 GET 任务查询接口;不修改 handle。\n *\n * - 网关(Scnet/Lemondata)常见字段:`data.billing.amount` / `data.usage.billing.amount` + `currency`\n * - Volcengine Ark 官方常见字段:`usage.total_tokens`(仅 token,无 billing 金额)\n *\n * 调用方拿到返回值后:\n * - amount 存在 → actualCost = amount,billableState = 'actual'\n * - 仅 totalTokens → actualTokens = totalTokens,billableState 保持 'unknown'\n * - status === 'unknown' → 不变更 usageRecord\n */\nexport async function reconcileSeedanceVideoUsage(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoReconcileResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } catch (error) {\n return { status: 'unknown', reason: error instanceof Error ? error.message : '网络错误' };\n }\n if (!res.ok) {\n return { status: 'unknown', reason: `上游返回 ${res.status}` };\n }\n let raw: any;\n try {\n raw = await res.json();\n } catch {\n return { status: 'unknown', reason: '响应不是合法 JSON' };\n }\n\n return parseSeedanceVideoUsage(raw);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const legacyReferences = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const rawSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(legacyReferences);\n const multimodalSlots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, provider);\n const references: VideoImage[] = multimodalSlots.filter(slot => slot.kind === 'image').map(slot => ({\n href: slot.href,\n mimeType: slot.mimeType,\n slotRole: String(slot.role || 'unassigned'),\n label: slot.label,\n }));\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const usesFirstFrame = !options?.generationSubmode || options.generationSubmode === 'image-to-video' || options.generationSubmode === 'first-last-frame';\n const firstFrame = usesFirstFrame\n ? references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined)\n : undefined;\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n userParams: {\n ...(options?.generateAudio !== undefined ? { generateAudio: options.generateAudio } : {}),\n ...(options?.seed !== undefined ? { seed: options.seed } : {}),\n ...(options?.returnLastFrame !== undefined ? { returnLastFrame: options.returnLastFrame } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n const lastFrameSlot = options?.generationSubmode === 'first-last-frame'\n ? multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'last_frame')\n : undefined;\n const referenceImages = options?.generationSubmode === 'reference-to-video'\n ? multimodalSlots.filter(slot => slot.kind === 'image' && slot.role !== 'first_frame' && slot.role !== 'last_frame').slice(0, 3)\n : [];\n return generateVideo(prompt, aspectRatio === '9:16' ? '9:16' : '16:9', onProgress, firstFrame, key?.key, {\n model,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n generateAudio: options?.generateAudio,\n signal: options?.signal,\n lastFrame: lastFrameSlot ? { href: lastFrameSlot.href, mimeType: lastFrameSlot.mimeType } : undefined,\n referenceImages: referenceImages.map(reference => ({ href: reference.href, mimeType: reference.mimeType })),\n baseUrl: key?.baseUrl,\n });\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl, { signal: options?.signal });\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: options?.resolution?.toLowerCase() === '1080p' ? 'pro' : 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: String(options?.durationSec || 5),\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl, { signal: options?.signal });\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'xai') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const submode: ProductModelMode = options?.generationSubmode || 'text-to-video';\n\n onProgress('Submitting video generation task...');\n let createPath: string;\n const createBody: Record<string, unknown> = { model, prompt };\n\n if (submode === 'video-extension') {\n createPath = `${baseUrl}/videos/extensions`;\n const videoSlot = multimodalSlots.find(slot => slot.kind === 'video');\n if (!videoSlot) {\n throw new Error('xAI 视频扩展至少需要 1 个视频素材作为驱动视频。');\n }\n createBody.video = { url: videoSlot.href };\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n } else {\n createPath = `${baseUrl}/videos/generations`;\n if (aspectRatio) {\n createBody.aspect_ratio = aspectRatio;\n }\n if (options?.resolution) {\n createBody.resolution = options.resolution;\n }\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n if (firstFrame) {\n createBody.image = { url: firstFrame.href };\n }\n }\n\n const createRes = await fetch(createPath, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'xAI 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'xAI 视频生成创建响应');\n const requestId = createJson?.request_id;\n if (!requestId) {\n throw new Error('xAI 视频生成未返回 request_id');\n }\n\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const xaiPollStart = Date.now();\n while (true) {\n if (Date.now() - xaiPollStart > 600_000) {\n throw new Error('xAI 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'xAI 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'xAI 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'failed') {\n throw new Error(`xAI 视频生成失败: ${queryJson?.error || queryJson?.message || 'Unknown error'}`);\n }\n if (status === 'done') {\n videoUrl = queryJson?.output?.url;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!videoUrl) {\n throw new Error('xAI 视频生成完成但未返回视频 URL');\n }\n\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n await options?.onProviderTaskLifecycle?.({ phase: 'submitted', providerTaskId: handle.taskId, submittedAt: handle.createdAt });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n let cancelPromise: Promise<SeedanceVideoCancelResult> | undefined;\n const requestUpstreamCancel = () => cancelPromise ||= cancelSeedanceVideoTask(handle, key, { reason: 'user_abort' }).then(async result => {\n await options?.onProviderTaskLifecycle?.({ phase: 'cancelled', providerTaskId: handle.taskId, canceled: result.canceled, upstreamStillRunning: result.upstreamStillRunning, message: result.message });\n const usage = await reconcileSeedanceVideoUsage(handle, key);\n if (usage.status !== 'unknown' || usage.amount !== undefined || usage.totalTokens !== undefined) {\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n }\n return result;\n });\n const handleAbort = () => { void requestUpstreamCancel(); };\n options?.signal?.addEventListener('abort', handleAbort, { once: true });\n\n try {\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n const usage = parseSeedanceVideoUsage(result.raw, 'failed');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n const usage = parseSeedanceVideoUsage(result.raw, 'succeeded');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n await options?.onProviderTaskLifecycle?.({ phase: 'running', providerTaskId: handle.taskId, remoteStatus: result.remoteStatus });\n }\n } catch (error) {\n if (options?.signal?.aborted || error instanceof Error && error.name === 'AbortError') await requestUpstreamCancel();\n throw error;\n } finally {\n options?.signal?.removeEventListener('abort', handleAbort);\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const params = sanitizeProductGenerationParams(input.productModelId, {\n mode: input.generationSubmode,\n aspectRatio: input.aspectRatio,\n resolution: input.resolution,\n quality: input.quality,\n durationSec: input.durationSec,\n generateAudio: input.generateAudio,\n webSearch: input.webSearch,\n realPersonCheck: input.realPersonCheck,\n referenceCount: input.references?.length || 0,\n });\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: params.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: params.durationSec,\n resolution: params.resolution,\n generateAudio: params.generateAudio,\n watermark: input.watermark,\n generationSubmode: params.mode,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n onProviderTaskLifecycle: input.onProviderTaskLifecycle,\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, {\n signal: input.signal,\n aspectRatio: params.aspectRatio,\n resolution: params.resolution,\n quality: params.quality,\n webSearch: params.webSearch,\n });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "7ba1898b7763d62cbf8bfc697a4163c4913e951d": {"t": 1785773457.2218957, "n": 163369, "h": "564c92f93547e394", "c": "import type { AICapability, AIProvider, ProductModelMode, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\nimport { sanitizeProductGenerationParams } from './productModelCatalog';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport type ProviderTaskLifecycleEvent =\n | { phase: 'submitted'; providerTaskId: string; submittedAt: number }\n | { phase: 'running'; providerTaskId: string; remoteStatus?: string }\n | { phase: 'cancelled'; providerTaskId: string; canceled: boolean; upstreamStillRunning?: boolean; message?: string }\n | { phase: 'usage'; providerTaskId: string; status: SeedanceVideoReconcileResult['status']; amount?: number; currency?: string; totalTokens?: number };\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n productModelId?: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n quality?: string;\n generationSubmode?: ProductModelMode;\n generateAudio?: boolean;\n watermark?: boolean;\n webSearch?: boolean;\n realPersonCheck?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image', 'gemini-3.1-flash-lite-image', 'gemini-3-pro-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-fast-generate-preview', 'veo-3.1-lite-generate-preview'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n xai: {\n text: ['grok-4-fast', 'grok-4'],\n image: ['grok-2-image'],\n video: ['grok-imagine-video', 'grok-imagine-video-1.5'],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '3:2' | '2:3' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\nconst OPENAI_IMAGE_SIZES: Record<'1K' | '2K' | '4K', Record<string, string>> = {\n '1K': { '1:1': '1024x1024', '3:2': '1536x1024', '2:3': '1024x1536', '16:9': '1792x1024', '9:16': '1024x1792', '4:3': '1344x1008', '3:4': '1008x1344' },\n '2K': { '1:1': '2048x2048', '3:2': '2048x1360', '2:3': '1360x2048', '16:9': '2048x1152', '9:16': '1152x2048', '4:3': '2048x1536', '3:4': '1536x2048' },\n '4K': { '1:1': '2880x2880', '3:2': '3520x2352', '2:3': '2352x3520', '16:9': '3840x2160', '9:16': '2160x3840', '4:3': '3312x2480', '3:4': '2480x3312' },\n};\n\nfunction getOpenAIImageSize(resolution?: string, aspectRatio?: VideoAspectRatio): string {\n const tier = resolution?.toUpperCase() === '4K' ? '4K' : resolution?.toUpperCase() === '2K' ? '2K' : '1K';\n return OPENAI_IMAGE_SIZES[tier][aspectRatio || '1:1'] || OPENAI_IMAGE_SIZES[tier]['1:1'];\n}\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance') || isRunningHubSeedance20Model(normalized)) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'runningHub' && (inferCapabilityFromModelName(model) === 'video' || isRunningHubVideoEndpoint(normalized))) {\n return { ...DEFAULT_VIDEO_CAPABILITY, durations: resolveRunningHubVideoDurations(normalized) };\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地画布元素。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n xai: ['1:1', '16:9', '9:16', '4:3', '3:4', '2:3', '3:2'], // Grok Imagine Video\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n xai: 'https://api.x.ai/v1',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^xai-/i.test(trimmed)) return 'xai';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n xai: 'xAI Grok',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction assertResolvedUpstreamModel(model: string): void {\n if (stripModelSelectionRef(model).trim().toLowerCase().startsWith('flovart:')) {\n throw new Error(`产品模型 ${model} 尚未解析为供应商上游模型。请检查该产品模型的 BYOK 映射。`);\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie') || normalized.includes('motion-control')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction adaptVideoSlotsForMode(slots: MultimodalSlot[], mode?: ProductModelMode, provider?: AIProvider): MultimodalSlot[] {\n if (!mode) return slots;\n const images = slots.filter(slot => slot.kind === 'image');\n if (mode === 'text-to-video') return [];\n if (mode === 'image-to-video') {\n if (!images[0]) throw new Error('图生视频至少需要 1 张图片。');\n return [{ ...images[0], role: 'first_frame' }];\n }\n if (mode === 'first-last-frame') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配首尾帧,请切换模式或重新映射线路。');\n if (images.length < 2) throw new Error('首尾帧模式需要 2 张图片。');\n return [{ ...images[0], role: 'first_frame' }, { ...images[1], role: 'last_frame' }];\n }\n if (mode === 'reference-to-video') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配全能参考,请切换模式或重新映射线路。');\n const references = provider === 'google' ? images.slice(0, 3) : slots;\n if (!references.length) throw new Error(provider === 'google' ? 'Veo 全能参考至少需要 1 张图片。' : '全能参考至少需要 1 个媒体素材。');\n return references.map(slot => ({\n ...slot,\n role: slot.kind === 'video' ? 'reference_video' : slot.kind === 'audio' ? 'reference_audio' : 'reference_image',\n }));\n }\n return slots;\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nconst RUNNINGHUB_STANDARD_MODEL_DEFAULTS: Array<{ pattern: RegExp; payload: Record<string, unknown> }> = [\n { pattern: /^rhart-image-(?:g-2|n-g31-flash)\\/(?:text-to-image|image-to-image)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-n-pro(?:-official)?\\/(?:text-to-image|edit(?:-ultra)?)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-v1(?:-official)?\\/(?:text-to-image|edit)$/i, payload: { resolution: '1k' } },\n { pattern: /^youchuan\\/text-to-image-v81$/i, payload: { hd: false } },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:text-to-video|image-to-video|multimodal-video)$/i,\n payload: {\n resolution: '720p',\n duration: '5',\n generateAudio: true,\n ratio: 'adaptive',\n realPersonMode: true,\n conversionSlots: ['all'],\n returnLastFrame: false,\n seed: -1,\n },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/text-to-video$/i,\n payload: { webSearch: false },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:image-to-video|multimodal-video)$/i,\n payload: { realPersonMode: true, conversionSlots: ['all'] },\n },\n // Veo 3.1 系列(Google DeepMind 旗舰视频,原生音频 + 口型同步)\n {\n pattern: /^rhart-video-v3\\.1(?:-[\\w-]+)?\\/(?:text-to-video|image-to-video|start-end-to-video)$/i,\n payload: { resolution: '720p', duration: '8', generateAudio: false },\n },\n {\n pattern: /^rhart-video-v3\\.1-pro-official\\/reference-to-video$/i,\n payload: { resolution: '1080p', generateAudio: false },\n },\n // SkyReels V4 Omni(天工 AI 统一多模态视频,@tag 引用机制)\n {\n pattern: /^skyreels-v4\\/omni-reference(?:-(?:fast|std))?$/i,\n payload: { resolution: '1080p', duration: 5, promptOptimizer: true },\n },\n // 全能视频S(低价渠道版,duration '10'/'15',aspectRatio 16:9/9:16)\n {\n pattern: /^rhart-video-s\\/text-to-video$/i,\n payload: { duration: '10' },\n },\n];\n\nfunction runningHubStandardModelDefaults(modelEndpoint: string) {\n return RUNNINGHUB_STANDARD_MODEL_DEFAULTS.reduce<Record<string, unknown>>((payload, preset) => {\n if (preset.pattern.test(modelEndpoint)) Object.assign(payload, preset.payload);\n return payload;\n }, {});\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31Model(modelEndpoint: string) {\n return /rhart-video-v3\\.1/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LowPriceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-(?:fast|pro)\\//i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31ReferenceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1[\\w.-]*\\/reference-to-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LiteModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-lite/i.test(modelEndpoint);\n}\n\nfunction isRunningHubOmniModel(modelEndpoint: string) {\n return /skyreels-v4\\/omni-reference/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoSModel(modelEndpoint: string) {\n return /rhart-video-s\\//i.test(modelEndpoint);\n}\n\nfunction resolveRunningHubVideoDurations(model: string): number[] {\n if (isRunningHubVeo31ReferenceModel(model)) return [];\n if (isRunningHubVeo31Model(model)) {\n if (isRunningHubVeo31LowPriceModel(model)) return [8];\n if (/start-end-to-video/i.test(model)) return isRunningHubVeo31LiteModel(model) ? [] : [8];\n return [4, 6, 8];\n }\n if (isRunningHubOmniModel(model)) return [3, 5, 8, 10, 12, 15];\n if (isRunningHubVideoSModel(model)) return [10, 15];\n return [5, 8, 10];\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video|reference-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (isRunningHubOmniModel(modelEndpoint) && references.length > 0) {\n throw new Error('SkyReels V4 Omni 参考输入(refImages/refVideos)暂未支持,请去掉参考图后使用文生视频。');\n }\n\n // Veo 3.1 Fast official image-to-video:imageUrl + optional lastImageUrl。\n if (/rhart-video-v3\\.1-fast-official\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n if (last?.href) output.lastImageUrl = assertRunningHubPublicUrl(last.href, 'lastImageUrl');\n return output;\n }\n\n // Veo 3.1 Pro / Lite official image-to-video:imageUrl 单数;Fast 低价图生视频仍用 imageUrls 数组。\n if (/rhart-video-v3\\.1-(?:pro(?:-official)?|lite-official)\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n return output;\n }\n\n // Veo 3.1 start-end-to-video:firstFrameUrl + lastFrameUrl\n if (/rhart-video-v3\\.1[\\w.-]*\\/start-end-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // youchuan/text-to-image-v81 垫图用 imageUrl 单数(非 imageUrls 数组)\n if (/youchuan\\/text-to-image/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // Veo 3.1 Fast 低价图生视频 + 各 reference-to-video:imageUrls 数组 max 3 (schema api-448183087/459865180)\n if (/rhart-video-v3\\.1[\\w.-]*\\/(?:image-to-video|reference-to-video)/i.test(modelEndpoint)) {\n if (urls.length > 3) console.warn(`[RunningHub] ${modelEndpoint} 仅支持最多 3 张参考图,已截断 ${urls.length} → 3`);\n output.imageUrls = uniqueLimitedUrls(urls, 3);\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = uniqueLimitedUrls(imageUrls, 9);\n if (videoUrls.length > 0) output.videoUrls = uniqueLimitedUrls(videoUrls, 3);\n if (audioUrls.length > 0) output.audioUrls = uniqueLimitedUrls(audioUrls, 3);\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...(!disableDefaults && !nodeFieldModel ? runningHubStandardModelDefaults(modelEndpoint) : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n const isVeo31Ref = isRunningHubVeo31ReferenceModel(modelEndpoint);\n const isVeo31 = isRunningHubVeo31Model(modelEndpoint);\n const isVeo31LowPrice = isRunningHubVeo31LowPriceModel(modelEndpoint);\n const isProOfficialRef = /rhart-video-v3\\.1-pro-official\\/reference-to-video/i.test(modelEndpoint);\n if (options?.aspectRatio && !isProOfficialRef) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (isRunningHubVideoEndpoint(modelEndpoint)) {\n payload.resolution = options?.resolution || payload.resolution || '720p';\n if (isVeo31Ref) {\n // Veo 3.1 reference-to-video:无 duration;fast-official 保留 aspectRatio (16:9/9:16),pro-official 无 aspectRatio 字段\n delete payload.duration;\n if (isProOfficialRef) {\n delete payload.aspectRatio;\n } else if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') {\n payload.aspectRatio = '16:9';\n }\n } else if (isVeo31) {\n // Veo 3.1:duration 必须是 '4'/'6'/'8' 字符串\n const isStartEnd = /start-end-to-video/i.test(modelEndpoint);\n const isLiteOfficial = isRunningHubVeo31LiteModel(modelEndpoint);\n if (isVeo31LowPrice) {\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n delete payload.generateAudio;\n payload.duration = '8';\n } else if (isStartEnd) {\n // Veo 3.1 start-end-to-video: schema 只有 duration='8' (lite-official 无 duration 无 generateAudio 字段)\n if (isLiteOfficial) {\n delete payload.duration;\n delete payload.generateAudio;\n } else {\n payload.duration = '8';\n }\n } else {\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 8;\n const snapped = [4, 6, 8].reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, 4);\n payload.duration = String(snapped);\n }\n } else if (isRunningHubOmniModel(modelEndpoint)) {\n // Omni:duration 是 3-15 数字\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 5;\n payload.duration = Math.max(3, Math.min(15, Math.round(requested)));\n } else if (isRunningHubVideoSModel(modelEndpoint)) {\n // 全能视频S:duration 是 '10'/'15' 字符串,aspectRatio 只支持 '9:16'/'16:9'\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 10;\n payload.duration = requested >= 15 ? '15' : '10';\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n } else {\n payload.duration = String((options?.durationSec && options.durationSec > 0) ? options.durationSec : (payload.duration || 5));\n }\n } else {\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n }\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(grok-|xai)/i.test(model)) return 'xai';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal; aspectRatio?: VideoAspectRatio; resolution?: string; quality?: string; webSearch?: boolean },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (isGoogleImageEditModel(model)) {\n return editImage(refs, prompt, undefined, key?.key, options?.signal, {\n model,\n aspectRatio: options?.aspectRatio === 'adaptive' ? undefined : options?.aspectRatio,\n imageSize: options?.resolution,\n webSearch: options?.webSearch,\n baseUrl: key?.baseUrl,\n });\n }\n return generateImageFromText(prompt, key?.key, options?.signal, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const imageSize = getOpenAIImageSize(options?.resolution, options?.aspectRatio);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key, undefined, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport class SeedanceSubmissionUnknownError extends Error {\n readonly code = 'SEEDANCE_SUBMISSION_UNKNOWN';\n constructor(cause?: unknown) {\n super('Seedance 提交结果未知:供应商可能已经创建并计费。已禁止自动重试或切换线路,请先到任务记录核对。', { cause });\n this.name = 'SeedanceSubmissionUnknownError';\n }\n}\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const rawSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n const slots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, 'volcengine');\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n let createRes: Response;\n try {\n createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n } catch (error) {\n throw new SeedanceSubmissionUnknownError(error);\n }\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 区分官方 Ark 与第三方 OpenAI-compatible 网关:\n * - Ark 官方:baseUrl 含 `ark.*.volces.com` 或路径含 `/api/v3`,惯例 DELETE `/contents/generations/tasks/{id}`\n * - 网关(如 Scnet):路径含 `/api/llm/v1`,惯例 POST `/tasks/{id}/cancel`\n * - 其他:fallback 到 Ark 风格(保留向后兼容)。\n */\nfunction detectSeedanceCancelRoute(baseUrl: string): 'ark' | 'gateway' {\n const lower = (baseUrl || '').toLowerCase();\n if (lower.includes('/api/llm/v1') || lower.includes('/v1/tasks')) return 'gateway';\n if (lower.includes('volces.com') || lower.includes('/api/v3')) return 'ark';\n return 'ark';\n}\n\nexport type SeedanceVideoCancelResult = {\n canceled: boolean;\n reason: 'ok' | 'not_queued' | 'not_cancellable' | 'unsupported' | 'network_error';\n upstreamStillRunning?: boolean;\n message?: string;\n raw?: unknown;\n};\n\n/**\n * 取消 Seedance 上游任务。仅对 queued 阶段的成功取消返回 `canceled: true`。\n * 上游已开始 processing、或上游不支持取消时返回 `canceled:false, upstreamStillRunning:true`,\n * 让前端走“放弃等待”的退路(仅本地 abortSignal 不再轮询上游)。\n */\nexport async function cancelSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal; reason?: string },\n): Promise<SeedanceVideoCancelResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const route = detectSeedanceCancelRoute(baseUrl);\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n if (route === 'ark') {\n // 官方 Ark:DELETE /contents/generations/tasks/{id}。409=不可取消;200/204=已取消。\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } else {\n // 网关风格:POST /tasks/{id}/cancel;body 可携带 reason。\n res = await fetch(`${baseUrl}/tasks/${taskId}/cancel`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },\n body: JSON.stringify({ reason: options?.reason || 'user_abort' }),\n signal: options?.signal,\n });\n }\n } catch (error) {\n return {\n canceled: false,\n reason: 'network_error',\n upstreamStillRunning: true,\n message: error instanceof Error ? error.message : '网络错误,无法确认取消结果',\n };\n }\n\n if (res.status === 405 || res.status === 404) {\n // 网关/官方未实现取消端点\n return {\n canceled: false,\n reason: 'unsupported',\n upstreamStillRunning: true,\n message: '当前 Seedance 渠道不支持取消,请等待完成或放弃等待',\n };\n }\n\n let raw: any = undefined;\n try {\n raw = await res.json();\n } catch {\n // 204 No Content 等成功无 body 的情况\n }\n\n if (res.ok) {\n // Scnet 风格可能返回 { code: 0 } 或 { success: true }; Ark 200 返回 { cancelled: true }\n // 兼容两种解析:cancelled=true / code===0 / success===true 视为成功\n const okFlag =\n raw?.cancelled === true\n || raw?.data?.cancelled === true\n || raw?.code === 0\n || raw?.success === true\n || (route === 'ark' && res.status === 200 && raw == null);\n return {\n canceled: Boolean(okFlag),\n reason: okFlag ? 'ok' : 'not_cancellable',\n upstreamStillRunning: !okFlag,\n message: okFlag ? undefined : (raw?.message || raw?.msg || '上游响应未确认取消'),\n raw,\n };\n }\n\n if (res.status === 409 || res.status === 400) {\n // Ark: 409 task_not_cancellable (已 processing);网关: 400 已开始\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || '任务已开始执行,上游无法取消',\n raw,\n };\n }\n\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || `上游返回 ${res.status}`,\n raw,\n };\n}\n\nexport type SeedanceVideoReconcileResult = {\n status: 'succeeded' | 'failed' | 'cancelled' | 'expired' | 'unknown';\n amount?: number;\n currency?: string;\n totalTokens?: number;\n reason?: string;\n raw?: unknown;\n};\n\nfunction parseSeedanceVideoUsage(raw: any, fallbackStatus: SeedanceVideoReconcileResult['status'] = 'unknown'): SeedanceVideoReconcileResult {\n const remoteStatus = String(raw?.status || raw?.data?.status || raw?.data?.task_status || raw?.task?.status || raw?.state || '').toLowerCase();\n let status: SeedanceVideoReconcileResult['status'] = fallbackStatus;\n if (['succeeded', 'succeed', 'success', 'completed', 'complete', 'done'].includes(remoteStatus)) status = 'succeeded';\n else if (['cancelled', 'canceled'].includes(remoteStatus)) status = 'cancelled';\n else if (['failed', 'fail', 'error'].includes(remoteStatus)) status = 'failed';\n else if (['expired', 'timeout'].includes(remoteStatus)) status = 'expired';\n const billingRaw = raw?.data?.billing || raw?.data?.usage?.billing || raw?.billing || raw?.usage?.billing;\n const amount = typeof billingRaw?.amount === 'number' ? billingRaw.amount : undefined;\n const currency = typeof billingRaw?.currency === 'string' ? billingRaw.currency : raw?.data?.currency || raw?.currency;\n const totalTokens = typeof raw?.usage?.total_tokens === 'number' ? raw.usage.total_tokens\n : typeof raw?.data?.usage?.total_tokens === 'number' ? raw.data.usage.total_tokens\n : typeof raw?.usage?.token_usage?.total_tokens === 'number' ? raw.usage.token_usage.total_tokens\n : undefined;\n return { status, amount, currency, totalTokens, raw };\n}\n\n/**\n * 查询上游任务的真实账单/token 使用量。复用 GET 任务查询接口;不修改 handle。\n *\n * - 网关(Scnet/Lemondata)常见字段:`data.billing.amount` / `data.usage.billing.amount` + `currency`\n * - Volcengine Ark 官方常见字段:`usage.total_tokens`(仅 token,无 billing 金额)\n *\n * 调用方拿到返回值后:\n * - amount 存在 → actualCost = amount,billableState = 'actual'\n * - 仅 totalTokens → actualTokens = totalTokens,billableState 保持 'unknown'\n * - status === 'unknown' → 不变更 usageRecord\n */\nexport async function reconcileSeedanceVideoUsage(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoReconcileResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } catch (error) {\n return { status: 'unknown', reason: error instanceof Error ? error.message : '网络错误' };\n }\n if (!res.ok) {\n return { status: 'unknown', reason: `上游返回 ${res.status}` };\n }\n let raw: any;\n try {\n raw = await res.json();\n } catch {\n return { status: 'unknown', reason: '响应不是合法 JSON' };\n }\n\n return parseSeedanceVideoUsage(raw);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const legacyReferences = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const rawSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(legacyReferences);\n const multimodalSlots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, provider);\n const references: VideoImage[] = multimodalSlots.filter(slot => slot.kind === 'image').map(slot => ({\n href: slot.href,\n mimeType: slot.mimeType,\n slotRole: String(slot.role || 'unassigned'),\n label: slot.label,\n }));\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const usesFirstFrame = !options?.generationSubmode || options.generationSubmode === 'image-to-video' || options.generationSubmode === 'first-last-frame';\n const firstFrame = usesFirstFrame\n ? references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined)\n : undefined;\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n const lastFrameSlot = options?.generationSubmode === 'first-last-frame'\n ? multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'last_frame')\n : undefined;\n const referenceImages = options?.generationSubmode === 'reference-to-video'\n ? multimodalSlots.filter(slot => slot.kind === 'image' && slot.role !== 'first_frame' && slot.role !== 'last_frame').slice(0, 3)\n : [];\n return generateVideo(prompt, aspectRatio === '9:16' ? '9:16' : '16:9', onProgress, firstFrame, key?.key, {\n model,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n generateAudio: options?.generateAudio,\n signal: options?.signal,\n lastFrame: lastFrameSlot ? { href: lastFrameSlot.href, mimeType: lastFrameSlot.mimeType } : undefined,\n referenceImages: referenceImages.map(reference => ({ href: reference.href, mimeType: reference.mimeType })),\n baseUrl: key?.baseUrl,\n });\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl, { signal: options?.signal });\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: options?.resolution?.toLowerCase() === '1080p' ? 'pro' : 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: String(options?.durationSec || 5),\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl, { signal: options?.signal });\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'xai') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const submode: ProductModelMode = options?.generationSubmode || 'text-to-video';\n\n onProgress('Submitting video generation task...');\n let createPath: string;\n const createBody: Record<string, unknown> = { model, prompt };\n\n if (submode === 'video-extension') {\n createPath = `${baseUrl}/videos/extensions`;\n const videoSlot = multimodalSlots.find(slot => slot.kind === 'video');\n if (!videoSlot) {\n throw new Error('xAI 视频扩展至少需要 1 个视频素材作为驱动视频。');\n }\n createBody.video = { url: videoSlot.href };\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n } else {\n createPath = `${baseUrl}/videos/generations`;\n if (aspectRatio) {\n createBody.aspect_ratio = aspectRatio;\n }\n if (options?.resolution) {\n createBody.resolution = options.resolution;\n }\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n if (firstFrame) {\n createBody.image = { url: firstFrame.href };\n }\n }\n\n const createRes = await fetch(createPath, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'xAI 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'xAI 视频生成创建响应');\n const requestId = createJson?.request_id;\n if (!requestId) {\n throw new Error('xAI 视频生成未返回 request_id');\n }\n\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const xaiPollStart = Date.now();\n while (true) {\n if (Date.now() - xaiPollStart > 600_000) {\n throw new Error('xAI 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'xAI 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'xAI 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'failed') {\n throw new Error(`xAI 视频生成失败: ${queryJson?.error || queryJson?.message || 'Unknown error'}`);\n }\n if (status === 'done') {\n videoUrl = queryJson?.output?.url;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!videoUrl) {\n throw new Error('xAI 视频生成完成但未返回视频 URL');\n }\n\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n await options?.onProviderTaskLifecycle?.({ phase: 'submitted', providerTaskId: handle.taskId, submittedAt: handle.createdAt });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n let cancelPromise: Promise<SeedanceVideoCancelResult> | undefined;\n const requestUpstreamCancel = () => cancelPromise ||= cancelSeedanceVideoTask(handle, key, { reason: 'user_abort' }).then(async result => {\n await options?.onProviderTaskLifecycle?.({ phase: 'cancelled', providerTaskId: handle.taskId, canceled: result.canceled, upstreamStillRunning: result.upstreamStillRunning, message: result.message });\n const usage = await reconcileSeedanceVideoUsage(handle, key);\n if (usage.status !== 'unknown' || usage.amount !== undefined || usage.totalTokens !== undefined) {\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n }\n return result;\n });\n const handleAbort = () => { void requestUpstreamCancel(); };\n options?.signal?.addEventListener('abort', handleAbort, { once: true });\n\n try {\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n const usage = parseSeedanceVideoUsage(result.raw, 'failed');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n const usage = parseSeedanceVideoUsage(result.raw, 'succeeded');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n await options?.onProviderTaskLifecycle?.({ phase: 'running', providerTaskId: handle.taskId, remoteStatus: result.remoteStatus });\n }\n } catch (error) {\n if (options?.signal?.aborted || error instanceof Error && error.name === 'AbortError') await requestUpstreamCancel();\n throw error;\n } finally {\n options?.signal?.removeEventListener('abort', handleAbort);\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const params = sanitizeProductGenerationParams(input.productModelId, {\n mode: input.generationSubmode,\n aspectRatio: input.aspectRatio,\n resolution: input.resolution,\n quality: input.quality,\n durationSec: input.durationSec,\n generateAudio: input.generateAudio,\n webSearch: input.webSearch,\n realPersonCheck: input.realPersonCheck,\n referenceCount: input.references?.length || 0,\n });\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: params.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: params.durationSec,\n resolution: params.resolution,\n generateAudio: params.generateAudio,\n watermark: input.watermark,\n generationSubmode: params.mode,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n onProviderTaskLifecycle: input.onProviderTaskLifecycle,\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, {\n signal: input.signal,\n aspectRatio: params.aspectRatio,\n resolution: params.resolution,\n quality: params.quality,\n webSearch: params.webSearch,\n });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "4bb719384da213f64c805c5e33e6ca6aa3e8616d": {"t": 1785773459.67129, "n": 163680, "h": "74859f9b322541e7", "c": "import type { AICapability, AIProvider, ProductModelMode, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\nimport { sanitizeProductGenerationParams } from './productModelCatalog';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport type ProviderTaskLifecycleEvent =\n | { phase: 'submitted'; providerTaskId: string; submittedAt: number }\n | { phase: 'running'; providerTaskId: string; remoteStatus?: string }\n | { phase: 'cancelled'; providerTaskId: string; canceled: boolean; upstreamStillRunning?: boolean; message?: string }\n | { phase: 'usage'; providerTaskId: string; status: SeedanceVideoReconcileResult['status']; amount?: number; currency?: string; totalTokens?: number };\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n productModelId?: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n quality?: string;\n generationSubmode?: ProductModelMode;\n generateAudio?: boolean;\n watermark?: boolean;\n webSearch?: boolean;\n realPersonCheck?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image', 'gemini-3-pro-image', 'gemini-2.5-flash-image'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-fast-generate-preview', 'veo-3.1-lite-generate-preview'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n xai: {\n text: ['grok-4-fast', 'grok-4'],\n image: ['grok-2-image'],\n video: ['grok-imagine-video', 'grok-imagine-video-1.5'],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '3:2' | '2:3' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\nconst OPENAI_IMAGE_SIZES: Record<'1K' | '2K' | '4K', Record<string, string>> = {\n '1K': { '1:1': '1024x1024', '3:2': '1536x1024', '2:3': '1024x1536', '16:9': '1792x1024', '9:16': '1024x1792', '4:3': '1344x1008', '3:4': '1008x1344' },\n '2K': { '1:1': '2048x2048', '3:2': '2048x1360', '2:3': '1360x2048', '16:9': '2048x1152', '9:16': '1152x2048', '4:3': '2048x1536', '3:4': '1536x2048' },\n '4K': { '1:1': '2880x2880', '3:2': '3520x2352', '2:3': '2352x3520', '16:9': '3840x2160', '9:16': '2160x3840', '4:3': '3312x2480', '3:4': '2480x3312' },\n};\n\nfunction getOpenAIImageSize(resolution?: string, aspectRatio?: VideoAspectRatio): string {\n const tier = resolution?.toUpperCase() === '4K' ? '4K' : resolution?.toUpperCase() === '2K' ? '2K' : '1K';\n return OPENAI_IMAGE_SIZES[tier][aspectRatio || '1:1'] || OPENAI_IMAGE_SIZES[tier]['1:1'];\n}\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance') || isRunningHubSeedance20Model(normalized)) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'runningHub' && (inferCapabilityFromModelName(model) === 'video' || isRunningHubVideoEndpoint(normalized))) {\n return { ...DEFAULT_VIDEO_CAPABILITY, durations: resolveRunningHubVideoDurations(normalized) };\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地画布元素。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n xai: ['1:1', '16:9', '9:16', '4:3', '3:4', '2:3', '3:2'], // Grok Imagine Video\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n xai: 'https://api.x.ai/v1',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^xai-/i.test(trimmed)) return 'xai';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n xai: 'xAI Grok',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction assertResolvedUpstreamModel(model: string): void {\n if (stripModelSelectionRef(model).trim().toLowerCase().startsWith('flovart:')) {\n throw new Error(`产品模型 ${model} 尚未解析为供应商上游模型。请检查该产品模型的 BYOK 映射。`);\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie') || normalized.includes('motion-control')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction adaptVideoSlotsForMode(slots: MultimodalSlot[], mode?: ProductModelMode, provider?: AIProvider): MultimodalSlot[] {\n if (!mode) return slots;\n const images = slots.filter(slot => slot.kind === 'image');\n if (mode === 'text-to-video') return [];\n if (mode === 'image-to-video') {\n if (!images[0]) throw new Error('图生视频至少需要 1 张图片。');\n return [{ ...images[0], role: 'first_frame' }];\n }\n if (mode === 'first-last-frame') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配首尾帧,请切换模式或重新映射线路。');\n if (images.length < 2) throw new Error('首尾帧模式需要 2 张图片。');\n return [{ ...images[0], role: 'first_frame' }, { ...images[1], role: 'last_frame' }];\n }\n if (mode === 'reference-to-video') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配全能参考,请切换模式或重新映射线路。');\n const references = provider === 'google' ? images.slice(0, 3) : slots;\n if (!references.length) throw new Error(provider === 'google' ? 'Veo 全能参考至少需要 1 张图片。' : '全能参考至少需要 1 个媒体素材。');\n return references.map(slot => ({\n ...slot,\n role: slot.kind === 'video' ? 'reference_video' : slot.kind === 'audio' ? 'reference_audio' : 'reference_image',\n }));\n }\n return slots;\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nconst RUNNINGHUB_STANDARD_MODEL_DEFAULTS: Array<{ pattern: RegExp; payload: Record<string, unknown> }> = [\n { pattern: /^rhart-image-(?:g-2|n-g31-flash)\\/(?:text-to-image|image-to-image)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-n-pro(?:-official)?\\/(?:text-to-image|edit(?:-ultra)?)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-v1(?:-official)?\\/(?:text-to-image|edit)$/i, payload: { resolution: '1k' } },\n { pattern: /^youchuan\\/text-to-image-v81$/i, payload: { hd: false } },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:text-to-video|image-to-video|multimodal-video)$/i,\n payload: {\n resolution: '720p',\n duration: '5',\n generateAudio: true,\n ratio: 'adaptive',\n realPersonMode: true,\n conversionSlots: ['all'],\n returnLastFrame: false,\n seed: -1,\n },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/text-to-video$/i,\n payload: { webSearch: false },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:image-to-video|multimodal-video)$/i,\n payload: { realPersonMode: true, conversionSlots: ['all'] },\n },\n // Veo 3.1 系列(Google DeepMind 旗舰视频,原生音频 + 口型同步)\n {\n pattern: /^rhart-video-v3\\.1(?:-[\\w-]+)?\\/(?:text-to-video|image-to-video|start-end-to-video)$/i,\n payload: { resolution: '720p', duration: '8', generateAudio: false },\n },\n {\n pattern: /^rhart-video-v3\\.1-pro-official\\/reference-to-video$/i,\n payload: { resolution: '1080p', generateAudio: false },\n },\n // SkyReels V4 Omni(天工 AI 统一多模态视频,@tag 引用机制)\n {\n pattern: /^skyreels-v4\\/omni-reference(?:-(?:fast|std))?$/i,\n payload: { resolution: '1080p', duration: 5, promptOptimizer: true },\n },\n // 全能视频S(低价渠道版,duration '10'/'15',aspectRatio 16:9/9:16)\n {\n pattern: /^rhart-video-s\\/text-to-video$/i,\n payload: { duration: '10' },\n },\n];\n\nfunction runningHubStandardModelDefaults(modelEndpoint: string) {\n return RUNNINGHUB_STANDARD_MODEL_DEFAULTS.reduce<Record<string, unknown>>((payload, preset) => {\n if (preset.pattern.test(modelEndpoint)) Object.assign(payload, preset.payload);\n return payload;\n }, {});\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31Model(modelEndpoint: string) {\n return /rhart-video-v3\\.1/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LowPriceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-(?:fast|pro)\\//i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31ReferenceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1[\\w.-]*\\/reference-to-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LiteModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-lite/i.test(modelEndpoint);\n}\n\nfunction isRunningHubOmniModel(modelEndpoint: string) {\n return /skyreels-v4\\/omni-reference/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoSModel(modelEndpoint: string) {\n return /rhart-video-s\\//i.test(modelEndpoint);\n}\n\nfunction resolveRunningHubVideoDurations(model: string): number[] {\n if (isRunningHubVeo31ReferenceModel(model)) return [];\n if (isRunningHubVeo31Model(model)) {\n if (isRunningHubVeo31LowPriceModel(model)) return [8];\n if (/start-end-to-video/i.test(model)) return isRunningHubVeo31LiteModel(model) ? [] : [8];\n return [4, 6, 8];\n }\n if (isRunningHubOmniModel(model)) return [3, 5, 8, 10, 12, 15];\n if (isRunningHubVideoSModel(model)) return [10, 15];\n return [5, 8, 10];\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video|reference-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (isRunningHubOmniModel(modelEndpoint) && references.length > 0) {\n throw new Error('SkyReels V4 Omni 参考输入(refImages/refVideos)暂未支持,请去掉参考图后使用文生视频。');\n }\n\n // Veo 3.1 Fast official image-to-video:imageUrl + optional lastImageUrl。\n if (/rhart-video-v3\\.1-fast-official\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n if (last?.href) output.lastImageUrl = assertRunningHubPublicUrl(last.href, 'lastImageUrl');\n return output;\n }\n\n // Veo 3.1 Pro / Lite official image-to-video:imageUrl 单数;Fast 低价图生视频仍用 imageUrls 数组。\n if (/rhart-video-v3\\.1-(?:pro(?:-official)?|lite-official)\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n return output;\n }\n\n // Veo 3.1 start-end-to-video:firstFrameUrl + lastFrameUrl\n if (/rhart-video-v3\\.1[\\w.-]*\\/start-end-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // youchuan/text-to-image-v81 垫图用 imageUrl 单数(非 imageUrls 数组)\n if (/youchuan\\/text-to-image/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // Veo 3.1 Fast 低价图生视频 + 各 reference-to-video:imageUrls 数组 max 3 (schema api-448183087/459865180)\n if (/rhart-video-v3\\.1[\\w.-]*\\/(?:image-to-video|reference-to-video)/i.test(modelEndpoint)) {\n if (urls.length > 3) console.warn(`[RunningHub] ${modelEndpoint} 仅支持最多 3 张参考图,已截断 ${urls.length} → 3`);\n output.imageUrls = uniqueLimitedUrls(urls, 3);\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = uniqueLimitedUrls(imageUrls, 9);\n if (videoUrls.length > 0) output.videoUrls = uniqueLimitedUrls(videoUrls, 3);\n if (audioUrls.length > 0) output.audioUrls = uniqueLimitedUrls(audioUrls, 3);\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...(!disableDefaults && !nodeFieldModel ? runningHubStandardModelDefaults(modelEndpoint) : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n const isVeo31Ref = isRunningHubVeo31ReferenceModel(modelEndpoint);\n const isVeo31 = isRunningHubVeo31Model(modelEndpoint);\n const isVeo31LowPrice = isRunningHubVeo31LowPriceModel(modelEndpoint);\n const isProOfficialRef = /rhart-video-v3\\.1-pro-official\\/reference-to-video/i.test(modelEndpoint);\n if (options?.aspectRatio && !isProOfficialRef) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (isRunningHubVideoEndpoint(modelEndpoint)) {\n payload.resolution = options?.resolution || payload.resolution || '720p';\n if (isVeo31Ref) {\n // Veo 3.1 reference-to-video:无 duration;fast-official 保留 aspectRatio (16:9/9:16),pro-official 无 aspectRatio 字段\n delete payload.duration;\n if (isProOfficialRef) {\n delete payload.aspectRatio;\n } else if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') {\n payload.aspectRatio = '16:9';\n }\n } else if (isVeo31) {\n // Veo 3.1:duration 必须是 '4'/'6'/'8' 字符串\n const isStartEnd = /start-end-to-video/i.test(modelEndpoint);\n const isLiteOfficial = isRunningHubVeo31LiteModel(modelEndpoint);\n if (isVeo31LowPrice) {\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n delete payload.generateAudio;\n payload.duration = '8';\n } else if (isStartEnd) {\n // Veo 3.1 start-end-to-video: schema 只有 duration='8' (lite-official 无 duration 无 generateAudio 字段)\n if (isLiteOfficial) {\n delete payload.duration;\n delete payload.generateAudio;\n } else {\n payload.duration = '8';\n }\n } else {\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 8;\n const snapped = [4, 6, 8].reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, 4);\n payload.duration = String(snapped);\n }\n } else if (isRunningHubOmniModel(modelEndpoint)) {\n // Omni:duration 是 3-15 数字\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 5;\n payload.duration = Math.max(3, Math.min(15, Math.round(requested)));\n } else if (isRunningHubVideoSModel(modelEndpoint)) {\n // 全能视频S:duration 是 '10'/'15' 字符串,aspectRatio 只支持 '9:16'/'16:9'\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 10;\n payload.duration = requested >= 15 ? '15' : '10';\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n } else {\n payload.duration = String((options?.durationSec && options.durationSec > 0) ? options.durationSec : (payload.duration || 5));\n }\n } else {\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n }\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(grok-|xai)/i.test(model)) return 'xai';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal; aspectRatio?: VideoAspectRatio; resolution?: string; quality?: string; webSearch?: boolean },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (isGoogleImageEditModel(model)) {\n return editImage(refs, prompt, undefined, key?.key, options?.signal, {\n model,\n aspectRatio: options?.aspectRatio === 'adaptive' ? undefined : options?.aspectRatio,\n imageSize: options?.resolution,\n webSearch: options?.webSearch,\n baseUrl: key?.baseUrl,\n });\n }\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal, { model, baseUrl: key?.baseUrl });\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal, { model, baseUrl: key?.baseUrl });\n }\n return generateImageFromText(prompt, key?.key, options?.signal, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const imageSize = getOpenAIImageSize(options?.resolution, options?.aspectRatio);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key, undefined, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport class SeedanceSubmissionUnknownError extends Error {\n readonly code = 'SEEDANCE_SUBMISSION_UNKNOWN';\n constructor(cause?: unknown) {\n super('Seedance 提交结果未知:供应商可能已经创建并计费。已禁止自动重试或切换线路,请先到任务记录核对。', { cause });\n this.name = 'SeedanceSubmissionUnknownError';\n }\n}\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const rawSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n const slots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, 'volcengine');\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n let createRes: Response;\n try {\n createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n } catch (error) {\n throw new SeedanceSubmissionUnknownError(error);\n }\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 区分官方 Ark 与第三方 OpenAI-compatible 网关:\n * - Ark 官方:baseUrl 含 `ark.*.volces.com` 或路径含 `/api/v3`,惯例 DELETE `/contents/generations/tasks/{id}`\n * - 网关(如 Scnet):路径含 `/api/llm/v1`,惯例 POST `/tasks/{id}/cancel`\n * - 其他:fallback 到 Ark 风格(保留向后兼容)。\n */\nfunction detectSeedanceCancelRoute(baseUrl: string): 'ark' | 'gateway' {\n const lower = (baseUrl || '').toLowerCase();\n if (lower.includes('/api/llm/v1') || lower.includes('/v1/tasks')) return 'gateway';\n if (lower.includes('volces.com') || lower.includes('/api/v3')) return 'ark';\n return 'ark';\n}\n\nexport type SeedanceVideoCancelResult = {\n canceled: boolean;\n reason: 'ok' | 'not_queued' | 'not_cancellable' | 'unsupported' | 'network_error';\n upstreamStillRunning?: boolean;\n message?: string;\n raw?: unknown;\n};\n\n/**\n * 取消 Seedance 上游任务。仅对 queued 阶段的成功取消返回 `canceled: true`。\n * 上游已开始 processing、或上游不支持取消时返回 `canceled:false, upstreamStillRunning:true`,\n * 让前端走“放弃等待”的退路(仅本地 abortSignal 不再轮询上游)。\n */\nexport async function cancelSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal; reason?: string },\n): Promise<SeedanceVideoCancelResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const route = detectSeedanceCancelRoute(baseUrl);\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n if (route === 'ark') {\n // 官方 Ark:DELETE /contents/generations/tasks/{id}。409=不可取消;200/204=已取消。\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } else {\n // 网关风格:POST /tasks/{id}/cancel;body 可携带 reason。\n res = await fetch(`${baseUrl}/tasks/${taskId}/cancel`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },\n body: JSON.stringify({ reason: options?.reason || 'user_abort' }),\n signal: options?.signal,\n });\n }\n } catch (error) {\n return {\n canceled: false,\n reason: 'network_error',\n upstreamStillRunning: true,\n message: error instanceof Error ? error.message : '网络错误,无法确认取消结果',\n };\n }\n\n if (res.status === 405 || res.status === 404) {\n // 网关/官方未实现取消端点\n return {\n canceled: false,\n reason: 'unsupported',\n upstreamStillRunning: true,\n message: '当前 Seedance 渠道不支持取消,请等待完成或放弃等待',\n };\n }\n\n let raw: any = undefined;\n try {\n raw = await res.json();\n } catch {\n // 204 No Content 等成功无 body 的情况\n }\n\n if (res.ok) {\n // Scnet 风格可能返回 { code: 0 } 或 { success: true }; Ark 200 返回 { cancelled: true }\n // 兼容两种解析:cancelled=true / code===0 / success===true 视为成功\n const okFlag =\n raw?.cancelled === true\n || raw?.data?.cancelled === true\n || raw?.code === 0\n || raw?.success === true\n || (route === 'ark' && res.status === 200 && raw == null);\n return {\n canceled: Boolean(okFlag),\n reason: okFlag ? 'ok' : 'not_cancellable',\n upstreamStillRunning: !okFlag,\n message: okFlag ? undefined : (raw?.message || raw?.msg || '上游响应未确认取消'),\n raw,\n };\n }\n\n if (res.status === 409 || res.status === 400) {\n // Ark: 409 task_not_cancellable (已 processing);网关: 400 已开始\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || '任务已开始执行,上游无法取消',\n raw,\n };\n }\n\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || `上游返回 ${res.status}`,\n raw,\n };\n}\n\nexport type SeedanceVideoReconcileResult = {\n status: 'succeeded' | 'failed' | 'cancelled' | 'expired' | 'unknown';\n amount?: number;\n currency?: string;\n totalTokens?: number;\n reason?: string;\n raw?: unknown;\n};\n\nfunction parseSeedanceVideoUsage(raw: any, fallbackStatus: SeedanceVideoReconcileResult['status'] = 'unknown'): SeedanceVideoReconcileResult {\n const remoteStatus = String(raw?.status || raw?.data?.status || raw?.data?.task_status || raw?.task?.status || raw?.state || '').toLowerCase();\n let status: SeedanceVideoReconcileResult['status'] = fallbackStatus;\n if (['succeeded', 'succeed', 'success', 'completed', 'complete', 'done'].includes(remoteStatus)) status = 'succeeded';\n else if (['cancelled', 'canceled'].includes(remoteStatus)) status = 'cancelled';\n else if (['failed', 'fail', 'error'].includes(remoteStatus)) status = 'failed';\n else if (['expired', 'timeout'].includes(remoteStatus)) status = 'expired';\n const billingRaw = raw?.data?.billing || raw?.data?.usage?.billing || raw?.billing || raw?.usage?.billing;\n const amount = typeof billingRaw?.amount === 'number' ? billingRaw.amount : undefined;\n const currency = typeof billingRaw?.currency === 'string' ? billingRaw.currency : raw?.data?.currency || raw?.currency;\n const totalTokens = typeof raw?.usage?.total_tokens === 'number' ? raw.usage.total_tokens\n : typeof raw?.data?.usage?.total_tokens === 'number' ? raw.data.usage.total_tokens\n : typeof raw?.usage?.token_usage?.total_tokens === 'number' ? raw.usage.token_usage.total_tokens\n : undefined;\n return { status, amount, currency, totalTokens, raw };\n}\n\n/**\n * 查询上游任务的真实账单/token 使用量。复用 GET 任务查询接口;不修改 handle。\n *\n * - 网关(Scnet/Lemondata)常见字段:`data.billing.amount` / `data.usage.billing.amount` + `currency`\n * - Volcengine Ark 官方常见字段:`usage.total_tokens`(仅 token,无 billing 金额)\n *\n * 调用方拿到返回值后:\n * - amount 存在 → actualCost = amount,billableState = 'actual'\n * - 仅 totalTokens → actualTokens = totalTokens,billableState 保持 'unknown'\n * - status === 'unknown' → 不变更 usageRecord\n */\nexport async function reconcileSeedanceVideoUsage(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoReconcileResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } catch (error) {\n return { status: 'unknown', reason: error instanceof Error ? error.message : '网络错误' };\n }\n if (!res.ok) {\n return { status: 'unknown', reason: `上游返回 ${res.status}` };\n }\n let raw: any;\n try {\n raw = await res.json();\n } catch {\n return { status: 'unknown', reason: '响应不是合法 JSON' };\n }\n\n return parseSeedanceVideoUsage(raw);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const legacyReferences = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const rawSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(legacyReferences);\n const multimodalSlots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, provider);\n const references: VideoImage[] = multimodalSlots.filter(slot => slot.kind === 'image').map(slot => ({\n href: slot.href,\n mimeType: slot.mimeType,\n slotRole: String(slot.role || 'unassigned'),\n label: slot.label,\n }));\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const usesFirstFrame = !options?.generationSubmode || options.generationSubmode === 'image-to-video' || options.generationSubmode === 'first-last-frame';\n const firstFrame = usesFirstFrame\n ? references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined)\n : undefined;\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n const lastFrameSlot = options?.generationSubmode === 'first-last-frame'\n ? multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'last_frame')\n : undefined;\n const referenceImages = options?.generationSubmode === 'reference-to-video'\n ? multimodalSlots.filter(slot => slot.kind === 'image' && slot.role !== 'first_frame' && slot.role !== 'last_frame').slice(0, 3)\n : [];\n return generateVideo(prompt, aspectRatio === '9:16' ? '9:16' : '16:9', onProgress, firstFrame, key?.key, {\n model,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n generateAudio: options?.generateAudio,\n signal: options?.signal,\n lastFrame: lastFrameSlot ? { href: lastFrameSlot.href, mimeType: lastFrameSlot.mimeType } : undefined,\n referenceImages: referenceImages.map(reference => ({ href: reference.href, mimeType: reference.mimeType })),\n baseUrl: key?.baseUrl,\n });\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl, { signal: options?.signal });\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: options?.resolution?.toLowerCase() === '1080p' ? 'pro' : 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: String(options?.durationSec || 5),\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl, { signal: options?.signal });\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'xai') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const submode: ProductModelMode = options?.generationSubmode || 'text-to-video';\n\n onProgress('Submitting video generation task...');\n let createPath: string;\n const createBody: Record<string, unknown> = { model, prompt };\n\n if (submode === 'video-extension') {\n createPath = `${baseUrl}/videos/extensions`;\n const videoSlot = multimodalSlots.find(slot => slot.kind === 'video');\n if (!videoSlot) {\n throw new Error('xAI 视频扩展至少需要 1 个视频素材作为驱动视频。');\n }\n createBody.video = { url: videoSlot.href };\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n } else {\n createPath = `${baseUrl}/videos/generations`;\n if (aspectRatio) {\n createBody.aspect_ratio = aspectRatio;\n }\n if (options?.resolution) {\n createBody.resolution = options.resolution;\n }\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n if (firstFrame) {\n createBody.image = { url: firstFrame.href };\n }\n }\n\n const createRes = await fetch(createPath, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'xAI 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'xAI 视频生成创建响应');\n const requestId = createJson?.request_id;\n if (!requestId) {\n throw new Error('xAI 视频生成未返回 request_id');\n }\n\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const xaiPollStart = Date.now();\n while (true) {\n if (Date.now() - xaiPollStart > 600_000) {\n throw new Error('xAI 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'xAI 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'xAI 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'failed') {\n throw new Error(`xAI 视频生成失败: ${queryJson?.error || queryJson?.message || 'Unknown error'}`);\n }\n if (status === 'done') {\n videoUrl = queryJson?.output?.url;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!videoUrl) {\n throw new Error('xAI 视频生成完成但未返回视频 URL');\n }\n\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n await options?.onProviderTaskLifecycle?.({ phase: 'submitted', providerTaskId: handle.taskId, submittedAt: handle.createdAt });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n let cancelPromise: Promise<SeedanceVideoCancelResult> | undefined;\n const requestUpstreamCancel = () => cancelPromise ||= cancelSeedanceVideoTask(handle, key, { reason: 'user_abort' }).then(async result => {\n await options?.onProviderTaskLifecycle?.({ phase: 'cancelled', providerTaskId: handle.taskId, canceled: result.canceled, upstreamStillRunning: result.upstreamStillRunning, message: result.message });\n const usage = await reconcileSeedanceVideoUsage(handle, key);\n if (usage.status !== 'unknown' || usage.amount !== undefined || usage.totalTokens !== undefined) {\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n }\n return result;\n });\n const handleAbort = () => { void requestUpstreamCancel(); };\n options?.signal?.addEventListener('abort', handleAbort, { once: true });\n\n try {\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n const usage = parseSeedanceVideoUsage(result.raw, 'failed');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n const usage = parseSeedanceVideoUsage(result.raw, 'succeeded');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n await options?.onProviderTaskLifecycle?.({ phase: 'running', providerTaskId: handle.taskId, remoteStatus: result.remoteStatus });\n }\n } catch (error) {\n if (options?.signal?.aborted || error instanceof Error && error.name === 'AbortError') await requestUpstreamCancel();\n throw error;\n } finally {\n options?.signal?.removeEventListener('abort', handleAbort);\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const params = sanitizeProductGenerationParams(input.productModelId, {\n mode: input.generationSubmode,\n aspectRatio: input.aspectRatio,\n resolution: input.resolution,\n quality: input.quality,\n durationSec: input.durationSec,\n generateAudio: input.generateAudio,\n webSearch: input.webSearch,\n realPersonCheck: input.realPersonCheck,\n referenceCount: input.references?.length || 0,\n });\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: params.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: params.durationSec,\n resolution: params.resolution,\n generateAudio: params.generateAudio,\n watermark: input.watermark,\n generationSubmode: params.mode,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n onProviderTaskLifecycle: input.onProviderTaskLifecycle,\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, {\n signal: input.signal,\n aspectRatio: params.aspectRatio,\n resolution: params.resolution,\n quality: params.quality,\n webSearch: params.webSearch,\n });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "3220b71f5f478d2cc9b711193848565e2c4413fb": {"t": 1785773461.8034294, "n": 153834, "h": "7b2179c98873e1d3", "c": "import type { AICapability, AIProvider, ProductModelMode, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\nimport { sanitizeProductGenerationParams } from './productModelCatalog';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n productModelId?: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n quality?: string;\n generationSubmode?: ProductModelMode;\n generateAudio?: boolean;\n watermark?: boolean;\n webSearch?: boolean;\n realPersonCheck?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image', 'gemini-3-pro-image', 'gemini-2.5-flash-image'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-fast-generate-preview', 'veo-3.1-lite-generate-preview'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '3:2' | '2:3' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\nconst OPENAI_IMAGE_SIZES: Record<'1K' | '2K' | '4K', Record<string, string>> = {\n '1K': { '1:1': '1024x1024', '3:2': '1536x1024', '2:3': '1024x1536', '16:9': '1792x1024', '9:16': '1024x1792', '4:3': '1344x1008', '3:4': '1008x1344' },\n '2K': { '1:1': '2048x2048', '3:2': '2048x1360', '2:3': '1360x2048', '16:9': '2048x1152', '9:16': '1152x2048', '4:3': '2048x1536', '3:4': '1536x2048' },\n '4K': { '1:1': '2880x2880', '3:2': '3520x2352', '2:3': '2352x3520', '16:9': '3840x2160', '9:16': '2160x3840', '4:3': '3312x2480', '3:4': '2480x3312' },\n};\n\nfunction getOpenAIImageSize(resolution?: string, aspectRatio?: VideoAspectRatio): string {\n const tier = resolution?.toUpperCase() === '4K' ? '4K' : resolution?.toUpperCase() === '2K' ? '2K' : '1K';\n return OPENAI_IMAGE_SIZES[tier][aspectRatio || '1:1'] || OPENAI_IMAGE_SIZES[tier]['1:1'];\n}\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance') || isRunningHubSeedance20Model(normalized)) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'runningHub' && (inferCapabilityFromModelName(model) === 'video' || isRunningHubVideoEndpoint(normalized))) {\n return { ...DEFAULT_VIDEO_CAPABILITY, durations: resolveRunningHubVideoDurations(normalized) };\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地画布元素。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie') || normalized.includes('motion-control')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nconst RUNNINGHUB_STANDARD_MODEL_DEFAULTS: Array<{ pattern: RegExp; payload: Record<string, unknown> }> = [\n { pattern: /^rhart-image-(?:g-2|n-g31-flash)\\/(?:text-to-image|image-to-image)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-n-pro(?:-official)?\\/(?:text-to-image|edit(?:-ultra)?)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-v1(?:-official)?\\/(?:text-to-image|edit)$/i, payload: { resolution: '1k' } },\n { pattern: /^youchuan\\/text-to-image-v81$/i, payload: { hd: false } },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:text-to-video|image-to-video|multimodal-video)$/i,\n payload: {\n resolution: '720p',\n duration: '5',\n generateAudio: true,\n ratio: 'adaptive',\n realPersonMode: true,\n conversionSlots: ['all'],\n returnLastFrame: false,\n seed: -1,\n },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/text-to-video$/i,\n payload: { webSearch: false },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:image-to-video|multimodal-video)$/i,\n payload: { realPersonMode: true, conversionSlots: ['all'] },\n },\n // Veo 3.1 系列(Google DeepMind 旗舰视频,原生音频 + 口型同步)\n {\n pattern: /^rhart-video-v3\\.1(?:-[\\w-]+)?\\/(?:text-to-video|image-to-video|start-end-to-video)$/i,\n payload: { resolution: '720p', duration: '8', generateAudio: false },\n },\n {\n pattern: /^rhart-video-v3\\.1-pro-official\\/reference-to-video$/i,\n payload: { resolution: '1080p', generateAudio: false },\n },\n // SkyReels V4 Omni(天工 AI 统一多模态视频,@tag 引用机制)\n {\n pattern: /^skyreels-v4\\/omni-reference(?:-(?:fast|std))?$/i,\n payload: { resolution: '1080p', duration: 5, promptOptimizer: true },\n },\n // 全能视频S(低价渠道版,duration '10'/'15',aspectRatio 16:9/9:16)\n {\n pattern: /^rhart-video-s\\/text-to-video$/i,\n payload: { duration: '10' },\n },\n];\n\nfunction runningHubStandardModelDefaults(modelEndpoint: string) {\n return RUNNINGHUB_STANDARD_MODEL_DEFAULTS.reduce<Record<string, unknown>>((payload, preset) => {\n if (preset.pattern.test(modelEndpoint)) Object.assign(payload, preset.payload);\n return payload;\n }, {});\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31Model(modelEndpoint: string) {\n return /rhart-video-v3\\.1/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LowPriceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-(?:fast|pro)\\//i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31ReferenceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1[\\w.-]*\\/reference-to-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LiteModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-lite/i.test(modelEndpoint);\n}\n\nfunction isRunningHubOmniModel(modelEndpoint: string) {\n return /skyreels-v4\\/omni-reference/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoSModel(modelEndpoint: string) {\n return /rhart-video-s\\//i.test(modelEndpoint);\n}\n\nfunction resolveRunningHubVideoDurations(model: string): number[] {\n if (isRunningHubVeo31ReferenceModel(model)) return [];\n if (isRunningHubVeo31Model(model)) {\n if (isRunningHubVeo31LowPriceModel(model)) return [8];\n if (/start-end-to-video/i.test(model)) return isRunningHubVeo31LiteModel(model) ? [] : [8];\n return [4, 6, 8];\n }\n if (isRunningHubOmniModel(model)) return [3, 5, 8, 10, 12, 15];\n if (isRunningHubVideoSModel(model)) return [10, 15];\n return [5, 8, 10];\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video|reference-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (isRunningHubOmniModel(modelEndpoint) && references.length > 0) {\n throw new Error('SkyReels V4 Omni 参考输入(refImages/refVideos)暂未支持,请去掉参考图后使用文生视频。');\n }\n\n // Veo 3.1 Fast official image-to-video:imageUrl + optional lastImageUrl。\n if (/rhart-video-v3\\.1-fast-official\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n if (last?.href) output.lastImageUrl = assertRunningHubPublicUrl(last.href, 'lastImageUrl');\n return output;\n }\n\n // Veo 3.1 Pro / Lite official image-to-video:imageUrl 单数;Fast 低价图生视频仍用 imageUrls 数组。\n if (/rhart-video-v3\\.1-(?:pro(?:-official)?|lite-official)\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n return output;\n }\n\n // Veo 3.1 start-end-to-video:firstFrameUrl + lastFrameUrl\n if (/rhart-video-v3\\.1[\\w.-]*\\/start-end-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // youchuan/text-to-image-v81 垫图用 imageUrl 单数(非 imageUrls 数组)\n if (/youchuan\\/text-to-image/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // Veo 3.1 Fast 低价图生视频 + 各 reference-to-video:imageUrls 数组 max 3 (schema api-448183087/459865180)\n if (/rhart-video-v3\\.1[\\w.-]*\\/(?:image-to-video|reference-to-video)/i.test(modelEndpoint)) {\n if (urls.length > 3) console.warn(`[RunningHub] ${modelEndpoint} 仅支持最多 3 张参考图,已截断 ${urls.length} → 3`);\n output.imageUrls = uniqueLimitedUrls(urls, 3);\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = uniqueLimitedUrls(imageUrls, 9);\n if (videoUrls.length > 0) output.videoUrls = uniqueLimitedUrls(videoUrls, 3);\n if (audioUrls.length > 0) output.audioUrls = uniqueLimitedUrls(audioUrls, 3);\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...(!disableDefaults && !nodeFieldModel ? runningHubStandardModelDefaults(modelEndpoint) : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n const isVeo31Ref = isRunningHubVeo31ReferenceModel(modelEndpoint);\n const isVeo31 = isRunningHubVeo31Model(modelEndpoint);\n const isVeo31LowPrice = isRunningHubVeo31LowPriceModel(modelEndpoint);\n const isProOfficialRef = /rhart-video-v3\\.1-pro-official\\/reference-to-video/i.test(modelEndpoint);\n if (options?.aspectRatio && !isProOfficialRef) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (isRunningHubVideoEndpoint(modelEndpoint)) {\n payload.resolution = options?.resolution || payload.resolution || '720p';\n if (isVeo31Ref) {\n // Veo 3.1 reference-to-video:无 duration;fast-official 保留 aspectRatio (16:9/9:16),pro-official 无 aspectRatio 字段\n delete payload.duration;\n if (isProOfficialRef) {\n delete payload.aspectRatio;\n } else if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') {\n payload.aspectRatio = '16:9';\n }\n } else if (isVeo31) {\n // Veo 3.1:duration 必须是 '4'/'6'/'8' 字符串\n const isStartEnd = /start-end-to-video/i.test(modelEndpoint);\n const isLiteOfficial = isRunningHubVeo31LiteModel(modelEndpoint);\n if (isVeo31LowPrice) {\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n delete payload.generateAudio;\n payload.duration = '8';\n } else if (isStartEnd) {\n // Veo 3.1 start-end-to-video: schema 只有 duration='8' (lite-official 无 duration 无 generateAudio 字段)\n if (isLiteOfficial) {\n delete payload.duration;\n delete payload.generateAudio;\n } else {\n payload.duration = '8';\n }\n } else {\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 8;\n const snapped = [4, 6, 8].reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, 4);\n payload.duration = String(snapped);\n }\n } else if (isRunningHubOmniModel(modelEndpoint)) {\n // Omni:duration 是 3-15 数字\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 5;\n payload.duration = Math.max(3, Math.min(15, Math.round(requested)));\n } else if (isRunningHubVideoSModel(modelEndpoint)) {\n // 全能视频S:duration 是 '10'/'15' 字符串,aspectRatio 只支持 '9:16'/'16:9'\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 10;\n payload.duration = requested >= 15 ? '15' : '10';\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n } else {\n payload.duration = String((options?.durationSec && options.durationSec > 0) ? options.durationSec : (payload.duration || 5));\n }\n } else {\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n }\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal; aspectRatio?: VideoAspectRatio; resolution?: string; quality?: string; webSearch?: boolean },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (isGoogleImageEditModel(model)) {\n return editImage(refs, prompt, undefined, key?.key, options?.signal, {\n model,\n aspectRatio: options?.aspectRatio === 'adaptive' ? undefined : options?.aspectRatio,\n imageSize: options?.resolution,\n webSearch: options?.webSearch,\n });\n }\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal);\n }\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const imageSize = getOpenAIImageSize(options?.resolution, options?.aspectRatio);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport class SeedanceSubmissionUnknownError extends Error {\n readonly code = 'SEEDANCE_SUBMISSION_UNKNOWN';\n constructor(cause?: unknown) {\n super('Seedance 提交结果未知:供应商可能已经创建并计费。已禁止自动重试或切换线路,请先到任务记录核对。', { cause });\n this.name = 'SeedanceSubmissionUnknownError';\n }\n}\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n let createRes: Response;\n try {\n createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n } catch (error) {\n throw new SeedanceSubmissionUnknownError(error);\n }\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 区分官方 Ark 与第三方 OpenAI-compatible 网关:\n * - Ark 官方:baseUrl 含 `ark.*.volces.com` 或路径含 `/api/v3`,惯例 DELETE `/contents/generations/tasks/{id}`\n * - 网关(如 Scnet):路径含 `/api/llm/v1`,惯例 POST `/tasks/{id}/cancel`\n * - 其他:fallback 到 Ark 风格(保留向后兼容)。\n */\nfunction detectSeedanceCancelRoute(baseUrl: string): 'ark' | 'gateway' {\n const lower = (baseUrl || '').toLowerCase();\n if (lower.includes('/api/llm/v1') || lower.includes('/v1/tasks')) return 'gateway';\n if (lower.includes('volces.com') || lower.includes('/api/v3')) return 'ark';\n return 'ark';\n}\n\nexport type SeedanceVideoCancelResult = {\n canceled: boolean;\n reason: 'ok' | 'not_queued' | 'not_cancellable' | 'unsupported' | 'network_error';\n upstreamStillRunning?: boolean;\n message?: string;\n raw?: unknown;\n};\n\n/**\n * 取消 Seedance 上游任务。仅对 queued 阶段的成功取消返回 `canceled: true`。\n * 上游已开始 processing、或上游不支持取消时返回 `canceled:false, upstreamStillRunning:true`,\n * 让前端走“放弃等待”的退路(仅本地 abortSignal 不再轮询上游)。\n */\nexport async function cancelSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal; reason?: string },\n): Promise<SeedanceVideoCancelResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const route = detectSeedanceCancelRoute(baseUrl);\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n if (route === 'ark') {\n // 官方 Ark:DELETE /contents/generations/tasks/{id}。409=不可取消;200/204=已取消。\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } else {\n // 网关风格:POST /tasks/{id}/cancel;body 可携带 reason。\n res = await fetch(`${baseUrl}/tasks/${taskId}/cancel`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },\n body: JSON.stringify({ reason: options?.reason || 'user_abort' }),\n signal: options?.signal,\n });\n }\n } catch (error) {\n return {\n canceled: false,\n reason: 'network_error',\n upstreamStillRunning: true,\n message: error instanceof Error ? error.message : '网络错误,无法确认取消结果',\n };\n }\n\n if (res.status === 405 || res.status === 404) {\n // 网关/官方未实现取消端点\n return {\n canceled: false,\n reason: 'unsupported',\n upstreamStillRunning: true,\n message: '当前 Seedance 渠道不支持取消,请等待完成或放弃等待',\n };\n }\n\n let raw: any = undefined;\n try {\n raw = await res.json();\n } catch {\n // 204 No Content 等成功无 body 的情况\n }\n\n if (res.ok) {\n // Scnet 风格可能返回 { code: 0 } 或 { success: true }; Ark 200 返回 { cancelled: true }\n // 兼容两种解析:cancelled=true / code===0 / success===true 视为成功\n const okFlag =\n raw?.cancelled === true\n || raw?.data?.cancelled === true\n || raw?.code === 0\n || raw?.success === true\n || (route === 'ark' && res.status === 200 && raw == null);\n return {\n canceled: Boolean(okFlag),\n reason: okFlag ? 'ok' : 'not_cancellable',\n upstreamStillRunning: !okFlag,\n message: okFlag ? undefined : (raw?.message || raw?.msg || '上游响应未确认取消'),\n raw,\n };\n }\n\n if (res.status === 409 || res.status === 400) {\n // Ark: 409 task_not_cancellable (已 processing);网关: 400 已开始\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || '任务已开始执行,上游无法取消',\n raw,\n };\n }\n\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || `上游返回 ${res.status}`,\n raw,\n };\n}\n\nexport type SeedanceVideoReconcileResult = {\n status: 'succeeded' | 'failed' | 'cancelled' | 'expired' | 'unknown';\n amount?: number;\n currency?: string;\n totalTokens?: number;\n reason?: string;\n raw?: unknown;\n};\n\n/**\n * 查询上游任务的真实账单/token 使用量。复用 GET 任务查询接口;不修改 handle。\n *\n * - 网关(Scnet/Lemondata)常见字段:`data.billing.amount` / `data.usage.billing.amount` + `currency`\n * - Volcengine Ark 官方常见字段:`usage.total_tokens`(仅 token,无 billing 金额)\n *\n * 调用方拿到返回值后:\n * - amount 存在 → actualCost = amount,billableState = 'actual'\n * - 仅 totalTokens → actualTokens = totalTokens,billableState 保持 'unknown'\n * - status === 'unknown' → 不变更 usageRecord\n */\nexport async function reconcileSeedanceVideoUsage(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoReconcileResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } catch (error) {\n return { status: 'unknown', reason: error instanceof Error ? error.message : '网络错误' };\n }\n if (!res.ok) {\n return { status: 'unknown', reason: `上游返回 ${res.status}` };\n }\n let raw: any;\n try {\n raw = await res.json();\n } catch {\n return { status: 'unknown', reason: '响应不是合法 JSON' };\n }\n\n const remoteStatus = String(raw?.status || raw?.data?.status || raw?.data?.task_status || raw?.task?.status || raw?.state || '').toLowerCase();\n let status: SeedanceVideoReconcileResult['status'] = 'unknown';\n if (['succeeded', 'succeed', 'success', 'completed', 'complete', 'done'].includes(remoteStatus)) status = 'succeeded';\n else if (['cancelled', 'canceled'].includes(remoteStatus)) status = 'cancelled';\n else if (['failed', 'fail', 'error'].includes(remoteStatus)) status = 'failed';\n else if (['expired', 'timeout'].includes(remoteStatus)) status = 'expired';\n\n const billingRaw = raw?.data?.billing || raw?.data?.usage?.billing || raw?.billing || raw?.usage?.billing;\n const amount = typeof billingRaw?.amount === 'number' ? billingRaw.amount : undefined;\n const currency = typeof billingRaw?.currency === 'string' ? billingRaw.currency : raw?.data?.currency || raw?.currency;\n const totalTokens =\n typeof raw?.usage?.total_tokens === 'number' ? raw.usage.total_tokens\n : typeof raw?.data?.usage?.total_tokens === 'number' ? raw.data.usage.total_tokens\n : typeof raw?.usage?.token_usage?.total_tokens === 'number' ? raw.usage.token_usage.total_tokens\n : undefined;\n\n return { status, amount, currency, totalTokens, raw };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const multimodalSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const usesFirstFrame = !options?.generationSubmode || options.generationSubmode === 'image-to-video' || options.generationSubmode === 'first-last-frame';\n const firstFrame = usesFirstFrame\n ? references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined)\n : undefined;\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = hasExplicitSlots\n ? [\n ...references,\n ...multimodalSlots\n .filter(slot => slot.kind === 'image')\n .map(slot => ({ href: slot.href, mimeType: slot.mimeType, slotRole: String(slot.role || 'unassigned') })),\n ]\n : references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n const lastFrameSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'last_frame');\n const referenceImages = options?.generationSubmode === 'reference-to-video'\n ? multimodalSlots.filter(slot => slot.kind === 'image' && slot.role !== 'first_frame' && slot.role !== 'last_frame').slice(0, 3)\n : [];\n return generateVideo(prompt, aspectRatio === '9:16' ? '9:16' : '16:9', onProgress, firstFrame, key?.key, {\n model,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n generateAudio: options?.generateAudio,\n signal: options?.signal,\n lastFrame: lastFrameSlot ? { href: lastFrameSlot.href, mimeType: lastFrameSlot.mimeType } : undefined,\n referenceImages: referenceImages.map(reference => ({ href: reference.href, mimeType: reference.mimeType })),\n });\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const params = sanitizeProductGenerationParams(input.productModelId, {\n mode: input.generationSubmode,\n aspectRatio: input.aspectRatio,\n resolution: input.resolution,\n quality: input.quality,\n durationSec: input.durationSec,\n generateAudio: input.generateAudio,\n webSearch: input.webSearch,\n realPersonCheck: input.realPersonCheck,\n referenceCount: input.references?.length || 0,\n });\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: params.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: params.durationSec,\n resolution: params.resolution,\n generateAudio: params.generateAudio,\n watermark: input.watermark,\n generationSubmode: input.generationSubmode,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, {\n signal: input.signal,\n aspectRatio: params.aspectRatio,\n resolution: params.resolution,\n quality: params.quality,\n webSearch: params.webSearch,\n });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "950985ee8977dadfa1f12a9d6d360f6c3fec8b9a": {"t": 1785773464.7590244, "n": 142745, "h": "8946fbb693a8ed15", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance') || isRunningHubSeedance20Model(normalized)) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'runningHub' && (inferCapabilityFromModelName(model) === 'video' || isRunningHubVideoEndpoint(normalized))) {\n return { ...DEFAULT_VIDEO_CAPABILITY, durations: resolveRunningHubVideoDurations(normalized) };\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地画布元素。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie') || normalized.includes('motion-control')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nconst RUNNINGHUB_STANDARD_MODEL_DEFAULTS: Array<{ pattern: RegExp; payload: Record<string, unknown> }> = [\n { pattern: /^rhart-image-(?:g-2|n-g31-flash)\\/(?:text-to-image|image-to-image)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-n-pro(?:-official)?\\/(?:text-to-image|edit(?:-ultra)?)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-v1(?:-official)?\\/(?:text-to-image|edit)$/i, payload: { resolution: '1k' } },\n { pattern: /^youchuan\\/text-to-image-v81$/i, payload: { hd: false } },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:text-to-video|image-to-video|multimodal-video)$/i,\n payload: {\n resolution: '720p',\n duration: '5',\n generateAudio: true,\n ratio: 'adaptive',\n realPersonMode: true,\n conversionSlots: ['all'],\n returnLastFrame: false,\n seed: -1,\n },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/text-to-video$/i,\n payload: { webSearch: false },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:image-to-video|multimodal-video)$/i,\n payload: { realPersonMode: true, conversionSlots: ['all'] },\n },\n // Veo 3.1 系列(Google DeepMind 旗舰视频,原生音频 + 口型同步)\n {\n pattern: /^rhart-video-v3\\.1(?:-[\\w-]+)?\\/(?:text-to-video|image-to-video|start-end-to-video)$/i,\n payload: { resolution: '720p', duration: '8', generateAudio: false },\n },\n {\n pattern: /^rhart-video-v3\\.1-pro-official\\/reference-to-video$/i,\n payload: { resolution: '1080p', generateAudio: false },\n },\n // SkyReels V4 Omni(天工 AI 统一多模态视频,@tag 引用机制)\n {\n pattern: /^skyreels-v4\\/omni-reference(?:-(?:fast|std))?$/i,\n payload: { resolution: '1080p', duration: 5, promptOptimizer: true },\n },\n // 全能视频S(低价渠道版,duration '10'/'15',aspectRatio 16:9/9:16)\n {\n pattern: /^rhart-video-s\\/text-to-video$/i,\n payload: { duration: '10' },\n },\n];\n\nfunction runningHubStandardModelDefaults(modelEndpoint: string) {\n return RUNNINGHUB_STANDARD_MODEL_DEFAULTS.reduce<Record<string, unknown>>((payload, preset) => {\n if (preset.pattern.test(modelEndpoint)) Object.assign(payload, preset.payload);\n return payload;\n }, {});\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31Model(modelEndpoint: string) {\n return /rhart-video-v3\\.1/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LowPriceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-(?:fast|pro)\\//i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31ReferenceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1[\\w.-]*\\/reference-to-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LiteModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-lite/i.test(modelEndpoint);\n}\n\nfunction isRunningHubOmniModel(modelEndpoint: string) {\n return /skyreels-v4\\/omni-reference/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoSModel(modelEndpoint: string) {\n return /rhart-video-s\\//i.test(modelEndpoint);\n}\n\nfunction resolveRunningHubVideoDurations(model: string): number[] {\n if (isRunningHubVeo31ReferenceModel(model)) return [];\n if (isRunningHubVeo31Model(model)) {\n if (isRunningHubVeo31LowPriceModel(model)) return [8];\n if (/start-end-to-video/i.test(model)) return isRunningHubVeo31LiteModel(model) ? [] : [8];\n return [4, 6, 8];\n }\n if (isRunningHubOmniModel(model)) return [3, 5, 8, 10, 12, 15];\n if (isRunningHubVideoSModel(model)) return [10, 15];\n return [5, 8, 10];\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video|reference-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (isRunningHubOmniModel(modelEndpoint) && references.length > 0) {\n throw new Error('SkyReels V4 Omni 参考输入(refImages/refVideos)暂未支持,请去掉参考图后使用文生视频。');\n }\n\n // Veo 3.1 Fast official image-to-video:imageUrl + optional lastImageUrl。\n if (/rhart-video-v3\\.1-fast-official\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n if (last?.href) output.lastImageUrl = assertRunningHubPublicUrl(last.href, 'lastImageUrl');\n return output;\n }\n\n // Veo 3.1 Pro / Lite official image-to-video:imageUrl 单数;Fast 低价图生视频仍用 imageUrls 数组。\n if (/rhart-video-v3\\.1-(?:pro(?:-official)?|lite-official)\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n return output;\n }\n\n // Veo 3.1 start-end-to-video:firstFrameUrl + lastFrameUrl\n if (/rhart-video-v3\\.1[\\w.-]*\\/start-end-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // youchuan/text-to-image-v81 垫图用 imageUrl 单数(非 imageUrls 数组)\n if (/youchuan\\/text-to-image/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // Veo 3.1 Fast 低价图生视频 + 各 reference-to-video:imageUrls 数组 max 3 (schema api-448183087/459865180)\n if (/rhart-video-v3\\.1[\\w.-]*\\/(?:image-to-video|reference-to-video)/i.test(modelEndpoint)) {\n if (urls.length > 3) console.warn(`[RunningHub] ${modelEndpoint} 仅支持最多 3 张参考图,已截断 ${urls.length} → 3`);\n output.imageUrls = uniqueLimitedUrls(urls, 3);\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = uniqueLimitedUrls(imageUrls, 9);\n if (videoUrls.length > 0) output.videoUrls = uniqueLimitedUrls(videoUrls, 3);\n if (audioUrls.length > 0) output.audioUrls = uniqueLimitedUrls(audioUrls, 3);\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...(!disableDefaults && !nodeFieldModel ? runningHubStandardModelDefaults(modelEndpoint) : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n const isVeo31Ref = isRunningHubVeo31ReferenceModel(modelEndpoint);\n const isVeo31 = isRunningHubVeo31Model(modelEndpoint);\n const isVeo31LowPrice = isRunningHubVeo31LowPriceModel(modelEndpoint);\n const isProOfficialRef = /rhart-video-v3\\.1-pro-official\\/reference-to-video/i.test(modelEndpoint);\n if (options?.aspectRatio && !isProOfficialRef) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (isRunningHubVideoEndpoint(modelEndpoint)) {\n payload.resolution = options?.resolution || payload.resolution || '720p';\n if (isVeo31Ref) {\n // Veo 3.1 reference-to-video:无 duration;fast-official 保留 aspectRatio (16:9/9:16),pro-official 无 aspectRatio 字段\n delete payload.duration;\n if (isProOfficialRef) {\n delete payload.aspectRatio;\n } else if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') {\n payload.aspectRatio = '16:9';\n }\n } else if (isVeo31) {\n // Veo 3.1:duration 必须是 '4'/'6'/'8' 字符串\n const isStartEnd = /start-end-to-video/i.test(modelEndpoint);\n const isLiteOfficial = isRunningHubVeo31LiteModel(modelEndpoint);\n if (isVeo31LowPrice) {\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n delete payload.generateAudio;\n payload.duration = '8';\n } else if (isStartEnd) {\n // Veo 3.1 start-end-to-video: schema 只有 duration='8' (lite-official 无 duration 无 generateAudio 字段)\n if (isLiteOfficial) {\n delete payload.duration;\n delete payload.generateAudio;\n } else {\n payload.duration = '8';\n }\n } else {\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 8;\n const snapped = [4, 6, 8].reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, 4);\n payload.duration = String(snapped);\n }\n } else if (isRunningHubOmniModel(modelEndpoint)) {\n // Omni:duration 是 3-15 数字\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 5;\n payload.duration = Math.max(3, Math.min(15, Math.round(requested)));\n } else if (isRunningHubVideoSModel(modelEndpoint)) {\n // 全能视频S:duration 是 '10'/'15' 字符串,aspectRatio 只支持 '9:16'/'16:9'\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 10;\n payload.duration = requested >= 15 ? '15' : '10';\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n } else {\n payload.duration = String((options?.durationSec && options.durationSec > 0) ? options.durationSec : (payload.duration || 5));\n }\n } else {\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n }\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal);\n }\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const multimodalSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = hasExplicitSlots\n ? [\n ...references,\n ...multimodalSlots\n .filter(slot => slot.kind === 'image')\n .map(slot => ({ href: slot.href, mimeType: slot.mimeType, slotRole: String(slot.role || 'unassigned') })),\n ]\n : references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, { signal: input.signal });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "1214912892b80ccd5ce721aa37061001d63a1d87": {"t": 1785773466.7404382, "n": 141906, "h": "f667ca0421514f34", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地画布元素。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nconst RUNNINGHUB_STANDARD_MODEL_DEFAULTS: Array<{ pattern: RegExp; payload: Record<string, unknown> }> = [\n { pattern: /^rhart-image-(?:g-2|n-g31-flash)\\/(?:text-to-image|image-to-image)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-n-pro(?:-official)?\\/(?:text-to-image|edit(?:-ultra)?)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-v1(?:-official)?\\/(?:text-to-image|edit)$/i, payload: { resolution: '1k' } },\n { pattern: /^youchuan\\/text-to-image-v81$/i, payload: { hd: false } },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:text-to-video|image-to-video|multimodal-video)$/i,\n payload: {\n resolution: '720p',\n duration: '5',\n generateAudio: true,\n ratio: 'adaptive',\n realPersonMode: true,\n conversionSlots: ['all'],\n returnLastFrame: false,\n seed: -1,\n },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/text-to-video$/i,\n payload: { webSearch: false },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:image-to-video|multimodal-video)$/i,\n payload: { realPersonMode: true, conversionSlots: ['all'] },\n },\n // Veo 3.1 系列(Google DeepMind 旗舰视频,原生音频 + 口型同步)\n {\n pattern: /^rhart-video-v3\\.1(?:-[\\w-]+)?\\/(?:text-to-video|image-to-video|start-end-to-video)$/i,\n payload: { resolution: '720p', duration: '8', generateAudio: false },\n },\n {\n pattern: /^rhart-video-v3\\.1-pro-official\\/reference-to-video$/i,\n payload: { resolution: '1080p', generateAudio: false },\n },\n // SkyReels V4 Omni(天工 AI 统一多模态视频,@tag 引用机制)\n {\n pattern: /^skyreels-v4\\/omni-reference(?:-(?:fast|std))?$/i,\n payload: { resolution: '1080p', duration: 5, promptOptimizer: true },\n },\n // 全能视频S(低价渠道版,duration '10'/'15',aspectRatio 16:9/9:16)\n {\n pattern: /^rhart-video-s\\/text-to-video$/i,\n payload: { duration: '10' },\n },\n];\n\nfunction runningHubStandardModelDefaults(modelEndpoint: string) {\n return RUNNINGHUB_STANDARD_MODEL_DEFAULTS.reduce<Record<string, unknown>>((payload, preset) => {\n if (preset.pattern.test(modelEndpoint)) Object.assign(payload, preset.payload);\n return payload;\n }, {});\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31Model(modelEndpoint: string) {\n return /rhart-video-v3\\.1/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LowPriceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-(?:fast|pro)\\//i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31ReferenceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1[\\w.-]*\\/reference-to-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LiteModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-lite/i.test(modelEndpoint);\n}\n\nfunction isRunningHubOmniModel(modelEndpoint: string) {\n return /skyreels-v4\\/omni-reference/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoSModel(modelEndpoint: string) {\n return /rhart-video-s\\//i.test(modelEndpoint);\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video|reference-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (isRunningHubOmniModel(modelEndpoint) && references.length > 0) {\n throw new Error('SkyReels V4 Omni 参考输入(refImages/refVideos)暂未支持,请去掉参考图后使用文生视频。');\n }\n\n // Veo 3.1 Fast official image-to-video:imageUrl + optional lastImageUrl。\n if (/rhart-video-v3\\.1-fast-official\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n if (last?.href) output.lastImageUrl = assertRunningHubPublicUrl(last.href, 'lastImageUrl');\n return output;\n }\n\n // Veo 3.1 Pro / Lite official image-to-video:imageUrl 单数;Fast 低价图生视频仍用 imageUrls 数组。\n if (/rhart-video-v3\\.1-(?:pro(?:-official)?|lite-official)\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n return output;\n }\n\n // Veo 3.1 start-end-to-video:firstFrameUrl + lastFrameUrl\n if (/rhart-video-v3\\.1[\\w.-]*\\/start-end-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // youchuan/text-to-image-v81 垫图用 imageUrl 单数(非 imageUrls 数组)\n if (/youchuan\\/text-to-image/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // Veo 3.1 Fast 低价图生视频 + 各 reference-to-video:imageUrls 数组 max 3 (schema api-448183087/459865180)\n if (/rhart-video-v3\\.1[\\w.-]*\\/(?:image-to-video|reference-to-video)/i.test(modelEndpoint)) {\n if (urls.length > 3) console.warn(`[RunningHub] ${modelEndpoint} 仅支持最多 3 张参考图,已截断 ${urls.length} → 3`);\n output.imageUrls = uniqueLimitedUrls(urls, 3);\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = uniqueLimitedUrls(imageUrls, 9);\n if (videoUrls.length > 0) output.videoUrls = uniqueLimitedUrls(videoUrls, 3);\n if (audioUrls.length > 0) output.audioUrls = uniqueLimitedUrls(audioUrls, 3);\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...(!disableDefaults && !nodeFieldModel ? runningHubStandardModelDefaults(modelEndpoint) : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n const isVeo31Ref = isRunningHubVeo31ReferenceModel(modelEndpoint);\n const isVeo31 = isRunningHubVeo31Model(modelEndpoint);\n const isVeo31LowPrice = isRunningHubVeo31LowPriceModel(modelEndpoint);\n const isProOfficialRef = /rhart-video-v3\\.1-pro-official\\/reference-to-video/i.test(modelEndpoint);\n if (options?.aspectRatio && !isProOfficialRef) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (isRunningHubVideoEndpoint(modelEndpoint)) {\n payload.resolution = options?.resolution || payload.resolution || '720p';\n if (isVeo31Ref) {\n // Veo 3.1 reference-to-video:无 duration;fast-official 保留 aspectRatio (16:9/9:16),pro-official 无 aspectRatio 字段\n delete payload.duration;\n if (isProOfficialRef) {\n delete payload.aspectRatio;\n } else if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') {\n payload.aspectRatio = '16:9';\n }\n } else if (isVeo31) {\n // Veo 3.1:duration 必须是 '4'/'6'/'8' 字符串\n const isStartEnd = /start-end-to-video/i.test(modelEndpoint);\n const isLiteOfficial = isRunningHubVeo31LiteModel(modelEndpoint);\n if (isVeo31LowPrice) {\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n delete payload.generateAudio;\n payload.duration = '8';\n } else if (isStartEnd) {\n // Veo 3.1 start-end-to-video: schema 只有 duration='8' (lite-official 无 duration 无 generateAudio 字段)\n if (isLiteOfficial) {\n delete payload.duration;\n delete payload.generateAudio;\n } else {\n payload.duration = '8';\n }\n } else {\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 8;\n const snapped = [4, 6, 8].reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, 4);\n payload.duration = String(snapped);\n }\n } else if (isRunningHubOmniModel(modelEndpoint)) {\n // Omni:duration 是 3-15 数字\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 5;\n payload.duration = Math.max(3, Math.min(15, Math.round(requested)));\n } else if (isRunningHubVideoSModel(modelEndpoint)) {\n // 全能视频S:duration 是 '10'/'15' 字符串,aspectRatio 只支持 '9:16'/'16:9'\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 10;\n payload.duration = requested >= 15 ? '15' : '10';\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n } else {\n payload.duration = String((options?.durationSec && options.durationSec > 0) ? options.durationSec : (payload.duration || 5));\n }\n } else {\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n }\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal);\n }\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const multimodalSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = hasExplicitSlots\n ? [\n ...references,\n ...multimodalSlots\n .filter(slot => slot.kind === 'image')\n .map(slot => ({ href: slot.href, mimeType: slot.mimeType, slotRole: String(slot.role || 'unassigned') })),\n ]\n : references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, { signal: input.signal });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "b1fc34bcb4ba1ebc4e0b86638879fd00c30a4d18": {"t": 1785773470.050435, "n": 141872, "h": "a1a396f571d40f10", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地画布元素。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nconst RUNNINGHUB_STANDARD_MODEL_DEFAULTS: Array<{ pattern: RegExp; payload: Record<string, unknown> }> = [\n { pattern: /^rhart-image-(?:g-2|n-g31-flash)\\/(?:text-to-image|image-to-image)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-n-pro(?:-official)?\\/(?:text-to-image|edit(?:-ultra)?)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-v1(?:-official)?\\/(?:text-to-image|edit)$/i, payload: { resolution: '1k' } },\n { pattern: /^youchuan\\/text-to-image-v81$/i, payload: { hd: false } },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:text-to-video|image-to-video|multimodal-video)$/i,\n payload: {\n resolution: '720p',\n duration: '5',\n generateAudio: true,\n ratio: 'adaptive',\n realPersonMode: true,\n conversionSlots: ['all'],\n returnLastFrame: false,\n seed: -1,\n },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/text-to-video$/i,\n payload: { webSearch: false },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:image-to-video|multimodal-video)$/i,\n payload: { realPersonMode: true, conversionSlots: ['all'] },\n },\n // Veo 3.1 系列(Google DeepMind 旗舰视频,原生音频 + 口型同步)\n {\n pattern: /^rhart-video-v3\\.1(?:-[\\w-]+)?\\/(?:text-to-video|image-to-video|start-end-to-video)$/i,\n payload: { resolution: '720p', duration: '8', generateAudio: false },\n },\n {\n pattern: /^rhart-video-v3\\.1-pro-official\\/reference-to-video$/i,\n payload: { resolution: '1080p', generateAudio: false },\n },\n // SkyReels V4 Omni(天工 AI 统一多模态视频,@tag 引用机制)\n {\n pattern: /^skyreels-v4\\/omni-reference(?:-(?:fast|std))?$/i,\n payload: { resolution: '1080p', duration: 5, promptOptimizer: true },\n },\n // 全能视频S(低价渠道版,duration '10'/'15',aspectRatio 16:9/9:16)\n {\n pattern: /^rhart-video-s\\/text-to-video$/i,\n payload: { duration: '10' },\n },\n];\n\nfunction runningHubStandardModelDefaults(modelEndpoint: string) {\n return RUNNINGHUB_STANDARD_MODEL_DEFAULTS.reduce<Record<string, unknown>>((payload, preset) => {\n if (preset.pattern.test(modelEndpoint)) Object.assign(payload, preset.payload);\n return payload;\n }, {});\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31Model(modelEndpoint: string) {\n return /rhart-video-v3\\.1/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LowPriceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-(?:fast|pro)\\//i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31ReferenceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1[\\w.-]*\\/reference-to-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LiteModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-lite/i.test(modelEndpoint);\n}\n\nfunction isRunningHubOmniModel(modelEndpoint: string) {\n return /skyreels-v4\\/omni-reference/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoSModel(modelEndpoint: string) {\n return /rhart-video-s\\//i.test(modelEndpoint);\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video|reference-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (isRunningHubOmniModel(modelEndpoint) && references.length > 0) {\n throw new Error('SkyReels V4 Omni 参考输入(refImages/refVideos)暂未支持,请去掉参考图后使用文生视频。');\n }\n\n // Veo 3.1 Fast official image-to-video:imageUrl + optional lastImageUrl。\n if (/rhart-video-v3\\.1-fast-official\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n if (last?.href) output.lastImageUrl = assertRunningHubPublicUrl(last.href, 'lastImageUrl');\n return output;\n }\n\n // Veo 3.1 Pro image-to-video:imageUrl 单数;Fast 低价图生视频仍用 imageUrls 数组。\n if (/rhart-video-v3\\.1-pro(?:-official)?\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n return output;\n }\n\n // Veo 3.1 start-end-to-video:firstFrameUrl + lastFrameUrl\n if (/rhart-video-v3\\.1[\\w.-]*\\/start-end-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // youchuan/text-to-image-v81 垫图用 imageUrl 单数(非 imageUrls 数组)\n if (/youchuan\\/text-to-image/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // Veo 3.1 Fast 低价图生视频 + 各 reference-to-video:imageUrls 数组 max 3 (schema api-448183087/459865180)\n if (/rhart-video-v3\\.1[\\w.-]*\\/(?:image-to-video|reference-to-video)/i.test(modelEndpoint)) {\n if (urls.length > 3) console.warn(`[RunningHub] ${modelEndpoint} 仅支持最多 3 张参考图,已截断 ${urls.length} → 3`);\n output.imageUrls = uniqueLimitedUrls(urls, 3);\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = uniqueLimitedUrls(imageUrls, 9);\n if (videoUrls.length > 0) output.videoUrls = uniqueLimitedUrls(videoUrls, 3);\n if (audioUrls.length > 0) output.audioUrls = uniqueLimitedUrls(audioUrls, 3);\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...(!disableDefaults && !nodeFieldModel ? runningHubStandardModelDefaults(modelEndpoint) : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n const isVeo31Ref = isRunningHubVeo31ReferenceModel(modelEndpoint);\n const isVeo31 = isRunningHubVeo31Model(modelEndpoint);\n const isVeo31LowPrice = isRunningHubVeo31LowPriceModel(modelEndpoint);\n const isProOfficialRef = /rhart-video-v3\\.1-pro-official\\/reference-to-video/i.test(modelEndpoint);\n if (options?.aspectRatio && !isProOfficialRef) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (isRunningHubVideoEndpoint(modelEndpoint)) {\n payload.resolution = options?.resolution || payload.resolution || '720p';\n if (isVeo31Ref) {\n // Veo 3.1 reference-to-video:无 duration;fast-official 保留 aspectRatio (16:9/9:16),pro-official 无 aspectRatio 字段\n delete payload.duration;\n if (isProOfficialRef) {\n delete payload.aspectRatio;\n } else if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') {\n payload.aspectRatio = '16:9';\n }\n } else if (isVeo31) {\n // Veo 3.1:duration 必须是 '4'/'6'/'8' 字符串\n const isStartEnd = /start-end-to-video/i.test(modelEndpoint);\n const isLiteOfficial = isRunningHubVeo31LiteModel(modelEndpoint);\n if (isVeo31LowPrice) {\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n delete payload.generateAudio;\n payload.duration = '8';\n } else if (isStartEnd) {\n // Veo 3.1 start-end-to-video: schema 只有 duration='8' (lite-official 无 duration 无 generateAudio 字段)\n if (isLiteOfficial) {\n delete payload.duration;\n delete payload.generateAudio;\n } else {\n payload.duration = '8';\n }\n } else {\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 8;\n const snapped = [4, 6, 8].reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, 4);\n payload.duration = String(snapped);\n }\n } else if (isRunningHubOmniModel(modelEndpoint)) {\n // Omni:duration 是 3-15 数字\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 5;\n payload.duration = Math.max(3, Math.min(15, Math.round(requested)));\n } else if (isRunningHubVideoSModel(modelEndpoint)) {\n // 全能视频S:duration 是 '10'/'15' 字符串,aspectRatio 只支持 '9:16'/'16:9'\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 10;\n payload.duration = requested >= 15 ? '15' : '10';\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n } else {\n payload.duration = String((options?.durationSec && options.durationSec > 0) ? options.durationSec : (payload.duration || 5));\n }\n } else {\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n }\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal);\n }\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const multimodalSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = hasExplicitSlots\n ? [\n ...references,\n ...multimodalSlots\n .filter(slot => slot.kind === 'image')\n .map(slot => ({ href: slot.href, mimeType: slot.mimeType, slotRole: String(slot.role || 'unassigned') })),\n ]\n : references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, { signal: input.signal });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "74b27efc7015d4d1f5191b3a188ea885de2f1d45": {"t": 1785773475.3978705, "n": 140211, "h": "3152834cd41ca179", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [5, 10],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地画布元素。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nconst RUNNINGHUB_STANDARD_MODEL_DEFAULTS: Array<{ pattern: RegExp; payload: Record<string, unknown> }> = [\n { pattern: /^rhart-image-(?:g-2|n-g31-flash)\\/(?:text-to-image|image-to-image)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-n-pro(?:-official)?\\/(?:text-to-image|edit(?:-ultra)?)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-v1(?:-official)?\\/(?:text-to-image|edit)$/i, payload: { resolution: '1k' } },\n { pattern: /^youchuan\\/text-to-image-v81$/i, payload: { hd: false } },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:text-to-video|image-to-video|multimodal-video)$/i,\n payload: {\n resolution: '720p',\n duration: '5',\n generateAudio: true,\n ratio: 'adaptive',\n realPersonMode: true,\n conversionSlots: ['all'],\n returnLastFrame: false,\n seed: -1,\n },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/text-to-video$/i,\n payload: { webSearch: false },\n },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0(?:-fast)?\\/(?:image-to-video|multimodal-video)$/i,\n payload: { realPersonMode: true, conversionSlots: ['all'] },\n },\n // Veo 3.1 系列(Google DeepMind 旗舰视频,原生音频 + 口型同步)\n {\n pattern: /^rhart-video-v3\\.1(?:-[\\w-]+)?\\/(?:text-to-video|image-to-video|start-end-to-video)$/i,\n payload: { resolution: '720p', duration: '8', generateAudio: false },\n },\n {\n pattern: /^rhart-video-v3\\.1-pro-official\\/reference-to-video$/i,\n payload: { resolution: '1080p', generateAudio: false },\n },\n // SkyReels V4 Omni(天工 AI 统一多模态视频,@tag 引用机制)\n {\n pattern: /^skyreels-v4\\/omni-reference(?:-(?:fast|std))?$/i,\n payload: { resolution: '1080p', duration: 5, promptOptimizer: true },\n },\n // 全能视频S(低价渠道版,duration '10'/'15',aspectRatio 16:9/9:16)\n {\n pattern: /^rhart-video-s\\/text-to-video$/i,\n payload: { duration: '10' },\n },\n];\n\nfunction runningHubStandardModelDefaults(modelEndpoint: string) {\n return RUNNINGHUB_STANDARD_MODEL_DEFAULTS.reduce<Record<string, unknown>>((payload, preset) => {\n if (preset.pattern.test(modelEndpoint)) Object.assign(payload, preset.payload);\n return payload;\n }, {});\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31Model(modelEndpoint: string) {\n return /rhart-video-v3\\.1/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LowPriceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-(?:fast|pro)\\//i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31ReferenceModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1[\\w.-]*\\/reference-to-video/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVeo31LiteModel(modelEndpoint: string) {\n return /rhart-video-v3\\.1-lite/i.test(modelEndpoint);\n}\n\nfunction isRunningHubOmniModel(modelEndpoint: string) {\n return /skyreels-v4\\/omni-reference/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoSModel(modelEndpoint: string) {\n return /rhart-video-s\\//i.test(modelEndpoint);\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video|reference-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (isRunningHubOmniModel(modelEndpoint) && references.length > 0) {\n throw new Error('SkyReels V4 Omni 参考输入(refImages/refVideos)暂未支持,请去掉参考图后使用文生视频。');\n }\n\n // Veo 3.1 Fast official image-to-video:imageUrl + optional lastImageUrl。\n if (/rhart-video-v3\\.1-fast-official\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n if (last?.href) output.lastImageUrl = assertRunningHubPublicUrl(last.href, 'lastImageUrl');\n return output;\n }\n\n // Veo 3.1 Pro image-to-video:imageUrl 单数;Fast 低价图生视频仍用 imageUrls 数组。\n if (/rhart-video-v3\\.1-pro(?:-official)?\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.imageUrl = assertRunningHubPublicUrl(first.href, 'imageUrl');\n return output;\n }\n\n // Veo 3.1 start-end-to-video:firstFrameUrl + lastFrameUrl\n if (/rhart-video-v3\\.1[\\w.-]*\\/start-end-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // youchuan/text-to-image-v81 垫图用 imageUrl 单数(非 imageUrls 数组)\n if (/youchuan\\/text-to-image/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n // Veo 3.1 Fast 低价图生视频 + 各 reference-to-video:imageUrls 数组 max 3 (schema api-448183087/459865180)\n if (/rhart-video-v3\\.1[\\w.-]*\\/(?:image-to-video|reference-to-video)/i.test(modelEndpoint)) {\n if (urls.length > 3) console.warn(`[RunningHub] ${modelEndpoint} 仅支持最多 3 张参考图,已截断 ${urls.length} → 3`);\n output.imageUrls = uniqueLimitedUrls(urls, 3);\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0(?:-[\\w-]+)?\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = uniqueLimitedUrls(imageUrls, 9);\n if (videoUrls.length > 0) output.videoUrls = uniqueLimitedUrls(videoUrls, 3);\n if (audioUrls.length > 0) output.audioUrls = uniqueLimitedUrls(audioUrls, 3);\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...(!disableDefaults && !nodeFieldModel ? runningHubStandardModelDefaults(modelEndpoint) : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n const isVeo31Ref = isRunningHubVeo31ReferenceModel(modelEndpoint);\n const isVeo31 = isRunningHubVeo31Model(modelEndpoint);\n const isVeo31LowPrice = isRunningHubVeo31LowPriceModel(modelEndpoint);\n const isProOfficialRef = /rhart-video-v3\\.1-pro-official\\/reference-to-video/i.test(modelEndpoint);\n if (options?.aspectRatio && !isProOfficialRef) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (isRunningHubVideoEndpoint(modelEndpoint)) {\n payload.resolution = options?.resolution || payload.resolution || '720p';\n if (isVeo31Ref) {\n // Veo 3.1 reference-to-video:无 duration;fast-official 保留 aspectRatio (16:9/9:16),pro-official 无 aspectRatio 字段\n delete payload.duration;\n if (isProOfficialRef) {\n delete payload.aspectRatio;\n } else if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') {\n payload.aspectRatio = '16:9';\n }\n } else if (isVeo31) {\n // Veo 3.1:duration 必须是 '4'/'6'/'8' 字符串\n const isStartEnd = /start-end-to-video/i.test(modelEndpoint);\n const isLiteOfficial = isRunningHubVeo31LiteModel(modelEndpoint);\n if (isVeo31LowPrice) {\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n delete payload.generateAudio;\n payload.duration = '8';\n } else if (isStartEnd) {\n // Veo 3.1 start-end-to-video: schema 只有 duration='8' (lite-official 无 duration 无 generateAudio 字段)\n if (isLiteOfficial) {\n delete payload.duration;\n delete payload.generateAudio;\n } else {\n payload.duration = '8';\n }\n } else {\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 8;\n const snapped = [4, 6, 8].reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, 4);\n payload.duration = String(snapped);\n }\n } else if (isRunningHubOmniModel(modelEndpoint)) {\n // Omni:duration 是 3-15 数字\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 5;\n payload.duration = Math.max(3, Math.min(15, Math.round(requested)));\n } else if (isRunningHubVideoSModel(modelEndpoint)) {\n // 全能视频S:duration 是 '10'/'15' 字符串,aspectRatio 只支持 '9:16'/'16:9'\n const requested = (options?.durationSec && options.durationSec > 0) ? options.durationSec : Number(payload.duration) || 10;\n payload.duration = requested >= 15 ? '15' : '10';\n if (payload.aspectRatio !== '9:16' && payload.aspectRatio !== '16:9') payload.aspectRatio = '16:9';\n } else {\n payload.duration = String((options?.durationSec && options.durationSec > 0) ? options.durationSec : (payload.duration || 5));\n }\n } else {\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n }\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const media = result.results?.find(item => {\n if (!item.url) return false;\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return media?.url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal);\n }\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const multimodalSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = hasExplicitSlots\n ? [\n ...references,\n ...multimodalSlots\n .filter(slot => slot.kind === 'image')\n .map(slot => ({ href: slot.href, mimeType: slot.mimeType, slotRole: String(slot.role || 'unassigned') })),\n ]\n : references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, { signal: input.signal });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "b7d4ab01eab72ed5ceba715ac936d5ed63cbb9c9": {"t": 1785773477.1718338, "n": 132892, "h": "e71ad6105d42ef5d", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [5, 10],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地画布元素。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nconst RUNNINGHUB_STANDARD_MODEL_DEFAULTS: Array<{ pattern: RegExp; payload: Record<string, unknown> }> = [\n { pattern: /^rhart-image-(?:g-2|n-g31-flash)\\/(?:text-to-image|image-to-image)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-n-pro(?:-official)?\\/(?:text-to-image|edit(?:-ultra)?)$/i, payload: { resolution: '1k' } },\n { pattern: /^rhart-image-v1(?:-official)?\\/(?:text-to-image|edit)$/i, payload: { resolution: '1k' } },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0\\/multimodal-video$/i,\n payload: {\n resolution: '720p',\n duration: '5',\n generateAudio: false,\n realPersonMode: true,\n conversionSlots: ['all'],\n returnLastFrame: false,\n seed: -1,\n },\n },\n];\n\nfunction runningHubStandardModelDefaults(modelEndpoint: string) {\n return RUNNINGHUB_STANDARD_MODEL_DEFAULTS.reduce<Record<string, unknown>>((payload, preset) => {\n if (preset.pattern.test(modelEndpoint)) Object.assign(payload, preset.payload);\n return payload;\n }, {});\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video\\/|image-to-video|start-end-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = Array.from(new Set(imageUrls));\n if (videoUrls.length > 0) output.videoUrls = Array.from(new Set(videoUrls));\n if (audioUrls.length > 0) output.audioUrls = Array.from(new Set(audioUrls));\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...(!disableDefaults && !nodeFieldModel ? runningHubStandardModelDefaults(modelEndpoint) : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n if (options?.aspectRatio) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (isRunningHubVideoEndpoint(modelEndpoint)) {\n payload.resolution = options?.resolution || payload.resolution || '720p';\n payload.duration = String(options?.durationSec || payload.duration || 5);\n } else {\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n }\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const media = result.results?.find(item => {\n if (!item.url) return false;\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return media?.url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal);\n }\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) throw new Error(`视频下载失败: ${response.statusText}`);\n return {\n videoBlob: await response.blob(),\n mimeType: response.headers.get('Content-Type') || 'video/mp4',\n };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const multimodalSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = hasExplicitSlots\n ? [\n ...references,\n ...multimodalSlots\n .filter(slot => slot.kind === 'image')\n .map(slot => ({ href: slot.href, mimeType: slot.mimeType, slotRole: String(slot.role || 'unassigned') })),\n ]\n : references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, { signal: input.signal });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "79ef6e73c6acb9bd0e8861345bb26034361d6b05": {"t": 1785773481.341615, "n": 132420, "h": "45a4e164a3a9dd06", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [5, 10],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地画布元素。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nconst RUNNINGHUB_STANDARD_MODEL_DEFAULTS: Array<{ pattern: RegExp; payload: Record<string, unknown> }> = [\n { pattern: /^rhart-image-(?:g-2|n-g31-flash)\\/(?:text-to-image|image-to-image)$/i, payload: { resolution: '1k' } },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0\\/multimodal-video$/i,\n payload: {\n resolution: '720p',\n duration: '5',\n generateAudio: false,\n realPersonMode: true,\n conversionSlots: ['all'],\n returnLastFrame: false,\n seed: -1,\n },\n },\n];\n\nfunction runningHubStandardModelDefaults(modelEndpoint: string) {\n return RUNNINGHUB_STANDARD_MODEL_DEFAULTS.reduce<Record<string, unknown>>((payload, preset) => {\n if (preset.pattern.test(modelEndpoint)) Object.assign(payload, preset.payload);\n return payload;\n }, {});\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video\\/|image-to-video|start-end-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = Array.from(new Set(imageUrls));\n if (videoUrls.length > 0) output.videoUrls = Array.from(new Set(videoUrls));\n if (audioUrls.length > 0) output.audioUrls = Array.from(new Set(audioUrls));\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) return [];\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...(!disableDefaults && !nodeFieldModel ? runningHubStandardModelDefaults(modelEndpoint) : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n if (options?.aspectRatio) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (isRunningHubVideoEndpoint(modelEndpoint)) {\n payload.resolution = options?.resolution || payload.resolution || '720p';\n payload.duration = String(options?.durationSec || payload.duration || 5);\n } else {\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n }\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const media = result.results?.find(item => {\n if (!item.url) return false;\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return media?.url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal);\n }\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) throw new Error(`视频下载失败: ${response.statusText}`);\n return {\n videoBlob: await response.blob(),\n mimeType: response.headers.get('Content-Type') || 'video/mp4',\n };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const multimodalSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = hasExplicitSlots\n ? [\n ...references,\n ...multimodalSlots\n .filter(slot => slot.kind === 'image')\n .map(slot => ({ href: slot.href, mimeType: slot.mimeType, slotRole: String(slot.role || 'unassigned') })),\n ]\n : references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, { signal: input.signal });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "4587b562997d0ad4a7b69d46cc6bc1c52c76f610": {"t": 1785773483.7249663, "n": 130647, "h": "04b5c202cacf77a1", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [5, 10],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地画布元素。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nconst RUNNINGHUB_STANDARD_MODEL_DEFAULTS: Array<{ pattern: RegExp; payload: Record<string, unknown> }> = [\n { pattern: /^rhart-image-(?:g-2|n-g31-flash)\\/(?:text-to-image|image-to-image)$/i, payload: { resolution: '1k' } },\n {\n pattern: /^rhart-video\\/sparkvideo-2\\.0\\/multimodal-video$/i,\n payload: {\n resolution: '720p',\n duration: '5',\n generateAudio: false,\n realPersonMode: true,\n conversionSlots: ['all'],\n returnLastFrame: false,\n seed: -1,\n },\n },\n];\n\nfunction runningHubStandardModelDefaults(modelEndpoint: string) {\n return RUNNINGHUB_STANDARD_MODEL_DEFAULTS.reduce<Record<string, unknown>>((payload, preset) => {\n if (preset.pattern.test(modelEndpoint)) Object.assign(payload, preset.payload);\n return payload;\n }, {});\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video\\/|image-to-video|start-end-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = Array.from(new Set(imageUrls));\n if (videoUrls.length > 0) output.videoUrls = Array.from(new Set(videoUrls));\n if (audioUrls.length > 0) output.audioUrls = Array.from(new Set(audioUrls));\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) return [];\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...(!disableDefaults && !nodeFieldModel ? runningHubStandardModelDefaults(modelEndpoint) : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n if (options?.aspectRatio) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (isRunningHubVideoEndpoint(modelEndpoint)) {\n payload.resolution = options?.resolution || payload.resolution || '720p';\n payload.duration = String(options?.durationSec || payload.duration || 5);\n } else {\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n }\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const media = result.results?.find(item => {\n if (!item.url) return false;\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return media?.url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal);\n }\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<SeedanceVideoTaskHandle> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n): Promise<SeedanceVideoPollResult> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string): Promise<{ videoBlob: Blob; mimeType: string }> {\n const response = await fetch(videoUrl);\n if (!response.ok) throw new Error(`视频下载失败: ${response.statusText}`);\n return {\n videoBlob: await response.blob(),\n mimeType: response.headers.get('Content-Type') || 'video/mp4',\n };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const multimodalSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = hasExplicitSlots\n ? [\n ...references,\n ...multimodalSlots\n .filter(slot => slot.kind === 'image')\n .map(slot => ({ href: slot.href, mimeType: slot.mimeType, slotRole: String(slot.role || 'unassigned') })),\n ]\n : references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl);\n }\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const result = await pollSeedanceVideoTask(handle, key);\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl);\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, { signal: input.signal });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "79b2d2ddf7354a08c1bedffab5050561aad3ef03": {"t": 1785773486.1555634, "n": 129640, "h": "38fe495b9fe27ac0", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [5, 10],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地画布元素。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video\\/|image-to-video|start-end-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = Array.from(new Set(imageUrls));\n if (videoUrls.length > 0) output.videoUrls = Array.from(new Set(videoUrls));\n if (audioUrls.length > 0) output.audioUrls = Array.from(new Set(audioUrls));\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) return [];\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n if (options?.aspectRatio) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (isRunningHubVideoEndpoint(modelEndpoint)) {\n payload.resolution = options?.resolution || '720p';\n payload.duration = String(options?.durationSec || 5);\n } else {\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n }\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const media = result.results?.find(item => {\n if (!item.url) return false;\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return media?.url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal);\n }\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<SeedanceVideoTaskHandle> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n): Promise<SeedanceVideoPollResult> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string): Promise<{ videoBlob: Blob; mimeType: string }> {\n const response = await fetch(videoUrl);\n if (!response.ok) throw new Error(`视频下载失败: ${response.statusText}`);\n return {\n videoBlob: await response.blob(),\n mimeType: response.headers.get('Content-Type') || 'video/mp4',\n };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const multimodalSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = hasExplicitSlots\n ? [\n ...references,\n ...multimodalSlots\n .filter(slot => slot.kind === 'image')\n .map(slot => ({ href: slot.href, mimeType: slot.mimeType, slotRole: String(slot.role || 'unassigned') })),\n ]\n : references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl);\n }\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const result = await pollSeedanceVideoTask(handle, key);\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl);\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, { signal: input.signal });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "5d2fc1832698f2acf1f70ddf2b4829529b7e3286": {"t": 1785773488.1436133, "n": 128992, "h": "028cdd59c2c5d89b", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [5, 10],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction isProviderFetchableMediaUrl(value: string): boolean {\n return /^https?:\\/\\//i.test(value) || value.startsWith('asset://');\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n\n for (const slot of slots) {\n if ((slot.kind === 'video' || slot.kind === 'audio') && !isProviderFetchableMediaUrl(slot.href)) {\n const label = slot.kind === 'video' ? '参考视频' : '参考音频';\n throw new Error(`Seedance ${label}必须使用公网 URL 或 asset:// 素材 ID;本地 blob/data URL 需要先上传成可被火山访问的地址。`);\n }\n }\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nconst DEFAULT_RUNNINGHUB_PROMPT_FIELD = '12##text';\nconst DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD: Record<string, unknown> = {\n '42##select': '1',\n '43##value': 1024,\n '30##value': 1024,\n '44##file_type': 'PNG',\n};\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubNodeFieldModel(modelEndpoint: string) {\n return /^(?:rhart-image\\/)?f(?:-|_|$)|^f-/i.test(modelEndpoint);\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction runningHubAspectRatioField(modelEndpoint: string) {\n return isRunningHubSeedance20Model(modelEndpoint) ? 'ratio' : 'aspectRatio';\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction runningHubReferenceFields(modelEndpoint: string, references: VideoImage[] = []) {\n if (/image-to-image|image-to-video|start-end-to-video/i.test(modelEndpoint) && references.length === 0) {\n throw new Error(`RunningHub ${modelEndpoint} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n const urls = references.map((ref, index) => assertRunningHubPublicUrl(ref.href, `imageUrls[${index}]`));\n const output: Record<string, unknown> = {};\n\n if (/start-end-to-video/i.test(modelEndpoint) || /(?:sparkvideo|seedance)-2\\.0\\/image-to-video/i.test(modelEndpoint)) {\n if (first?.href) output.firstFrameUrl = assertRunningHubPublicUrl(first.href, 'firstFrameUrl');\n if (last?.href) output.lastFrameUrl = assertRunningHubPublicUrl(last.href, 'lastFrameUrl');\n return output;\n }\n\n if (/kling|hailuo/i.test(modelEndpoint) && urls[0]) {\n output.imageUrl = urls[0];\n return output;\n }\n\n if (urls.length > 0) {\n output.imageUrls = urls;\n }\n return output;\n}\n\nfunction runningHubMultimodalFields(\n modelEndpoint: string,\n references: VideoImage[] = [],\n slots: MultimodalSlot[] = [],\n) {\n if (!/(?:sparkvideo|seedance)-2\\.0\\/multimodal-video/i.test(modelEndpoint)) {\n return runningHubReferenceFields(modelEndpoint, references);\n }\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, index) => assertRunningHubPublicUrl(href, `imageUrls[${index}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, index) => assertRunningHubPublicUrl(slot.href, `videoUrls[${index}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, index) => assertRunningHubPublicUrl(slot.href, `audioUrls[${index}]`));\n const output: Record<string, unknown> = {};\n if (imageUrls.length > 0) output.imageUrls = Array.from(new Set(imageUrls));\n if (videoUrls.length > 0) output.videoUrls = Array.from(new Set(videoUrls));\n if (audioUrls.length > 0) output.audioUrls = Array.from(new Set(audioUrls));\n return output;\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) return [];\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const nodeFieldModel = isRunningHubNodeFieldModel(modelEndpoint);\n const promptField = extra.runningHubPromptField || (nodeFieldModel ? DEFAULT_RUNNINGHUB_PROMPT_FIELD : 'prompt');\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const payload: Record<string, unknown> = {\n ...(!disableDefaults && nodeFieldModel ? DEFAULT_RUNNINGHUB_IMAGE_PAYLOAD : {}),\n ...configuredPayload,\n [promptField]: prompt,\n };\n if (!nodeFieldModel) {\n if (options?.aspectRatio) payload[runningHubAspectRatioField(modelEndpoint)] = options.aspectRatio;\n if (options?.durationSec) payload.duration = String(options.durationSec);\n if (options?.resolution) payload.resolution = options.resolution;\n Object.assign(payload, runningHubMultimodalFields(modelEndpoint, options?.references, options?.slots));\n }\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n return payload;\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const media = result.results?.find(item => {\n if (!item.url) return false;\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return media?.url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal);\n }\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n }), {\n baseUrl,\n signal: options?.signal,\n });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<SeedanceVideoTaskHandle> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, slots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n): Promise<SeedanceVideoPollResult> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string): Promise<{ videoBlob: Blob; mimeType: string }> {\n const response = await fetch(videoUrl);\n if (!response.ok) throw new Error(`视频下载失败: ${response.statusText}`);\n return {\n videoBlob: await response.blob(),\n mimeType: response.headers.get('Content-Type') || 'video/mp4',\n };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n signal?: AbortSignal;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const multimodalSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const allImageRefs = hasExplicitSlots\n ? [\n ...references,\n ...multimodalSlots\n .filter(slot => slot.kind === 'image')\n .map(slot => ({ href: slot.href, mimeType: slot.mimeType, slotRole: String(slot.role || 'unassigned') })),\n ]\n : references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl);\n }\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const result = await pollSeedanceVideoTask(handle, key);\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl);\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, { signal: input.signal });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "d27b064b303d881357dc429f34ba730ba132a4a3": {"t": 1785773489.8084857, "n": 115720, "h": "2881bfcc2ed4c24c", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [5, 10],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction isProviderFetchableMediaUrl(value: string): boolean {\n return /^https?:\\/\\//i.test(value) || value.startsWith('asset://');\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n\n for (const slot of slots) {\n if ((slot.kind === 'video' || slot.kind === 'audio') && !isProviderFetchableMediaUrl(slot.href)) {\n const label = slot.kind === 'video' ? '参考视频' : '参考音频';\n throw new Error(`Seedance ${label}必须使用公网 URL 或 asset:// 素材 ID;本地 blob/data URL 需要先上传成可被火山访问的地址。`);\n }\n }\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n return editImage(refs, prompt, undefined, key?.key, options?.signal);\n }\n return generateImageFromText(prompt, key?.key, options?.signal);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<SeedanceVideoTaskHandle> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, slots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n): Promise<SeedanceVideoPollResult> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string): Promise<{ videoBlob: Blob; mimeType: string }> {\n const response = await fetch(videoUrl);\n if (!response.ok) throw new Error(`视频下载失败: ${response.statusText}`);\n return {\n videoBlob: await response.blob(),\n mimeType: response.headers.get('Content-Type') || 'video/mp4',\n };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const multimodalSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const result = await pollSeedanceVideoTask(handle, key);\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl);\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, { signal: input.signal });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "1a96d35751a449cf2791a885c3a596d948f2615d": {"t": 1785773492.0388985, "n": 114800, "h": "90e3095f21f7511c", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [5, 10],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction isProviderFetchableMediaUrl(value: string): boolean {\n return /^https?:\\/\\//i.test(value) || value.startsWith('asset://');\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n\n for (const slot of slots) {\n if ((slot.kind === 'video' || slot.kind === 'audio') && !isProviderFetchableMediaUrl(slot.href)) {\n const label = slot.kind === 'video' ? '参考视频' : '参考音频';\n throw new Error(`Seedance ${label}必须使用公网 URL 或 asset:// 素材 ID;本地 blob/data URL 需要先上传成可被火山访问的地址。`);\n }\n }\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key);\n }\n return editImage(refs, prompt, undefined, key?.key);\n }\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<SeedanceVideoTaskHandle> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, slots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n): Promise<SeedanceVideoPollResult> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string): Promise<{ videoBlob: Blob; mimeType: string }> {\n const response = await fetch(videoUrl);\n if (!response.ok) throw new Error(`视频下载失败: ${response.statusText}`);\n return {\n videoBlob: await response.blob(),\n mimeType: response.headers.get('Content-Type') || 'video/mp4',\n };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const multimodalSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const result = await pollSeedanceVideoTask(handle, key);\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl);\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences);\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "0ec1b72ab9f43fce6ea19f71f05131cb15c3d686": {"t": 1785773493.6742973, "n": 114671, "h": "e9f7335955839fd1", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n generateAudio?: boolean;\n watermark?: boolean;\n references?: IgnitionReference[];\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\ntype CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction isProviderFetchableMediaUrl(value: string): boolean {\n return /^https?:\\/\\//i.test(value) || value.startsWith('asset://');\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n\n for (const slot of slots) {\n if ((slot.kind === 'video' || slot.kind === 'audio') && !isProviderFetchableMediaUrl(slot.href)) {\n const label = slot.kind === 'video' ? '参考视频' : '参考音频';\n throw new Error(`Seedance ${label}必须使用公网 URL 或 asset:// 素材 ID;本地 blob/data URL 需要先上传成可被火山访问的地址。`);\n }\n }\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName);\n const nameNote = reference.sourceName && mention && reference.sourceName !== mention.replace(/^@/, '')\n ? `(${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention || reference.sourceName || reference.elementId || '未命名引用'}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key);\n }\n return editImage(refs, prompt, undefined, key?.key);\n }\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<SeedanceVideoTaskHandle> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n validateSeedanceSlots(slots, capability);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, slots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n): Promise<SeedanceVideoPollResult> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string): Promise<{ videoBlob: Blob; mimeType: string }> {\n const response = await fetch(videoUrl);\n if (!response.ok) throw new Error(`视频下载失败: ${response.statusText}`);\n return {\n videoBlob: await response.blob(),\n mimeType: response.headers.get('Content-Type') || 'video/mp4',\n };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const multimodalSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const result = await pollSeedanceVideoTask(handle, key);\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl);\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: input.durationSec,\n resolution: input.resolution,\n generateAudio: input.generateAudio,\n watermark: input.watermark,\n references: videoRefs,\n slots: videoSlots,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences);\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "004801941f8c6e451803744bc1d14f8eeec0ca2c": {"t": 1785773495.241251, "n": 106302, "h": "c51a2b2946d1b710", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n references?: IgnitionReference[];\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2.0', 'seedance-2.0', 'dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\ntype VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\n\ntype CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: ['16:9', '9:16', '1:1'],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: ['16:9', '9:16', '1:1'], // Seedance\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n return parseModelMappings(key?.extraConfig)[model] || model;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({ href: reference.href, mimeType: reference.mimeType || 'image/png', slotRole: reference.slotRole }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n }));\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const contentItems: Array<Record<string, unknown>> = [{ type: 'text', text: prompt }];\n for (const slot of filterMultimodalSlots(slots, capability)) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key);\n }\n return editImage(refs, prompt, undefined, key?.key);\n }\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<SeedanceVideoTaskHandle> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const slots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, slots, capability),\n ...withSupportedParams(capability, {\n ratio: options?.aspectRatio || '16:9',\n duration: options?.durationSec,\n resolution: options?.resolution,\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n): Promise<SeedanceVideoPollResult> {\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string): Promise<{ videoBlob: Blob; mimeType: string }> {\n const response = await fetch(videoUrl);\n if (!response.ok) throw new Error(`视频下载失败: ${response.statusText}`);\n return {\n videoBlob: await response.blob(),\n mimeType: response.headers.get('Content-Type') || 'video/mp4',\n };\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const multimodalSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const result = await pollSeedanceVideoTask(handle, key);\n if (result.status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl);\n }\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(prompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n references: videoRefs,\n slots: videoSlots,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(prompt, input.modelId, input.apiKeyPayload, imageReferences);\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "635af5e20c99de2fcd2031ea9b6d1728581b746a": {"t": 1785773497.013221, "n": 103211, "h": "294a4808fbba39fc", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n references?: IgnitionReference[];\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['dreamina-seedance-2-0-260128', 'doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\ntype VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n\ntype CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: ['16:9', '9:16', '1:1'],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: ['16:9', '9:16', '1:1'], // Seedance\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n return parseModelMappings(key?.extraConfig)[model] || model;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({ href: reference.href, mimeType: reference.mimeType || 'image/png', slotRole: reference.slotRole }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n }));\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const contentItems: Array<Record<string, unknown>> = [{ type: 'text', text: prompt }];\n for (const slot of filterMultimodalSlots(slots, capability)) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key);\n }\n return editImage(refs, prompt, undefined, key?.key);\n }\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const multimodalSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model || 'dreamina-seedance-2-0-260128', key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n\n // Seedance via Ark API: text plus typed multimodal slots in content[].\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, multimodalSlots, capability),\n ...withSupportedParams(capability, {\n ratio: aspectRatio,\n duration: options?.durationSec,\n resolution: options?.resolution,\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回 task_id');\n\n // Poll for completion\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n let seedanceVideoUrl: string | undefined;\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n if (status === 'succeeded') {\n seedanceVideoUrl = extractVideoOutputUrl(queryJson);\n break;\n }\n }\n\n if (!seedanceVideoUrl) throw new Error('Seedance 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const seedanceVideoRes = await fetch(seedanceVideoUrl);\n if (!seedanceVideoRes.ok) throw new Error(`视频下载失败: ${seedanceVideoRes.statusText}`);\n const seedanceVideoBlob = await seedanceVideoRes.blob();\n const seedanceMimeType = seedanceVideoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob: seedanceVideoBlob, mimeType: seedanceMimeType };\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(prompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n references: videoRefs,\n slots: videoSlots,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(prompt, input.modelId, input.apiKeyPayload, imageReferences);\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "25dea83d824e38295768bd31aab44ab728f4ecee": {"t": 1785773501.7965045, "n": 98522, "h": "eba8fe1666785f45", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n references?: IgnitionReference[];\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\ntype VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n\ntype CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n defaults?: Record<string, unknown>;\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: ['16:9', '9:16', '1:1'],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance')) {\n return SEEDANCE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.role === 'first_frame' || slot.role === 'last_frame') return 'reference_image';\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: ['16:9', '9:16', '1:1'], // Seedance\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n return parseModelMappings(key.extraConfig)[model] || model;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({ href: reference.href, mimeType: reference.mimeType || 'image/png', slotRole: reference.slotRole }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n }));\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-1(?:\\.5|-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id || payload?.data?.task_id || payload?.id || payload?.data?.id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status || payload?.data?.status || payload?.data?.task_status || payload?.state;\n return typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (['failure', 'failed', 'fail', 'error', 'cancelled', 'canceled'].includes(status)) {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (['success', 'succeed', 'completed', 'done'].includes(status)) {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const contentItems: Array<Record<string, unknown>> = [{ type: 'text', text: prompt }];\n for (const slot of filterMultimodalSlots(slots, capability)) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = images ?? [];\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key);\n }\n return editImage(refs, prompt, undefined, key?.key);\n }\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n size: '1024x1024',\n response_format: preferredFormat,\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(images, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...images.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n images.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const multimodalSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(references);\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const firstFrame = references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined);\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model || 'doubao-seedance-2-0-260128', key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n\n // Seedance via Ark API: text plus typed multimodal slots in content[].\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, multimodalSlots, capability),\n ...withSupportedParams(capability, {\n ratio: aspectRatio,\n duration: options?.durationSec,\n resolution: options?.resolution,\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回 task_id');\n\n // Poll for completion\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n let seedanceVideoUrl: string | undefined;\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed' || status === 'cancelled' || status === 'expired') {\n throw new Error(`Seedance 视频生成失败: ${queryJson?.error?.message || 'Unknown error'}`);\n }\n if (status === 'succeeded') {\n seedanceVideoUrl = extractVideoOutputUrl(queryJson);\n break;\n }\n }\n\n if (!seedanceVideoUrl) throw new Error('Seedance 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const seedanceVideoRes = await fetch(seedanceVideoUrl);\n if (!seedanceVideoRes.ok) throw new Error(`视频下载失败: ${seedanceVideoRes.statusText}`);\n const seedanceVideoBlob = await seedanceVideoRes.blob();\n const seedanceMimeType = seedanceVideoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob: seedanceVideoBlob, mimeType: seedanceMimeType };\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(prompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n references: videoRefs,\n slots: videoSlots,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(prompt, input.modelId, input.apiKeyPayload, imageReferences);\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "71227c2df71d8fbca7bdab3770bc87c76438355e": {"t": 1785773507.6271112, "n": 91147, "h": "4b7ef21677c9a80d", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\ntype VideoImage = ImageInput & { slotRole?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n references?: IgnitionReference[];\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2-0-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\ntype VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: ['16:9', '9:16', '1:1'], // Seedance\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n return parseModelMappings(key.extraConfig)[model] || model;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({ href: reference.href, mimeType: reference.mimeType || 'image/png', slotRole: reference.slotRole }));\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-1(?:\\.5|-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-)/i.test(model)) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id || payload?.data?.task_id || payload?.id || payload?.data?.id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status || payload?.data?.status || payload?.data?.task_status || payload?.state;\n return typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.data?.video_url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (['failure', 'failed', 'fail', 'error', 'cancelled', 'canceled'].includes(status)) {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (['success', 'succeed', 'completed', 'done'].includes(status)) {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n const refs = images ?? [];\n\n if (provider === 'google') {\n if (refs.length > 0) {\n if (!supportsReferenceImageEditing(model)) {\n return generateImageFromText(prompt, key?.key);\n }\n return editImage(refs, prompt, undefined, key?.key);\n }\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n size: '1024x1024',\n response_format: preferredFormat,\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(images, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...images.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n images.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const references = options?.references ?? [];\n const firstFrame = references.find(r => r.slotRole === 'first_frame') || references[0];\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, firstFrame, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'volcengine') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Seedance via Ark API: all reference images sent as role=\"reference_image\"\n onProgress('Submitting video generation task...');\n const contentItems: Array<Record<string, unknown>> = [\n { type: 'text', text: prompt },\n ];\n for (const ref of references) {\n contentItems.push({\n type: 'image_url',\n image_url: { url: ref.href },\n role: 'reference_image',\n });\n }\n const createBody: Record<string, unknown> = {\n model: model || 'doubao-seedance-2-0-260128',\n content: contentItems,\n generate_audio: true,\n ratio: aspectRatio,\n duration: 5,\n watermark: false,\n };\n\n const createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = createJson?.id;\n if (!taskId) throw new Error('Seedance 视频生成未返回 task_id');\n\n // Poll for completion\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n let seedanceVideoUrl: string | undefined;\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = queryJson?.status || queryJson?.data?.status;\n\n if (status === 'failed') {\n throw new Error(`Seedance 视频生成失败: ${queryJson?.error?.message || 'Unknown error'}`);\n }\n if (status === 'succeeded') {\n seedanceVideoUrl = queryJson?.content?.video_url;\n break;\n }\n }\n\n if (!seedanceVideoUrl) throw new Error('Seedance 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const seedanceVideoRes = await fetch(seedanceVideoUrl);\n if (!seedanceVideoRes.ok) throw new Error(`视频下载失败: ${seedanceVideoRes.statusText}`);\n const seedanceVideoBlob = await seedanceVideoRes.blob();\n const seedanceMimeType = seedanceVideoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob: seedanceVideoBlob, mimeType: seedanceMimeType };\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const result = await generateVideoWithProvider(prompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n references: videoRefs,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(prompt, input.modelId, input.apiKeyPayload, imageReferences);\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "106d3156ad5e432787dcdac311479275d0d16f52": {"t": 1785773510.2556827, "n": 83779, "h": "da3e57472bf7037b", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n references?: IgnitionReference[];\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: [],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\ntype VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n return parseModelMappings(key.extraConfig)[model] || model;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): ImageInput[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({ href: reference.href, mimeType: reference.mimeType || 'image/png' }));\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-1(?:\\.5|-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n if (provider === 'openrouter') return true;\n return false;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-)/i.test(model)) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id || payload?.data?.task_id || payload?.id || payload?.data?.id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status || payload?.data?.status || payload?.data?.task_status || payload?.state;\n return typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.data?.video_url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n if (options?.image) {\n createBody.images = [options.image.href];\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (['failure', 'failed', 'fail', 'error', 'cancelled', 'canceled'].includes(status)) {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (['success', 'succeed', 'completed', 'done'].includes(status)) {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户 UI 中配置的 API Key\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content: prompt }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n // 先尝试标准 /images/generations 端点\n // custom provider 请求 url 格式(代理/聚合端点对大图片 b64_json 响应可能断连),\n // OpenAI 官方端点请求 b64_json(避免额外下载且链接有时效)\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n size: '1024x1024',\n response_format: preferredFormat,\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n // 对于 custom provider,/images/generations 失败后 fallback 到 chat/completions\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n // custom provider 允许 fallback\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 生图(聚合端点通用方式)\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行图片生成。请切换到 Google Gemini、OpenAI 或 OpenRouter 图片模型。`);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(images, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...images.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n images.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, options?.image, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (options?.image) {\n createBody.first_frame_image = options.image.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (options?.image) {\n createBody.image = options.image.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01 或 Keling 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (capability === 'video') {\n const firstFrameReference = input.references?.find(reference => reference.type === 'image' && reference.slotRole === 'first_frame')\n || input.references?.find(reference => reference.type === 'image');\n const firstFrame = firstFrameReference?.href\n ? { href: firstFrameReference.href, mimeType: firstFrameReference.mimeType || 'image/png' }\n : undefined;\n const result = await generateVideoWithProvider(prompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: input.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n image: firstFrame,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = imageReferences.length > 0\n ? await editImageWithProvider(imageReferences, prompt, input.modelId, input.apiKeyPayload)\n : await generateImageWithProvider(prompt, input.modelId, input.apiKeyPayload);\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "d35cea398fedc9c143e67a28a63dc1f438072e22": {"t": 1785773512.463101, "n": 83722, "h": "efa5d4d9c2341a9a", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n};\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n apiKeyPayload?: UserApiKey;\n references?: IgnitionReference[];\n onProgress?: (progress: number, message: string) => void;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: [],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\ntype VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n return parseModelMappings(key.extraConfig)[model] || model;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): ImageInput[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({ href: reference.href, mimeType: reference.mimeType || 'image/png' }));\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-1(?:\\.5|-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n if (provider === 'openrouter') return true;\n return false;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-)/i.test(model)) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id || payload?.data?.task_id || payload?.id || payload?.data?.id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status || payload?.data?.status || payload?.data?.task_status || payload?.state;\n return typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.data?.video_url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n if (options?.image) {\n createBody.images = [options.image.href];\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (['failure', 'failed', 'fail', 'error', 'cancelled', 'canceled'].includes(status)) {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (['success', 'succeed', 'completed', 'done'].includes(status)) {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户 UI 中配置的 API Key\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content: prompt }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n // 先尝试标准 /images/generations 端点\n // custom provider 请求 url 格式(代理/聚合端点对大图片 b64_json 响应可能断连),\n // OpenAI 官方端点请求 b64_json(避免额外下载且链接有时效)\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n size: '1024x1024',\n response_format: preferredFormat,\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n // 对于 custom provider,/images/generations 失败后 fallback 到 chat/completions\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n // custom provider 允许 fallback\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 生图(聚合端点通用方式)\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行图片生成。请切换到 Google Gemini、OpenAI 或 OpenRouter 图片模型。`);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(images, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...images.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n images.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, options?.image, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (options?.image) {\n createBody.first_frame_image = options.image.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (options?.image) {\n createBody.image = options.image.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01 或 Keling 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (capability === 'video') {\n const firstFrameReference = input.references?.find(reference => reference.type === 'image' && reference.slotRole === 'first_frame')\n || input.references?.find(reference => reference.type === 'image');\n const firstFrame = firstFrameReference?.href\n ? { href: firstFrameReference.href, mimeType: firstFrameReference.mimeType || 'image/png' }\n : undefined;\n const result = await generateVideoWithProvider(prompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n image: firstFrame,\n onProgress: message => input.onProgress?.(35, message),\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = imageReferences.length > 0\n ? await editImageWithProvider(imageReferences, prompt, input.modelId, input.apiKeyPayload)\n : await generateImageWithProvider(prompt, input.modelId, input.apiKeyPayload);\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "f3dc902c02ef1c81a8245b1d3ef36a755add816c": {"t": 1785773514.8439112, "n": 80158, "h": "1c9705d5183ab997", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: [],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\ntype VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n return parseModelMappings(key.extraConfig)[model] || model;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-1(?:\\.5|-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n if (provider === 'openrouter') return true;\n return false;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-)/i.test(model)) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id || payload?.data?.task_id || payload?.id || payload?.data?.id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status || payload?.data?.status || payload?.data?.task_status || payload?.state;\n return typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.data?.video_url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n if (options?.image) {\n createBody.images = [options.image.href];\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (['failure', 'failed', 'fail', 'error', 'cancelled', 'canceled'].includes(status)) {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (['success', 'succeed', 'completed', 'done'].includes(status)) {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户 UI 中配置的 API Key\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content: prompt }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n // 先尝试标准 /images/generations 端点\n // custom provider 请求 url 格式(代理/聚合端点对大图片 b64_json 响应可能断连),\n // OpenAI 官方端点请求 b64_json(避免额外下载且链接有时效)\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n size: '1024x1024',\n response_format: preferredFormat,\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n // 对于 custom provider,/images/generations 失败后 fallback 到 chat/completions\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n // custom provider 允许 fallback\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 生图(聚合端点通用方式)\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行图片生成。请切换到 Google Gemini、OpenAI 或 OpenRouter 图片模型。`);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(images, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...images.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n images.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, options?.image, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (options?.image) {\n createBody.first_frame_image = options.image.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (options?.image) {\n createBody.image = options.image.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01 或 Keling 视频模型。`\n );\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "c3f28400e4b342209eeaa2f4941a4739bf508d35": {"t": 1785773517.5765965, "n": 81064, "h": "6521bee3828bf4e8", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { splitImageByBanana, runBananaImageAgent, type BananaAgentTask, type BananaAgentResult, type BananaImageInput, type BananaSplitLayer } from './bananaService';\n\ntype ImageInput = { href: string; mimeType: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[]; agent?: string[] };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n banana: {\n text: [],\n image: [],\n video: [],\n agent: ['banana-vision-v1'],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: [],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\ntype VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Banana / 其他: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n banana: 'https://api.banana.dev/v1/vision',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n if (caps.agent?.length) result.push('agent');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n banana: 'Banana',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n if (key?.provider !== 'custom') return model;\n return parseModelMappings(key.extraConfig)[model] || model;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.provider === 'custom' ? key.extraConfig?.authHeaderName || 'Authorization' : 'Authorization';\n const authScheme = key?.provider === 'custom' ? key.extraConfig?.authScheme : 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^banana/.test(normalized)) return 'agent';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-1(?:\\.5|-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n if (provider === 'openrouter') return true;\n return false;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^banana/i.test(model)) return 'banana';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-)/i.test(model)) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id || payload?.data?.task_id || payload?.id || payload?.data?.id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status || payload?.data?.status || payload?.data?.task_status || payload?.state;\n return typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.data?.video_url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n if (options?.image) {\n createBody.images = [options.image.href];\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (['failure', 'failed', 'fail', 'error', 'cancelled', 'canceled'].includes(status)) {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (['success', 'succeed', 'completed', 'done'].includes(status)) {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户 UI 中配置的 API Key\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content: prompt }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n // 先尝试标准 /images/generations 端点\n // custom provider 请求 url 格式(代理/聚合端点对大图片 b64_json 响应可能断连),\n // OpenAI 官方端点请求 b64_json(避免额外下载且链接有时效)\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n size: '1024x1024',\n response_format: preferredFormat,\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n // 对于 custom provider,/images/generations 失败后 fallback 到 chat/completions\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n // custom provider 允许 fallback\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 生图(聚合端点通用方式)\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行图片生成。请切换到 Google Gemini、OpenAI 或 OpenRouter 图片模型。`);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(images, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...images.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n images.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, options?.image, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (options?.image) {\n createBody.first_frame_image = options.image.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (options?.image) {\n createBody.image = options.image.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01 或 Keling 视频模型。`\n );\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: BananaImageInput,\n model: string,\n key?: UserApiKey,\n): Promise<BananaSplitLayer[]> {\n const provider = resolveGenerationProvider(model, key);\n if (provider === 'banana') {\n return splitImageByBanana(image);\n }\n if (provider !== 'custom') {\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行图层拆分。`);\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is BananaSplitLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: BananaImageInput,\n task: BananaAgentTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<BananaAgentResult> {\n const provider = resolveGenerationProvider(model, key);\n if (provider === 'banana') {\n return runBananaImageAgent(image, task, options);\n }\n if (provider !== 'custom') {\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 运行图片代理任务。`);\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video', 'agent'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n if (missing.includes('agent')) warnings.push('未配置 Agent API Key — 智能代理功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n const hasBanana = keys.some(k => k.provider === 'banana' && k.key);\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n {\n capability: 'agent',\n supported: covered.has('agent'),\n reason: covered.has('agent')\n ? 'Banana agent 端点可用。'\n : hasBanana\n ? 'Banana Key 已配置但未声明 agent 能力,请检查 Key 配置。'\n : '多 Agent 讨论可使用文本模型运行,但 Banana 专用 agent 端点未配置。',\n },\n ];\n}\n"}, "0c2f5c4fc8e8e9c8e32f456d36964bcfa866c344": {"t": 1785773521.6674745, "n": 79725, "h": "4001a014dbf48fb6", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { splitImageByBanana, runBananaImageAgent, type BananaAgentTask, type BananaAgentResult, type BananaImageInput, type BananaSplitLayer } from './bananaService';\n\ntype ImageInput = { href: string; mimeType: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[]; agent?: string[] };\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n banana: {\n text: [],\n image: [],\n video: [],\n agent: ['banana-vision-v1'],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: [],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\ntype VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Banana / 其他: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n banana: 'https://api.banana.dev/v1/vision',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n if (caps.agent?.length) result.push('agent');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n banana: 'Banana',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n if (key?.provider !== 'custom') return model;\n return parseModelMappings(key.extraConfig)[model] || model;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.provider === 'custom' ? key.extraConfig?.authHeaderName || 'Authorization' : 'Authorization';\n const authScheme = key?.provider === 'custom' ? key.extraConfig?.authScheme : 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^banana/.test(normalized)) return 'agent';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-1(?:\\.5|-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n if (provider === 'openrouter') return true;\n return false;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^banana/i.test(model)) return 'banana';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-)/i.test(model)) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id || payload?.data?.task_id || payload?.id || payload?.data?.id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status || payload?.data?.status || payload?.data?.task_status || payload?.state;\n return typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.data?.video_url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n if (options?.image) {\n createBody.images = [options.image.href];\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (['failure', 'failed', 'fail', 'error', 'cancelled', 'canceled'].includes(status)) {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (['success', 'succeed', 'completed', 'done'].includes(status)) {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户 UI 中配置的 API Key\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content: prompt }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n // 先尝试标准 /images/generations 端点\n // custom provider 请求 url 格式(代理/聚合端点对大图片 b64_json 响应可能断连),\n // OpenAI 官方端点请求 b64_json(避免额外下载且链接有时效)\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n size: '1024x1024',\n response_format: preferredFormat,\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n // 对于 custom provider,/images/generations 失败后 fallback 到 chat/completions\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n // custom provider 允许 fallback\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 生图(聚合端点通用方式)\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行图片生成。请切换到 Google Gemini、OpenAI 或 OpenRouter 图片模型。`);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(images, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...images.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n images.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, options?.image, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (options?.image) {\n createBody.first_frame_image = options.image.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (options?.image) {\n createBody.image = options.image.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01 或 Keling 视频模型。`\n );\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: BananaImageInput,\n model: string,\n key?: UserApiKey,\n): Promise<BananaSplitLayer[]> {\n const provider = resolveGenerationProvider(model, key);\n if (provider === 'banana') {\n return splitImageByBanana(image);\n }\n if (provider !== 'custom') {\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行图层拆分。`);\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is BananaSplitLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: BananaImageInput,\n task: BananaAgentTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<BananaAgentResult> {\n const provider = resolveGenerationProvider(model, key);\n if (provider === 'banana') {\n return runBananaImageAgent(image, task, options);\n }\n if (provider !== 'custom') {\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 运行图片代理任务。`);\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video', 'agent'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n if (missing.includes('agent')) warnings.push('未配置 Agent API Key — 智能代理功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n const hasBanana = keys.some(k => k.provider === 'banana' && k.key);\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n {\n capability: 'agent',\n supported: covered.has('agent'),\n reason: covered.has('agent')\n ? 'Banana agent 端点可用。'\n : hasBanana\n ? 'Banana Key 已配置但未声明 agent 能力,请检查 Key 配置。'\n : '多 Agent 讨论可使用文本模型运行,但 Banana 专用 agent 端点未配置。',\n },\n ];\n}\n"}, "aced28dfbe288fe06f8b03cba87c4af50226f860": {"t": 1785773525.0975046, "n": 79530, "h": "d2d0e97682c5ba34", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { splitImageByBanana, runBananaImageAgent, type BananaAgentTask, type BananaAgentResult, type BananaImageInput, type BananaSplitLayer } from './bananaService';\n\ntype ImageInput = { href: string; mimeType: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[]; agent?: string[] };\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n banana: {\n text: [],\n image: [],\n video: [],\n agent: ['banana-vision-v1'],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: [],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\ntype VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Banana / 其他: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n banana: 'https://api.banana.dev/v1/vision',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n if (caps.agent?.length) result.push('agent');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n banana: 'Banana',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n if (key?.provider !== 'custom') return model;\n return parseModelMappings(key.extraConfig)[model] || model;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.provider === 'custom' ? key.extraConfig?.authHeaderName || 'Authorization' : 'Authorization';\n const authScheme = key?.provider === 'custom' ? key.extraConfig?.authScheme : 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^banana/.test(normalized)) return 'agent';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-1(?:\\.5|-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n if (provider === 'openrouter') return true;\n return false;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^banana/i.test(model)) return 'banana';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-)/i.test(model)) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id || payload?.data?.task_id || payload?.id || payload?.data?.id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status || payload?.data?.status || payload?.data?.task_status || payload?.state;\n return typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.data?.video_url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n if (options?.image) {\n createBody.images = [options.image.href];\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (['failure', 'failed', 'fail', 'error', 'cancelled', 'canceled'].includes(status)) {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (['success', 'succeed', 'completed', 'done'].includes(status)) {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户 UI 中配置的 API Key\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content: prompt }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n // 先尝试标准 /images/generations 端点\n // custom provider 请求 url 格式(代理/聚合端点对大图片 b64_json 响应可能断连),\n // OpenAI 官方端点请求 b64_json(避免额外下载且链接有时效)\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n size: '1024x1024',\n response_format: preferredFormat,\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n // 对于 custom provider,/images/generations 失败后 fallback 到 chat/completions\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n // custom provider 允许 fallback\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 生图(聚合端点通用方式)\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行图片生成。请切换到 Google Gemini、OpenAI 或 OpenRouter 图片模型。`);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(images, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...images.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n images.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, options?.image, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (options?.image) {\n createBody.first_frame_image = options.image.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (options?.image) {\n createBody.image = options.image.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01 或 Keling 视频模型。`\n );\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: BananaImageInput,\n model: string,\n key?: UserApiKey,\n): Promise<BananaSplitLayer[]> {\n const provider = resolveGenerationProvider(model, key);\n if (provider === 'banana') {\n return splitImageByBanana(image);\n }\n if (provider !== 'custom') {\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行图层拆分。`);\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is BananaSplitLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: BananaImageInput,\n task: BananaAgentTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<BananaAgentResult> {\n const provider = resolveGenerationProvider(model, key);\n if (provider === 'banana') {\n return runBananaImageAgent(image, task, options);\n }\n if (provider !== 'custom') {\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 运行图片代理任务。`);\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video', 'agent'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n if (missing.includes('agent')) warnings.push('未配置 Agent API Key — 智能代理功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n const hasBanana = keys.some(k => k.provider === 'banana' && k.key);\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n {\n capability: 'agent',\n supported: covered.has('agent'),\n reason: covered.has('agent')\n ? 'Banana agent 端点可用。'\n : hasBanana\n ? 'Banana Key 已配置但未声明 agent 能力,请检查 Key 配置。'\n : '多 Agent 讨论可使用文本模型运行,但 Banana 专用 agent 端点未配置。',\n },\n ];\n}\n"}, "5e5c748240f1ba27c18304fcbda1529fbc477e3b": {"t": 1785773526.791889, "n": 67756, "h": "9d459d62ff97240e", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[]; agent?: string[] };\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n banana: {\n text: [],\n image: [],\n video: [],\n agent: ['banana-vision-v1'],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: [],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\ntype VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], // Veo accepts all 6\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Banana / 其他: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n banana: 'https://api.banana.dev/v1/vision',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n if (caps.agent?.length) result.push('agent');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n banana: 'Banana',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^banana/.test(normalized)) return 'agent';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-1(?:\\.5|-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n if (provider === 'openrouter') return true;\n return false;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers.get('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) return {} as T;\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers.get('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^banana/i.test(model)) return 'banana';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-)/i.test(model)) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id || payload?.data?.task_id || payload?.id || payload?.data?.id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status || payload?.data?.status || payload?.data?.task_status || payload?.state;\n return typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.data?.video_url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n if (options?.image) {\n createBody.images = [options.image.href];\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (['failure', 'failed', 'fail', 'error', 'cancelled', 'canceled'].includes(status)) {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (['success', 'succeed', 'completed', 'done'].includes(status)) {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` };\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: model || 'gpt-5.4-mini',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` };\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: model || 'gpt-5.4-mini',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户 UI 中配置的 API Key\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content: prompt }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // 先尝试标准 /images/generations 端点\n // custom provider 请求 url 格式(代理/聚合端点对大图片 b64_json 响应可能断连),\n // OpenAI 官方端点请求 b64_json(避免额外下载且链接有时效)\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n prompt,\n size: '1024x1024',\n response_format: preferredFormat,\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n // 对于 custom provider,/images/generations 失败后 fallback 到 chat/completions\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n // custom provider 允许 fallback\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 生图(聚合端点通用方式)\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行图片生成。请切换到 Google Gemini、OpenAI 或 OpenRouter 图片模型。`);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(images, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...images.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const formData = new FormData();\n formData.append('model', model);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n images.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${apiKey}`,\n },\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, options?.image, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (options?.image) {\n createBody.first_frame_image = options.image.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (options?.image) {\n createBody.image = options.image.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01 或 Keling 视频模型。`\n );\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video', 'agent'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n if (missing.includes('agent')) warnings.push('未配置 Agent API Key — 智能代理功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n const hasBanana = keys.some(k => k.provider === 'banana' && k.key);\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n {\n capability: 'agent',\n supported: covered.has('agent'),\n reason: covered.has('agent')\n ? 'Banana agent 端点可用。'\n : hasBanana\n ? 'Banana Key 已配置但未声明 agent 能力,请检查 Key 配置。'\n : '多 Agent 讨论可使用文本模型运行,但 Banana 专用 agent 端点未配置。',\n },\n ];\n}\n"}, "fc1a11fdc9b2cf0956237df670a4f7ec4a79539a": {"t": 1785773530.405715, "n": 66437, "h": "bb0719e931fbdab7", "c": "import type { AICapability, AIProvider, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\n\ntype ImageInput = { href: string; mimeType: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[]; agent?: string[] };\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image-preview', 'gemini-3-pro-image-preview', 'gemini-2.5-flash-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-lite-generate-preview', 'veo-2.0-generate-001'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n banana: {\n text: [],\n image: [],\n video: [],\n agent: ['banana-vision-v1'],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: ['rhart-image-n-pro-official'],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: [],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const { rhTestApiKey } = await import('./runningHubService');\n const valid = await rhTestApiKey(apiKey);\n return valid ? { ok: true, capabilitySummary: ['image'] } : { ok: false, message: 'API Key 无效' };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Banana / 其他: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n banana: 'https://api.banana.dev/v1/vision',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n if (caps.agent?.length) result.push('agent');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n banana: 'Banana',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction normalizeModelName(model: string): string {\n return model.trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo)/.test(normalized)) return 'video';\n if (/^banana/.test(normalized)) return 'agent';\n if (/^(imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-1(?:\\.5|-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n if (provider === 'openrouter') return true;\n return false;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers.get('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) return {} as T;\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers.get('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^banana/i.test(model)) return 'banana';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-)/i.test(model)) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction sleep(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id || payload?.data?.task_id || payload?.id || payload?.data?.id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status || payload?.data?.status || payload?.data?.task_status || payload?.state;\n return typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.data?.video_url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n if (options?.image) {\n createBody.images = [options.image.href];\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (['failure', 'failed', 'fail', 'error', 'cancelled', 'canceled'].includes(status)) {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (['success', 'succeed', 'completed', 'done'].includes(status)) {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` };\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: model || 'gpt-5.4-mini',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` };\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: model || 'gpt-5.4-mini',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户 UI 中配置的 API Key\n return generateImageFromText(prompt, key?.key);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content: prompt }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // 先尝试标准 /images/generations 端点\n // custom provider 请求 url 格式(代理/聚合端点对大图片 b64_json 响应可能断连),\n // OpenAI 官方端点请求 b64_json(避免额外下载且链接有时效)\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n prompt,\n size: '1024x1024',\n response_format: preferredFormat,\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n // 对于 custom provider,/images/generations 失败后 fallback 到 chat/completions\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n // custom provider 允许 fallback\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 生图(聚合端点通用方式)\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n throw new Error(`当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行图片生成。请切换到 Google Gemini、OpenAI 或 OpenRouter 图片模型。`);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(images, prompt, options?.mask, key?.key);\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...images.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom') {\n // custom provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const formData = new FormData();\n formData.append('model', model);\n formData.append('prompt', prompt);\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n\n images.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${apiKey}`,\n },\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';\n onProgress?: (message: string) => void;\n image?: ImageInput;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n\n if (provider === 'google') {\n return generateVideo(prompt, aspectRatio, onProgress, options?.image, key?.key);\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (options?.image) {\n createBody.first_frame_image = options.image.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: '5',\n };\n if (options?.image) {\n createBody.image = options.image.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await new Promise(resolve => setTimeout(resolve, 10000));\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl);\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01 或 Keling 视频模型。`\n );\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video', 'agent'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n if (missing.includes('agent')) warnings.push('未配置 Agent API Key — 智能代理功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n const hasBanana = keys.some(k => k.provider === 'banana' && k.key);\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n {\n capability: 'agent',\n supported: covered.has('agent'),\n reason: covered.has('agent')\n ? 'Banana agent 端点可用。'\n : hasBanana\n ? 'Banana Key 已配置但未声明 agent 能力,请检查 Key 配置。'\n : '多 Agent 讨论可使用文本模型运行,但 Banana 专用 agent 端点未配置。',\n },\n ];\n}\n"}, "0957ca7bed8de5569999b7ea8d480b7726282ae6": {"t": 1786068176.097815, "n": 159797, "h": "04fc229ecc5fe88d", "c": "import type { AICapability, AIProvider, ProductModelMode, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\nimport { explainReferenceCompatibility, sanitizeProductGenerationParams } from './productModelCatalog';\nimport { getRouteSchema, getRouteDurations, type RouteCapabilitySchema, type RouteMediaSpec } from './runningHubRouteCatalog';\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport type ProviderTaskLifecycleEvent =\n | { phase: 'submitted'; providerTaskId: string; submittedAt: number }\n | { phase: 'running'; providerTaskId: string; remoteStatus?: string }\n | { phase: 'cancelled'; providerTaskId: string; canceled: boolean; upstreamStillRunning?: boolean; message?: string }\n | { phase: 'usage'; providerTaskId: string; status: SeedanceVideoReconcileResult['status']; amount?: number; currency?: string; totalTokens?: number };\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n productModelId?: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n quality?: string;\n generationSubmode?: ProductModelMode;\n generateAudio?: boolean;\n watermark?: boolean;\n webSearch?: boolean;\n realPersonCheck?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image', 'gemini-3.1-flash-lite-image', 'gemini-3-pro-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-fast-generate-preview', 'veo-3.1-lite-generate-preview'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n xai: {\n text: ['grok-4-fast', 'grok-4'],\n image: ['grok-2-image'],\n video: ['grok-imagine-video', 'grok-imagine-video-1.5'],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '3:2' | '2:3' | '4:5' | '5:4' | '1:4' | '4:1' | '1:8' | '8:1' | '21:9' | '9:21' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\nconst OPENAI_IMAGE_SIZES: Record<'1K' | '2K' | '4K', Record<string, string>> = {\n '1K': { '1:1': '1024x1024', '3:2': '1536x1024', '2:3': '1024x1536', '16:9': '1792x1024', '9:16': '1024x1792', '4:3': '1344x1008', '3:4': '1008x1344' },\n '2K': { '1:1': '2048x2048', '3:2': '2048x1360', '2:3': '1360x2048', '16:9': '2048x1152', '9:16': '1152x2048', '4:3': '2048x1536', '3:4': '1536x2048' },\n '4K': { '1:1': '2880x2880', '3:2': '3520x2352', '2:3': '2352x3520', '16:9': '3840x2160', '9:16': '2160x3840', '4:3': '3312x2480', '3:4': '2480x3312' },\n};\n\nfunction getOpenAIImageSize(resolution?: string, aspectRatio?: VideoAspectRatio): string {\n const tier = resolution?.toUpperCase() === '4K' ? '4K' : resolution?.toUpperCase() === '2K' ? '2K' : '1K';\n return OPENAI_IMAGE_SIZES[tier][aspectRatio || '1:1'] || OPENAI_IMAGE_SIZES[tier]['1:1'];\n}\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance') || isRunningHubSeedance20Model(normalized)) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'runningHub' && (inferCapabilityFromModelName(model) === 'video' || isRunningHubVideoEndpoint(normalized))) {\n return { ...DEFAULT_VIDEO_CAPABILITY, durations: resolveRunningHubVideoDurations(normalized) };\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地工作流节点。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16'], // Veo 3.1 documented ratios\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n xai: ['1:1', '16:9', '9:16', '4:3', '3:4', '2:3', '3:2'], // Grok Imagine Video\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n xai: 'https://api.x.ai/v1',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^xai-/i.test(trimmed)) return 'xai';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n xai: 'xAI Grok',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction assertResolvedUpstreamModel(model: string): void {\n if (stripModelSelectionRef(model).trim().toLowerCase().startsWith('flovart:')) {\n throw new Error(`产品模型 ${model} 尚未解析为供应商上游模型。请检查该产品模型的 BYOK 映射。`);\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie') || normalized.includes('motion-control')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction adaptVideoSlotsForMode(slots: MultimodalSlot[], mode?: ProductModelMode, provider?: AIProvider, routeId?: string): MultimodalSlot[] {\n if (!mode) return slots;\n const images = slots.filter(slot => slot.kind === 'image');\n if (mode === 'text-to-video') return [];\n if (mode === 'image-to-video') {\n if (!images[0]) throw new Error('图生视频至少需要 1 张图片。');\n const routeImageSpec = provider === 'runningHub'\n ? getRouteSchema(routeId || '')?.media.find(spec => spec.kind === 'image' && spec.mapping === 'all_references')\n : undefined;\n if (routeImageSpec) return images.slice(0, routeImageSpec.max).map((image, index) => ({ ...image, role: index === 0 ? 'first_frame' : 'reference_image' }));\n return [{ ...images[0], role: 'first_frame' }];\n }\n if (mode === 'first-last-frame') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配首尾帧,请切换模式或重新映射线路。');\n if (images.length < 2) throw new Error('首尾帧模式需要 2 张图片。');\n return [{ ...images[0], role: 'first_frame' }, { ...images[1], role: 'last_frame' }];\n }\n if (mode === 'reference-to-video') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配全能参考,请切换模式或重新映射线路。');\n const references = provider === 'google' ? images.slice(0, 3) : slots;\n if (!references.length) throw new Error(provider === 'google' ? 'Veo 全能参考至少需要 1 张图片。' : '全能参考至少需要 1 个媒体素材。');\n return references.map(slot => ({\n ...slot,\n role: slot.kind === 'video' ? 'reference_video' : slot.kind === 'audio' ? 'reference_audio' : 'reference_image',\n }));\n }\n return slots;\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName) || `@${ordinal}`;\n const nameNote = reference.sourceName && reference.sourceName !== mention.replace(/^@/, '')\n ? `(节点: ${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction resolveRunningHubVideoDurations(model: string): number[] {\n const catalogDurations = getRouteDurations(model);\n return catalogDurations ?? [];\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRouteMediaFields(\n schema: RouteCapabilitySchema,\n references: VideoImage[],\n slots: MultimodalSlot[],\n): Record<string, unknown> {\n const output: Record<string, unknown> = {};\n for (const spec of schema.media) {\n if (spec.mapping === 'all_references') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (spec.serialization === 'array') {\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (urls[0]) {\n output[spec.field] = assertRunningHubPublicUrl(urls[0], spec.field);\n }\n } else if (spec.mapping === 'first_last_frame') {\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n if (spec.field === 'firstFrameUrl') {\n if (spec.required && !first?.href) {\n throw new Error(`RunningHub ${schema.routeId} 需要首帧图片。`);\n }\n if (first?.href) output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n } else if (spec.field === 'lastFrameUrl') {\n if (last?.href) output[spec.field] = assertRunningHubPublicUrl(last.href, spec.field);\n }\n } else if (spec.mapping === 'first_frame_only') {\n const first = references[0];\n if (first?.href) {\n output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n }\n } else if (spec.mapping === 'reference_images') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (spec.mapping === 'multimodal') {\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, i) => assertRunningHubPublicUrl(href, `${spec.field}[${i}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n if (spec.kind === 'image' && imageUrls.length > 0) output[spec.field] = uniqueLimitedUrls(imageUrls, spec.max);\n if (spec.kind === 'video' && videoUrls.length > 0) output[spec.field] = uniqueLimitedUrls(videoUrls, spec.max);\n if (spec.kind === 'audio' && audioUrls.length > 0) output[spec.field] = uniqueLimitedUrls(audioUrls, spec.max);\n }\n }\n return output;\n}\n\nfunction snapDuration(requested: number, allowed: number[]): number {\n return allowed.reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, allowed[0]);\n}\n\nfunction buildRunningHubRoutePayload(\n prompt: string,\n schema: RouteCapabilitySchema,\n key: UserApiKey | undefined,\n options: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const promptField = extra.runningHubPromptField || schema.promptField;\n\n const payload: Record<string, unknown> = {};\n\n if (!disableDefaults) {\n if (schema.resolutionDefault) payload.resolution = schema.resolutionDefault;\n if (schema.durationDefault !== undefined) payload.duration = schema.durationDefault;\n if (schema.aspectRatioField && schema.aspectRatioValues?.[0]) payload[schema.aspectRatioField] = schema.aspectRatioValues[0];\n for (const param of schema.params) {\n if (param.default !== undefined) {\n if (param.type === 'string' && param.default === 'all') {\n payload[param.field] = ['all'];\n } else {\n payload[param.field] = param.default;\n }\n }\n }\n }\n\n Object.assign(payload, configuredPayload);\n\n payload[promptField] = prompt;\n\n if (schema.aspectRatioField && options?.aspectRatio) {\n payload[schema.aspectRatioField] = options.aspectRatio;\n }\n\n if (schema.durationType && options?.durationSec && options.durationSec > 0) {\n if (schema.durationValues && schema.durationValues.length > 0) {\n const allowed = schema.durationValues.map(v => Number(v)).filter(n => !isNaN(n));\n if (allowed.length > 0) {\n if (schema.durationType === 'string') {\n payload.duration = String(snapDuration(options.durationSec, allowed));\n } else {\n payload.duration = snapDuration(options.durationSec, allowed);\n }\n }\n } else if (schema.durationType === 'string') {\n payload.duration = String(options.durationSec);\n } else {\n payload.duration = options.durationSec;\n }\n }\n\n if (options?.resolution) {\n payload.resolution = options.resolution;\n }\n\n if (options?.userParams) {\n const schemaFields = new Set(schema.params.map(p => p.field));\n for (const [key, value] of Object.entries(options.userParams)) {\n if (value !== undefined && schemaFields.has(key)) {\n payload[key] = value;\n }\n }\n }\n\n Object.assign(payload, buildRouteMediaFields(schema, options?.references || [], options?.slots || []));\n\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n\n return payload;\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const routeSchema = getRouteSchema(modelEndpoint);\n if (routeSchema) {\n return buildRunningHubRoutePayload(prompt, routeSchema, key, options || {});\n }\n throw new Error(`RunningHub 路由 ${modelEndpoint} 未通过验证(未在 Route Catalog 中登记),暂不可执行。请先在 Route Catalog 中补齐该路由的 Capability Schema 后再使用。`);\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(grok-|xai)/i.test(model)) return 'xai';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal; aspectRatio?: VideoAspectRatio; resolution?: string; quality?: string; webSearch?: boolean },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (isGoogleImageEditModel(model)) {\n return editImage(refs, prompt, undefined, key?.key, options?.signal, {\n model,\n aspectRatio: options?.aspectRatio === 'adaptive' ? undefined : options?.aspectRatio,\n imageSize: options?.resolution,\n webSearch: options?.webSearch,\n baseUrl: key?.baseUrl,\n });\n }\n return generateImageFromText(prompt, key?.key, options?.signal, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n aspectRatio: options?.aspectRatio,\n resolution: options?.resolution,\n userParams: {\n ...(options?.webSearch !== undefined ? { webSearch: options.webSearch } : {}),\n ...(options?.quality !== undefined ? { hd: options.quality === 'hd' || options.quality === 'high' } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const imageSize = getOpenAIImageSize(options?.resolution, options?.aspectRatio);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key, undefined, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport class SeedanceSubmissionUnknownError extends Error {\n readonly code = 'SEEDANCE_SUBMISSION_UNKNOWN';\n constructor(cause?: unknown) {\n super('Seedance 提交结果未知:供应商可能已经创建并计费。已禁止自动重试或切换线路,请先到任务记录核对。', { cause });\n this.name = 'SeedanceSubmissionUnknownError';\n }\n}\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const rawSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n const slots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, 'volcengine');\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n let createRes: Response;\n try {\n createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n } catch (error) {\n throw new SeedanceSubmissionUnknownError(error);\n }\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 区分官方 Ark 与第三方 OpenAI-compatible 网关:\n * - Ark 官方:baseUrl 含 `ark.*.volces.com` 或路径含 `/api/v3`,惯例 DELETE `/contents/generations/tasks/{id}`\n * - 网关(如 Scnet):路径含 `/api/llm/v1`,惯例 POST `/tasks/{id}/cancel`\n * - 其他:fallback 到 Ark 风格(保留向后兼容)。\n */\nfunction detectSeedanceCancelRoute(baseUrl: string): 'ark' | 'gateway' {\n const lower = (baseUrl || '').toLowerCase();\n if (lower.includes('/api/llm/v1') || lower.includes('/v1/tasks')) return 'gateway';\n if (lower.includes('volces.com') || lower.includes('/api/v3')) return 'ark';\n return 'ark';\n}\n\nexport type SeedanceVideoCancelResult = {\n canceled: boolean;\n reason: 'ok' | 'not_queued' | 'not_cancellable' | 'unsupported' | 'network_error';\n upstreamStillRunning?: boolean;\n message?: string;\n raw?: unknown;\n};\n\n/**\n * 取消 Seedance 上游任务。仅对 queued 阶段的成功取消返回 `canceled: true`。\n * 上游已开始 processing、或上游不支持取消时返回 `canceled:false, upstreamStillRunning:true`,\n * 让前端走“放弃等待”的退路(仅本地 abortSignal 不再轮询上游)。\n */\nexport async function cancelSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal; reason?: string },\n): Promise<SeedanceVideoCancelResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const route = detectSeedanceCancelRoute(baseUrl);\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n if (route === 'ark') {\n // 官方 Ark:DELETE /contents/generations/tasks/{id}。409=不可取消;200/204=已取消。\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } else {\n // 网关风格:POST /tasks/{id}/cancel;body 可携带 reason。\n res = await fetch(`${baseUrl}/tasks/${taskId}/cancel`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },\n body: JSON.stringify({ reason: options?.reason || 'user_abort' }),\n signal: options?.signal,\n });\n }\n } catch (error) {\n return {\n canceled: false,\n reason: 'network_error',\n upstreamStillRunning: true,\n message: error instanceof Error ? error.message : '网络错误,无法确认取消结果',\n };\n }\n\n if (res.status === 405 || res.status === 404) {\n // 网关/官方未实现取消端点\n return {\n canceled: false,\n reason: 'unsupported',\n upstreamStillRunning: true,\n message: '当前 Seedance 渠道不支持取消,请等待完成或放弃等待',\n };\n }\n\n let raw: any = undefined;\n try {\n raw = await res.json();\n } catch {\n // 204 No Content 等成功无 body 的情况\n }\n\n if (res.ok) {\n // Scnet 风格可能返回 { code: 0 } 或 { success: true }; Ark 200 返回 { cancelled: true }\n // 兼容两种解析:cancelled=true / code===0 / success===true 视为成功\n const okFlag =\n raw?.cancelled === true\n || raw?.data?.cancelled === true\n || raw?.code === 0\n || raw?.success === true\n || (route === 'ark' && res.status === 200 && raw == null);\n return {\n canceled: Boolean(okFlag),\n reason: okFlag ? 'ok' : 'not_cancellable',\n upstreamStillRunning: !okFlag,\n message: okFlag ? undefined : (raw?.message || raw?.msg || '上游响应未确认取消'),\n raw,\n };\n }\n\n if (res.status === 409 || res.status === 400) {\n // Ark: 409 task_not_cancellable (已 processing);网关: 400 已开始\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || '任务已开始执行,上游无法取消',\n raw,\n };\n }\n\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || `上游返回 ${res.status}`,\n raw,\n };\n}\n\nexport type SeedanceVideoReconcileResult = {\n status: 'succeeded' | 'failed' | 'cancelled' | 'expired' | 'unknown';\n amount?: number;\n currency?: string;\n totalTokens?: number;\n reason?: string;\n raw?: unknown;\n};\n\nfunction parseSeedanceVideoUsage(raw: any, fallbackStatus: SeedanceVideoReconcileResult['status'] = 'unknown'): SeedanceVideoReconcileResult {\n const remoteStatus = String(raw?.status || raw?.data?.status || raw?.data?.task_status || raw?.task?.status || raw?.state || '').toLowerCase();\n let status: SeedanceVideoReconcileResult['status'] = fallbackStatus;\n if (['succeeded', 'succeed', 'success', 'completed', 'complete', 'done'].includes(remoteStatus)) status = 'succeeded';\n else if (['cancelled', 'canceled'].includes(remoteStatus)) status = 'cancelled';\n else if (['failed', 'fail', 'error'].includes(remoteStatus)) status = 'failed';\n else if (['expired', 'timeout'].includes(remoteStatus)) status = 'expired';\n const billingRaw = raw?.data?.billing || raw?.data?.usage?.billing || raw?.billing || raw?.usage?.billing;\n const amount = typeof billingRaw?.amount === 'number' ? billingRaw.amount : undefined;\n const currency = typeof billingRaw?.currency === 'string' ? billingRaw.currency : raw?.data?.currency || raw?.currency;\n const totalTokens = typeof raw?.usage?.total_tokens === 'number' ? raw.usage.total_tokens\n : typeof raw?.data?.usage?.total_tokens === 'number' ? raw.data.usage.total_tokens\n : typeof raw?.usage?.token_usage?.total_tokens === 'number' ? raw.usage.token_usage.total_tokens\n : undefined;\n return { status, amount, currency, totalTokens, raw };\n}\n\n/**\n * 查询上游任务的真实账单/token 使用量。复用 GET 任务查询接口;不修改 handle。\n *\n * - 网关(Scnet/Lemondata)常见字段:`data.billing.amount` / `data.usage.billing.amount` + `currency`\n * - Volcengine Ark 官方常见字段:`usage.total_tokens`(仅 token,无 billing 金额)\n *\n * 调用方拿到返回值后:\n * - amount 存在 → actualCost = amount,billableState = 'actual'\n * - 仅 totalTokens → actualTokens = totalTokens,billableState 保持 'unknown'\n * - status === 'unknown' → 不变更 usageRecord\n */\nexport async function reconcileSeedanceVideoUsage(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoReconcileResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } catch (error) {\n return { status: 'unknown', reason: error instanceof Error ? error.message : '网络错误' };\n }\n if (!res.ok) {\n return { status: 'unknown', reason: `上游返回 ${res.status}` };\n }\n let raw: any;\n try {\n raw = await res.json();\n } catch {\n return { status: 'unknown', reason: '响应不是合法 JSON' };\n }\n\n return parseSeedanceVideoUsage(raw);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const runningHubEndpoint = provider === 'runningHub'\n ? assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model)\n : undefined;\n const legacyReferences = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const rawSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(legacyReferences);\n const multimodalSlots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, provider, runningHubEndpoint);\n const references: VideoImage[] = multimodalSlots.filter(slot => slot.kind === 'image').map(slot => ({\n href: slot.href,\n mimeType: slot.mimeType,\n slotRole: String(slot.role || 'unassigned'),\n label: slot.label,\n }));\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const usesFirstFrame = !options?.generationSubmode || options.generationSubmode === 'image-to-video' || options.generationSubmode === 'first-last-frame';\n const firstFrame = usesFirstFrame\n ? references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined)\n : undefined;\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = runningHubEndpoint!;\n const allImageRefs = references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n userParams: {\n ...(options?.generateAudio !== undefined ? { generateAudio: options.generateAudio } : {}),\n ...(options?.seed !== undefined ? { seed: options.seed } : {}),\n ...(options?.returnLastFrame !== undefined ? { returnLastFrame: options.returnLastFrame } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n const lastFrameSlot = options?.generationSubmode === 'first-last-frame'\n ? multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'last_frame')\n : undefined;\n const referenceImages = options?.generationSubmode === 'reference-to-video'\n ? multimodalSlots.filter(slot => slot.kind === 'image' && slot.role !== 'first_frame' && slot.role !== 'last_frame').slice(0, 3)\n : [];\n return generateVideo(prompt, aspectRatio === '9:16' ? '9:16' : '16:9', onProgress, firstFrame, key?.key, {\n model,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n generateAudio: options?.generateAudio,\n signal: options?.signal,\n lastFrame: lastFrameSlot ? { href: lastFrameSlot.href, mimeType: lastFrameSlot.mimeType } : undefined,\n referenceImages: referenceImages.map(reference => ({ href: reference.href, mimeType: reference.mimeType })),\n baseUrl: key?.baseUrl,\n });\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl, { signal: options?.signal });\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: options?.resolution?.toLowerCase() === '1080p' ? 'pro' : 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: String(options?.durationSec || 5),\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl, { signal: options?.signal });\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'xai') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const submode: ProductModelMode = options?.generationSubmode || 'text-to-video';\n\n onProgress('Submitting video generation task...');\n let createPath: string;\n const createBody: Record<string, unknown> = { model, prompt };\n\n if (submode === 'video-extension') {\n createPath = `${baseUrl}/videos/extensions`;\n const videoSlot = multimodalSlots.find(slot => slot.kind === 'video');\n if (!videoSlot) {\n throw new Error('xAI 视频扩展至少需要 1 个视频素材作为驱动视频。');\n }\n createBody.video = { url: videoSlot.href };\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n } else {\n createPath = `${baseUrl}/videos/generations`;\n if (aspectRatio) {\n createBody.aspect_ratio = aspectRatio;\n }\n if (options?.resolution) {\n createBody.resolution = options.resolution;\n }\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n if (firstFrame) {\n createBody.image = { url: firstFrame.href };\n }\n }\n\n const createRes = await fetch(createPath, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'xAI 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'xAI 视频生成创建响应');\n const requestId = createJson?.request_id;\n if (!requestId) {\n throw new Error('xAI 视频生成未返回 request_id');\n }\n\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const xaiPollStart = Date.now();\n while (true) {\n if (Date.now() - xaiPollStart > 600_000) {\n throw new Error('xAI 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'xAI 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'xAI 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'failed') {\n throw new Error(`xAI 视频生成失败: ${queryJson?.error || queryJson?.message || 'Unknown error'}`);\n }\n if (status === 'done') {\n videoUrl = queryJson?.output?.url;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!videoUrl) {\n throw new Error('xAI 视频生成完成但未返回视频 URL');\n }\n\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n await options?.onProviderTaskLifecycle?.({ phase: 'submitted', providerTaskId: handle.taskId, submittedAt: handle.createdAt });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n let cancelPromise: Promise<SeedanceVideoCancelResult> | undefined;\n const requestUpstreamCancel = () => cancelPromise ||= cancelSeedanceVideoTask(handle, key, { reason: 'user_abort' }).then(async result => {\n await options?.onProviderTaskLifecycle?.({ phase: 'cancelled', providerTaskId: handle.taskId, canceled: result.canceled, upstreamStillRunning: result.upstreamStillRunning, message: result.message });\n const usage = await reconcileSeedanceVideoUsage(handle, key);\n if (usage.status !== 'unknown' || usage.amount !== undefined || usage.totalTokens !== undefined) {\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n }\n return result;\n });\n const handleAbort = () => { void requestUpstreamCancel(); };\n options?.signal?.addEventListener('abort', handleAbort, { once: true });\n\n try {\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n const usage = parseSeedanceVideoUsage(result.raw, 'failed');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n const usage = parseSeedanceVideoUsage(result.raw, 'succeeded');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n await options?.onProviderTaskLifecycle?.({ phase: 'running', providerTaskId: handle.taskId, remoteStatus: result.remoteStatus });\n }\n } catch (error) {\n if (options?.signal?.aborted || error instanceof Error && error.name === 'AbortError') await requestUpstreamCancel();\n throw error;\n } finally {\n options?.signal?.removeEventListener('abort', handleAbort);\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const params = sanitizeProductGenerationParams(input.productModelId, {\n mode: input.generationSubmode,\n aspectRatio: input.aspectRatio,\n resolution: input.resolution,\n quality: input.quality,\n durationSec: input.durationSec,\n generateAudio: input.generateAudio,\n webSearch: input.webSearch,\n realPersonCheck: input.realPersonCheck,\n referenceCount: input.references?.length || 0,\n }, { provider: input.apiKeyPayload?.provider, routeId: input.modelId });\n if (capability === 'video') {\n const referenceKinds = (input.references || [])\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio' } => reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n .map(reference => reference.type);\n const issue = explainReferenceCompatibility(input.productModelId, params.mode as ProductModelMode, referenceKinds, { provider: input.apiKeyPayload?.provider, routeId: input.modelId });\n if (issue) throw new Error(issue);\n }\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: params.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: params.durationSec,\n resolution: params.resolution,\n generateAudio: params.generateAudio,\n watermark: input.watermark,\n generationSubmode: params.mode,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n onProviderTaskLifecycle: input.onProviderTaskLifecycle,\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, {\n signal: input.signal,\n aspectRatio: params.aspectRatio,\n resolution: params.resolution,\n quality: params.quality,\n webSearch: params.webSearch,\n });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}, "00cb21f445793d666f08232eea73c359cbd0e39d": {"t": 1786068474.6449385, "n": 161899, "h": "cf0fdbc5f198b23f", "c": "import type { AICapability, AIProvider, ProductModelMode, PromptEnhanceRequest, PromptEnhanceResult, UserApiKey } from '../types';\nimport { editImage, enhancePromptWithGemini, generateImageFromText, generateVideo, validateGeminiApiKey, getGeminiRestBaseUrl } from './geminiService';\nimport { fetchModelsForProvider, type FetchModelsResult } from './modelFetcher';\nimport { normalizeProviderBaseUrl } from './baseUrl';\nimport { assertRunningHubModelEndpoint } from './runningHubService';\nimport { explainReferenceCompatibility, sanitizeProductGenerationParams } from './productModelCatalog';\nimport { getRouteSchema, getRouteDurations, type RouteCapabilitySchema, type RouteMediaSpec } from './runningHubRouteCatalog';\nimport { runRuntimeMediaGeneration } from './runtimeGeneration';\n\nfunction blobToBase64(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => {\n const value = reader.result;\n if (typeof value === 'string') resolve(value);\n else reject(new Error('无法读取生成结果'));\n };\n reader.onerror = () => reject(new Error('读取生成结果失败'));\n reader.readAsDataURL(blob);\n });\n}\n\ntype ImageInput = { href: string; mimeType: string };\n\nexport type MultimodalSlotKind = 'image' | 'video' | 'audio';\nexport type MultimodalSlotRole =\n | 'first_frame'\n | 'last_frame'\n | 'reference_image'\n | 'reference_video'\n | 'reference_audio'\n | 'style_ref'\n | 'control_net'\n | 'unassigned';\n\nexport type MultimodalSlot = {\n kind: MultimodalSlotKind;\n href: string;\n mimeType: string;\n role?: MultimodalSlotRole | string;\n label?: string;\n};\n\ntype VideoImage = ImageInput & { slotRole?: string; label?: string; sourceName?: string; elementId?: string };\n\ntype ProviderModelMap = { text: string[]; image: string[]; video: string[] };\n\ntype IgnitionReference = {\n type: 'image' | 'video' | 'audio' | 'text' | 'shape';\n href?: string;\n mimeType?: string;\n slotRole?: string;\n label?: string;\n sourceName?: string;\n text?: string;\n elementId?: string;\n};\n\nexport type ProviderTaskLifecycleEvent =\n | { phase: 'submitted'; providerTaskId: string; submittedAt: number }\n | { phase: 'running'; providerTaskId: string; remoteStatus?: string }\n | { phase: 'cancelled'; providerTaskId: string; canceled: boolean; upstreamStillRunning?: boolean; message?: string }\n | { phase: 'usage'; providerTaskId: string; status: SeedanceVideoReconcileResult['status']; amount?: number; currency?: string; totalTokens?: number };\n\nexport interface UnifiedIgnitionInput {\n elementId: string;\n prompt: string;\n modelId: string;\n productModelId?: string;\n apiKeyPayload?: UserApiKey;\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n quality?: string;\n generationSubmode?: ProductModelMode;\n generateAudio?: boolean;\n watermark?: boolean;\n webSearch?: boolean;\n realPersonCheck?: boolean;\n references?: IgnitionReference[];\n signal?: AbortSignal;\n onProgress?: (progress: number, message: string) => void;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n}\n\nexport type UnifiedIgnitionResult =\n | { ok: true; elementId: string; mediaUrl: string; mimeType: string; capability: ElementMediaCapability; textResponse?: string | null }\n | { ok: false; elementId: string; errorMessage: string; capability: ElementMediaCapability };\n\nexport type ElementMediaCapability = 'image' | 'video';\n\nexport interface ModelParamSchema {\n hasSeed: boolean;\n hasCfgScale: boolean;\n hasAspectRatio: boolean;\n defaultAspectRatio?: VideoAspectRatio;\n}\n\nexport const DEFAULT_PROVIDER_MODELS: Partial<Record<AIProvider, ProviderModelMap>> = {\n google: {\n text: ['gemini-3-flash-preview', 'gemini-3.1-pro-preview', 'gemini-3.1-flash-lite-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'],\n image: ['gemini-3.1-flash-image', 'gemini-3.1-flash-lite-image', 'gemini-3-pro-image', 'imagen-4.0-generate-001'],\n video: ['veo-3.1-generate-preview', 'veo-3.1-fast-generate-preview', 'veo-3.1-lite-generate-preview'],\n },\n openai: {\n text: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-4o-mini'],\n image: ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'dall-e-3'],\n video: [],\n },\n anthropic: {\n text: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],\n image: [],\n video: [],\n },\n qwen: {\n text: ['qwen-max'],\n image: [],\n video: [],\n },\n deepseek: {\n text: ['deepseek-chat', 'deepseek-reasoner'],\n image: [],\n video: [],\n },\n siliconflow: {\n text: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct'],\n image: [],\n video: [],\n },\n keling: {\n text: [],\n image: [],\n video: [],\n },\n flux: {\n text: [],\n image: [],\n video: [],\n },\n midjourney: {\n text: [],\n image: [],\n video: [],\n },\n runningHub: {\n text: [],\n image: [],\n video: [],\n },\n minimax: {\n text: ['MiniMax-Text-01', 'abab6.5s-chat'],\n image: ['minimax-image-01'],\n video: ['video-01'],\n },\n volcengine: {\n text: ['doubao-1.5-pro-256k', 'doubao-1.5-pro-32k'],\n image: [],\n video: ['doubao-seedance-2-0-260128', 'doubao-seedance-2-0-fast-260128'],\n },\n openrouter: {\n text: ['openrouter/auto', 'google/gemini-3-flash-preview', 'anthropic/claude-opus-4-6', 'deepseek/deepseek-r1'],\n image: ['openai/gpt-image-1', 'google/imagen-4.0-generate-001'],\n video: [],\n },\n openai_compatible: {\n text: [],\n image: [],\n video: [],\n },\n xai: {\n text: ['grok-4-fast', 'grok-4'],\n image: ['grok-2-image'],\n video: ['grok-imagine-video', 'grok-imagine-video-1.5'],\n },\n};\n\nexport interface ApiKeyValidationResult {\n ok: boolean;\n message?: string;\n endpointFlavor?: FetchModelsResult['endpointFlavor'];\n capabilitySummary?: AICapability[];\n effectiveBaseUrl?: string;\n models?: FetchModelsResult['models'];\n}\n\ntype CustomProviderExtraConfig = Record<string, string> | undefined;\n\nexport type VideoAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '3:2' | '2:3' | '4:5' | '5:4' | '1:4' | '4:1' | '1:8' | '8:1' | '21:9' | '9:21' | 'adaptive';\n\nconst DEFAULT_SEEDANCE_MODEL = 'doubao-seedance-2.0';\nconst SEEDANCE_RESOLUTIONS = ['480p', '720p', '1080p'] as const;\nconst SEEDANCE_RATIOS: VideoAspectRatio[] = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9', 'adaptive'];\nconst SEEDANCE_REFERENCE_LIMITS: Record<MultimodalSlotKind, number> = {\n image: 9,\n video: 3,\n audio: 3,\n};\n\nexport type CapabilityDictionary = {\n multimodalSlots: Partial<Record<MultimodalSlotKind, {\n max: number;\n roles: string[];\n }>>;\n requestParams: string[];\n aspectRatios?: VideoAspectRatio[];\n resolutions?: string[];\n durations?: number[];\n defaults?: Record<string, unknown>;\n endpoint?: 'images/generations' | 'images/edits' | 'contents/generations/tasks';\n responseFormat?: 'b64_json' | 'url' | 'implicit';\n outputFormat?: 'png' | 'jpeg' | 'webp';\n};\n\nconst OPENAI_GPT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 16, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'quality', 'background', 'moderation', 'output_format', 'output_compression'],\n defaults: {\n size: '1024x1024',\n quality: 'auto',\n output_format: 'png',\n },\n aspectRatios: ['1:1', '3:2', '2:3', '16:9', '9:16'],\n endpoint: 'images/generations',\n responseFormat: 'implicit',\n outputFormat: 'png',\n};\n\nconst DEFAULT_VIDEO_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 1, roles: ['first_frame', 'reference_image', 'unassigned'] },\n },\n requestParams: ['ratio'],\n aspectRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'],\n};\n\nconst DEFAULT_IMAGE_RATIOS: VideoAspectRatio[] = ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4'];\nconst OPENAI_IMAGE_SIZES: Record<'1K' | '2K' | '4K', Record<string, string>> = {\n '1K': { '1:1': '1024x1024', '3:2': '1536x1024', '2:3': '1024x1536', '16:9': '1792x1024', '9:16': '1024x1792', '4:3': '1344x1008', '3:4': '1008x1344' },\n '2K': { '1:1': '2048x2048', '3:2': '2048x1360', '2:3': '1360x2048', '16:9': '2048x1152', '9:16': '1152x2048', '4:3': '2048x1536', '3:4': '1536x2048' },\n '4K': { '1:1': '2880x2880', '3:2': '3520x2352', '2:3': '2352x3520', '16:9': '3840x2160', '9:16': '2160x3840', '4:3': '3312x2480', '3:4': '2480x3312' },\n};\n\nfunction getOpenAIImageSize(resolution?: string, aspectRatio?: VideoAspectRatio): string {\n const tier = resolution?.toUpperCase() === '4K' ? '4K' : resolution?.toUpperCase() === '2K' ? '2K' : '1K';\n return OPENAI_IMAGE_SIZES[tier][aspectRatio || '1:1'] || OPENAI_IMAGE_SIZES[tier]['1:1'];\n}\n\nconst DEFAULT_IMAGE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 8, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n },\n requestParams: ['size', 'aspect_ratio', 'ratio'],\n aspectRatios: [...DEFAULT_IMAGE_RATIOS],\n};\n\nconst SEEDANCE_CAPABILITY: CapabilityDictionary = {\n multimodalSlots: {\n image: { max: 9, roles: ['reference_image', 'first_frame', 'last_frame', 'style_ref', 'unassigned'] },\n video: { max: 3, roles: ['reference_video', 'unassigned'] },\n audio: { max: 3, roles: ['reference_audio', 'unassigned'] },\n },\n requestParams: [\n 'ratio',\n 'duration',\n 'resolution',\n 'frames',\n 'seed',\n 'camera_fixed',\n 'watermark',\n 'return_last_frame',\n 'generate_audio',\n 'service_tier',\n 'safety_identifier',\n ],\n aspectRatios: SEEDANCE_RATIOS,\n resolutions: [...SEEDANCE_RESOLUTIONS],\n durations: [-1, 4, 5, 6, 8, 10, 12, 15],\n defaults: {\n generate_audio: true,\n duration: 5,\n watermark: false,\n },\n endpoint: 'contents/generations/tasks',\n};\n\nexport function getCapabilityDictionary(model: string, provider: AIProvider = inferProviderFromModel(model)): CapabilityDictionary {\n const normalized = normalizeModelName(model);\n if (provider === 'volcengine' || normalized.includes('seedance') || isRunningHubSeedance20Model(normalized)) {\n return SEEDANCE_CAPABILITY;\n }\n if (provider === 'runningHub' && (inferCapabilityFromModelName(model) === 'video' || isRunningHubVideoEndpoint(normalized))) {\n return { ...DEFAULT_VIDEO_CAPABILITY, durations: resolveRunningHubVideoDurations(normalized) };\n }\n if (provider === 'openrouter' && inferCapabilityFromModelName(model) === 'image') {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if ((provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') && isOpenAIImageEditModel(model)) {\n return OPENAI_GPT_IMAGE_CAPABILITY;\n }\n if (inferCapabilityFromModelName(model) === 'image') {\n return DEFAULT_IMAGE_CAPABILITY;\n }\n return DEFAULT_VIDEO_CAPABILITY;\n}\n\nexport function getSupportedDurations(model: string, key?: UserApiKey): number[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.durations ?? [];\n}\n\nexport function getSupportedImageRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n const capability = getCapabilityDictionary(model, provider);\n return capability?.aspectRatios ?? DEFAULT_IMAGE_RATIOS;\n}\n\nexport { DEFAULT_IMAGE_RATIOS };\n\nfunction normalizeMultimodalRole(slot: MultimodalSlot): string {\n if (slot.kind === 'image') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_image';\n if (slot.kind === 'video') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_video';\n if (slot.kind === 'audio') return slot.role && slot.role !== 'unassigned' ? String(slot.role) : 'reference_audio';\n return 'unassigned';\n}\n\nfunction filterMultimodalSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): MultimodalSlot[] {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n return slots.filter((slot) => {\n const slotCapability = capability.multimodalSlots[slot.kind];\n if (!slotCapability || !slot.href) return false;\n const count = counts[slot.kind] || 0;\n if (count >= slotCapability.max) return false;\n const role = normalizeMultimodalRole(slot);\n if (\n slotCapability.roles.length > 0\n && !slotCapability.roles.includes(role)\n && !slotCapability.roles.includes(String(slot.role || ''))\n ) {\n return false;\n }\n counts[slot.kind] = count + 1;\n return true;\n });\n}\n\nfunction isSeedanceFastModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return normalized.includes('seedance') && normalized.includes('fast');\n}\n\nfunction normalizeSeedanceResolution(value: string | undefined, model = ''): string {\n const token = String(value || '720p').trim().toLowerCase();\n const normalized = token === 'low'\n ? '480p'\n : token === 'auto' || token === 'medium' || token === 'high'\n ? '720p'\n : `${(token.replace(/p$/i, '') || '720')}p`;\n\n if (isSeedanceFastModel(model) && normalized === '1080p') return '720p';\n return (SEEDANCE_RESOLUTIONS as readonly string[]).includes(normalized) ? normalized : '720p';\n}\n\nfunction normalizeSeedanceDuration(value: number | string | undefined): number {\n if (String(value ?? '').trim() === '-1') return -1;\n const seconds = Math.floor(Number(value) || 5);\n return Math.max(4, Math.min(15, seconds));\n}\n\nfunction normalizeSeedanceRatio(value: string | undefined): VideoAspectRatio {\n const raw = String(value || '').trim();\n if (!raw || raw === 'auto' || raw === 'adaptive') return 'adaptive';\n if ((SEEDANCE_RATIOS as string[]).includes(raw)) return raw as VideoAspectRatio;\n\n const match = raw.match(/^(\\d+)x(\\d+)$/);\n if (!match) return 'adaptive';\n const width = Number(match[1]);\n const height = Number(match[2]);\n if (!width || !height) return 'adaptive';\n\n const ratio = width / height;\n const options: Array<[Exclude<VideoAspectRatio, 'adaptive'>, number]> = [\n ['16:9', 16 / 9],\n ['4:3', 4 / 3],\n ['1:1', 1],\n ['3:4', 3 / 4],\n ['9:16', 9 / 16],\n ['21:9', 21 / 9],\n ];\n return options.reduce((best, item) => (\n Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best\n ), options[0])[0];\n}\n\nfunction validateSeedanceSlots(slots: MultimodalSlot[], capability: CapabilityDictionary): void {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n for (const slot of slots) {\n if (!slot.href) continue;\n counts[slot.kind] = (counts[slot.kind] || 0) + 1;\n }\n\n for (const kind of Object.keys(SEEDANCE_REFERENCE_LIMITS) as MultimodalSlotKind[]) {\n const count = counts[kind] || 0;\n const max = capability.multimodalSlots[kind]?.max ?? SEEDANCE_REFERENCE_LIMITS[kind];\n if (count > max) {\n const label = kind === 'image' ? '图片' : kind === 'video' ? '视频' : '音频';\n throw new Error(`Seedance 参考${label}最多支持 ${max} 个,当前传入 ${count} 个。`);\n }\n }\n\n if ((counts.audio || 0) > 0 && (counts.image || 0) === 0 && (counts.video || 0) === 0) {\n throw new Error('Seedance 参考音频不能单独使用,请同时添加参考图或参考视频。');\n }\n}\n\nasync function resolveSeedanceSlotUrls(slots: MultimodalSlot[]): Promise<MultimodalSlot[]> {\n return Promise.all(slots.map(async slot => {\n const href = slot.href?.trim() || '';\n if (!href) return slot;\n if (/^https?:\\/\\//i.test(href) || href.startsWith('asset://')) return slot;\n if (slot.kind === 'video' || slot.kind === 'audio') {\n throw new Error(`Seedance 参考${slot.kind === 'video' ? '视频' : '音频'}必须使用公网 URL 或 asset:// 素材 ID`);\n }\n if (href.startsWith('data:')) return slot;\n if (href.startsWith('blob:')) {\n const dataUrl = await blobToDataUrl(href);\n return { ...slot, href: dataUrl };\n }\n throw new Error(`Seedance 参考${slot.kind === 'image' ? '图片' : slot.kind === 'video' ? '视频' : '音频'}地址无法解析,请使用公网 URL、asset:// 素材或本地工作流节点。`);\n }));\n}\n\nfunction withSupportedParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const allowed = new Set(capability.requestParams);\n const output: Record<string, unknown> = {};\n for (const [key, value] of Object.entries({ ...(capability.defaults || {}), ...input })) {\n if (!allowed.has(key)) continue;\n if (value === undefined || value === null || value === '') continue;\n output[key] = value;\n }\n return output;\n}\n\n/**\n * Provider → supported video aspect ratios.\n * If a provider is not listed, all 6 ratios are assumed to pass through as-is (custom/openrouter).\n */\nexport const PROVIDER_VIDEO_RATIOS: Partial<Record<AIProvider, VideoAspectRatio[]>> = {\n google: ['16:9', '9:16'], // Veo 3.1 documented ratios\n minimax: ['16:9', '9:16', '1:1'], // MiniMax only supports 16:9/9:16/1:1\n keling: ['16:9', '9:16', '1:1'], // Kling AI: 16:9/9:16/1:1\n volcengine: SEEDANCE_RATIOS, // Seedance 2.0\n xai: ['1:1', '16:9', '9:16', '4:3', '3:4', '2:3', '3:2'], // Grok Imagine Video\n};\n\n/** Check whether a given ratio is supported by the inferred video provider. */\nexport function isRatioSupportedByProvider(ratio: VideoAspectRatio, model: string, key?: UserApiKey): boolean {\n const provider = resolveGenerationProvider(model, key);\n const allowed = PROVIDER_VIDEO_RATIOS[provider];\n if (!allowed) return true; // unknown / custom provider → allow all\n return allowed.includes(ratio);\n}\n\n/** Return the list of supported ratios for a given video model. */\nexport function getSupportedRatios(model: string, key?: UserApiKey): VideoAspectRatio[] {\n const provider = resolveGenerationProvider(model, key);\n return PROVIDER_VIDEO_RATIOS[provider] ?? ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];\n}\n\n/**\n * 通用 API Key 验证 — 根据 provider 调用对应的验证逻辑\n */\nexport async function validateApiKey(provider: AIProvider, apiKey: string, baseUrl?: string, extraConfig?: CustomProviderExtraConfig): Promise<ApiKeyValidationResult> {\n const normalizedInputBaseUrl = baseUrl ? normalizeProviderBaseUrl(provider, baseUrl) : undefined;\n\n if (provider === 'custom' && extraConfig?.requestFormat === 'anthropic') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || '').replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, { id: 'validation', provider, capabilities: ['text'], key: apiKey, extraConfig, createdAt: 0, updatedAt: 0 }, { anthropic: true }),\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'], effectiveBaseUrl: url };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n if (provider === 'google') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // OpenAI-compatible: 不仅检查鉴权,还拿到能力摘要和协议类型\n if (provider === 'openai' || provider === 'qwen' || provider === 'deepseek' || provider === 'siliconflow' || provider === 'minimax' || provider === 'volcengine' || provider === 'openrouter' || provider === 'openai_compatible' || provider === 'custom') {\n const result = await fetchModelsForProvider(provider, apiKey, baseUrl);\n if (!result.ok) return { ok: false, message: result.error };\n return {\n ok: true,\n message: result.capabilitySummary?.length\n ? `已验证,可用能力:${result.capabilitySummary.join(' / ')}${result.effectiveBaseUrl && result.effectiveBaseUrl !== normalizedInputBaseUrl ? `,已自动识别 API 根:${result.effectiveBaseUrl}` : ''}`\n : '已验证,但端点未返回模型列表',\n endpointFlavor: result.endpointFlavor,\n capabilitySummary: result.capabilitySummary,\n effectiveBaseUrl: result.effectiveBaseUrl,\n models: result.models,\n };\n }\n\n // Anthropic: 调用 /messages 会返回 401 如果 key 无效\n if (provider === 'anthropic') {\n try {\n const url = (baseUrl || DEFAULT_BASE_URLS.anthropic).replace(/\\/$/, '');\n const res = await fetch(`${url}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),\n });\n if (res.ok || res.status === 200) return { ok: true };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, capabilitySummary: ['text'] }; // 其他错误可能是模型不存在,但 key 是对的\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // Keling / Flux / Midjourney: OpenAI-compatible 验证\n if (provider === 'keling' || provider === 'flux' || provider === 'midjourney') {\n try {\n const url = normalizeProviderBaseUrl(provider, baseUrl || DEFAULT_BASE_URLS[provider]);\n const res = await fetch(`${url}/models`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n if (res.ok) return { ok: true, capabilitySummary: inferCapabilitiesByProvider(provider) };\n if (res.status === 401 || res.status === 403) return { ok: false, message: 'API Key 无效或权限不足' };\n return { ok: true, message: '已保存(无法确认在线状态,但格式正确)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // RunningHub: 32位 hex key 验证\n if (provider === 'runningHub') {\n try {\n const result = await fetchModelsForProvider(provider, apiKey, normalizedInputBaseUrl);\n if (!result.ok) return { ok: false, message: result.error || 'API Key 无效或权限不足' };\n return {\n ok: true,\n message: result.models.length > 0\n ? `RunningHub Key 已验证,已获取 ${result.models.length} 个标准模型。`\n : 'RunningHub Key 已验证,可用于标准模型生成。',\n capabilitySummary: result.capabilitySummary || ['image', 'video'],\n effectiveBaseUrl: result.effectiveBaseUrl || normalizedInputBaseUrl || DEFAULT_BASE_URLS.runningHub,\n models: result.models,\n };\n } catch (err) {\n return { ok: false, message: err instanceof Error ? err.message : '网络错误' };\n }\n }\n\n // 其他 provider: 简单格式校验\n if (apiKey.length < 10) return { ok: false, message: 'API Key 太短' };\n return { ok: true, message: '已保存(格式校验通过,未做在线验证)', capabilitySummary: inferCapabilitiesByProvider(provider) };\n}\n\nconst DEFAULT_BASE_URLS: Record<AIProvider, string> = {\n openai: 'https://api.openai.com/v1',\n anthropic: 'https://api.anthropic.com/v1',\n google: 'https://generativelanguage.googleapis.com/v1beta',\n qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n deepseek: 'https://api.deepseek.com/v1',\n siliconflow: 'https://api.siliconflow.cn/v1',\n keling: 'https://api.klingai.com/v1',\n flux: 'https://api.bfl.ml/v1',\n midjourney: 'https://api.midjourney.com/v1',\n runningHub: 'https://www.runninghub.cn/openapi/v2',\n minimax: 'https://api.minimax.chat/v1',\n volcengine: 'https://ark.cn-beijing.volces.com/api/v3',\n openrouter: 'https://openrouter.ai/api/v1',\n openai_compatible: '',\n xai: 'https://api.x.ai/v1',\n custom: '',\n};\n\n/**\n * 根据 API Key 格式自动推断 Provider(用于粘贴时自动识别)\n */\nexport function inferProviderFromKey(apiKey: string): AIProvider | null {\n const trimmed = apiKey.trim();\n if (/^AIzaSy/i.test(trimmed)) return 'google';\n if (/^sk-ant-/i.test(trimmed)) return 'anthropic';\n if (/^sk-or-/i.test(trimmed)) return 'openrouter';\n if (/^sk-proj-/i.test(trimmed) || /^sk-[a-zA-Z0-9]{20,}$/.test(trimmed)) return 'openai';\n if (/^sk-[a-f0-9]{32,}$/i.test(trimmed)) return 'deepseek';\n // Stability AI removed — sa- prefix keys no longer auto-detected\n if (/^sk-sf/i.test(trimmed)) return 'siliconflow';\n if (/^xai-/i.test(trimmed)) return 'xai';\n if (/^eyJ/i.test(trimmed)) return 'minimax'; // MiniMax keys start with eyJ (JWT-like)\n if (/^[a-f0-9]{32}$/i.test(trimmed)) return 'runningHub'; // 32-char hex\n return null;\n}\n\n/**\n * Provider 默认 capabilities 推断\n */\nexport function inferCapabilitiesByProvider(provider: AIProvider): import('../types').AICapability[] {\n if (provider === 'runningHub') return ['image', 'video'];\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return ['text', 'image'];\n const result: import('../types').AICapability[] = [];\n if (caps.text?.length) result.push('text');\n if (caps.image?.length) result.push('image');\n if (caps.video?.length) result.push('video');\n return result.length ? result : ['text'];\n}\n\n/** Provider 可读标签 */\nexport const PROVIDER_LABELS: Record<AIProvider, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek 深度求索',\n siliconflow: 'SiliconFlow 硅基流动',\n keling: 'Keling 可灵',\n flux: 'Flux (BFL)',\n midjourney: 'Midjourney',\n runningHub: 'RunningHub',\n minimax: 'MiniMax',\n volcengine: '火山引擎 (豆包)',\n openrouter: 'OpenRouter',\n openai_compatible: 'OpenAI Compatible',\n xai: 'xAI Grok',\n custom: '自定义',\n};\n\nfunction getBaseUrl(provider: AIProvider, key?: UserApiKey) {\n return normalizeProviderBaseUrl(provider, key?.baseUrl || DEFAULT_BASE_URLS[provider]);\n}\n\nfunction requireApiKey(provider: AIProvider, key?: UserApiKey) {\n if (!key?.key) {\n throw new Error(`未配置 ${provider} 的 API Key。请先在设置中保存。`);\n }\n return key.key;\n}\n\nfunction parseModelMappings(config: CustomProviderExtraConfig): Record<string, string> {\n const raw = config?.modelMappingsJson;\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};\n } catch {\n return {};\n }\n}\n\nfunction stripModelSelectionRef(model: string): string {\n const index = model.indexOf('::');\n return index >= 0 ? model.slice(index + 2) : model;\n}\n\nfunction assertResolvedUpstreamModel(model: string): void {\n if (stripModelSelectionRef(model).trim().toLowerCase().startsWith('flovart:')) {\n throw new Error(`产品模型 ${model} 尚未解析为供应商上游模型。请检查该产品模型的 BYOK 映射。`);\n }\n}\n\nfunction mapProviderModel(model: string, key?: UserApiKey): string {\n const bareModel = stripModelSelectionRef(model);\n return parseModelMappings(key?.extraConfig)[bareModel] || bareModel;\n}\n\nfunction buildProviderHeaders(\n apiKey: string,\n key?: UserApiKey,\n options: { contentType?: boolean; anthropic?: boolean } = { contentType: true },\n): Record<string, string> {\n const headers: Record<string, string> = {};\n if (options.contentType !== false) headers['Content-Type'] = 'application/json';\n\n const headerName = key?.extraConfig?.authHeaderName || 'Authorization';\n const authScheme = key?.extraConfig?.authScheme ?? 'Bearer';\n headers[headerName] = authScheme === '' ? apiKey : `${authScheme || 'Bearer'} ${apiKey}`;\n\n if (options.anthropic) {\n headers['anthropic-version'] = '2023-06-01';\n }\n return headers;\n}\n\nfunction usesAnthropicRequestFormat(key?: UserApiKey): boolean {\n return key?.provider === 'custom' && key.extraConfig?.requestFormat === 'anthropic';\n}\n\nfunction normalizeModelName(model: string): string {\n return stripModelSelectionRef(model).trim().toLowerCase();\n}\n\nfunction stripModelProviderPrefix(model: string): string {\n const normalized = normalizeModelName(model);\n const parts = normalized.split('/');\n return parts.length > 1 ? parts.slice(1).join('/') : normalized;\n}\n\nexport function inferCapabilityFromModel(model: string): AICapability | undefined {\n const normalized = stripModelProviderPrefix(model);\n if (!normalized) return undefined;\n if (normalized.includes('seedance')) return 'video';\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|gemini-omni|happyhorse|ltx|pixverse|skyreels)/.test(normalized)) return 'video';\n if (/(^|[-_/])(text|image|reference|start|end|multimodal)?-?to-video|video-edit|edit-video|motion-control|video-extend/.test(normalized)) return 'video';\n if (/^(rhart-image|runninghub-image|imagen|dall-e|gpt-image|flux|stable-diffusion|sdxl|midjourney|recraft|ideogram|qwen-image|seedream|seededit|nano-banana|jimeng|doubao-image|omni-image|grok-image|f-|z-image|xai\\/rhart|xai\\/grok-imagine-image)/.test(normalized)) return 'image';\n if (/(^|[-_/])(text|image)-to-image|image-edit|edit-channel|edit-official|\\/edit(?:-|$)/.test(normalized)) return 'image';\n if (/^gemini/.test(normalized)) return normalized.includes('image') ? 'image' : 'text';\n if (/^(gpt|o\\d|claude|qwen|deepseek|llama|command|mistral|doubao|abab|minimax)/.test(normalized)) return 'text';\n return undefined;\n}\n\nexport function inferCapabilityFromModelName(modelName: string): ElementMediaCapability {\n const stripped = stripModelProviderPrefix(modelName);\n const normalized = stripped.includes('/') ? stripped.split('/').pop() || stripped : stripped;\n\n if (normalized.includes('seedance')) {\n return 'video';\n }\n\n if (/^(veo([-.\\d]|$)|video|wan|seedance|vidu|pika|runway|higgsfield|luma|kling|keling|sora|sdols|hailuo|qwen-video|liveportrait|videoretalk|emo|cogvideo|hunyuan-video)/.test(normalized)) {\n return 'video';\n }\n\n if (normalized.includes('video') || normalized.includes('movie') || normalized.includes('motion-control')) {\n return 'video';\n }\n\n return 'image';\n}\n\nexport function getDynamicParamSchema(modelName: string): ModelParamSchema {\n const capability = inferCapabilityFromModelName(modelName);\n const normalized = normalizeModelName(modelName);\n\n if (capability === 'video') {\n return {\n hasSeed: true,\n hasCfgScale: false,\n hasAspectRatio: true,\n defaultAspectRatio: '16:9',\n };\n }\n\n return {\n hasSeed: true,\n hasCfgScale: !normalized.includes('flux'),\n hasAspectRatio: false,\n };\n}\n\nfunction getImageReferencesForIgnition(references: IgnitionReference[] = []): VideoImage[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image'; href: string; mimeType: string } => (\n reference.type === 'image' && typeof reference.href === 'string' && reference.href.length > 0\n ))\n .map(reference => ({\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n slotRole: reference.slotRole,\n label: reference.label,\n sourceName: reference.sourceName,\n elementId: reference.elementId,\n }));\n}\n\nfunction getMultimodalSlotsForIgnition(references: IgnitionReference[] = []): MultimodalSlot[] {\n return references\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio'; href: string; mimeType: string } => (\n (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n && typeof reference.href === 'string'\n && reference.href.length > 0\n ))\n .map(reference => ({\n kind: reference.type,\n href: reference.href,\n mimeType: reference.mimeType || (reference.type === 'audio' ? 'audio/mpeg' : reference.type === 'video' ? 'video/mp4' : 'image/png'),\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction multimodalSlotsFromLegacyReferences(references: VideoImage[] = []): MultimodalSlot[] {\n return references.map(reference => ({\n kind: 'image',\n href: reference.href,\n mimeType: reference.mimeType || 'image/png',\n role: reference.slotRole,\n label: reference.label || reference.sourceName,\n }));\n}\n\nfunction adaptVideoSlotsForMode(slots: MultimodalSlot[], mode?: ProductModelMode, provider?: AIProvider, routeId?: string): MultimodalSlot[] {\n if (!mode) return slots;\n const images = slots.filter(slot => slot.kind === 'image');\n if (mode === 'text-to-video') return [];\n if (mode === 'image-to-video') {\n if (!images[0]) throw new Error('图生视频至少需要 1 张图片。');\n const routeImageSpec = provider === 'runningHub'\n ? getRouteSchema(routeId || '')?.media.find(spec => spec.kind === 'image' && spec.mapping === 'all_references')\n : undefined;\n if (routeImageSpec) return images.slice(0, routeImageSpec.max).map((image, index) => ({ ...image, role: index === 0 ? 'first_frame' : 'reference_image' }));\n return [{ ...images[0], role: 'first_frame' }];\n }\n if (mode === 'first-last-frame') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配首尾帧,请切换模式或重新映射线路。');\n if (images.length < 2) throw new Error('首尾帧模式需要 2 张图片。');\n return [{ ...images[0], role: 'first_frame' }, { ...images[1], role: 'last_frame' }];\n }\n if (mode === 'reference-to-video') {\n if (provider === 'keling') throw new Error('当前可灵 API 线路尚未适配全能参考,请切换模式或重新映射线路。');\n const references = provider === 'google' ? images.slice(0, 3) : slots;\n if (!references.length) throw new Error(provider === 'google' ? 'Veo 全能参考至少需要 1 张图片。' : '全能参考至少需要 1 个媒体素材。');\n return references.map(slot => ({\n ...slot,\n role: slot.kind === 'video' ? 'reference_video' : slot.kind === 'audio' ? 'reference_audio' : 'reference_image',\n }));\n }\n return slots;\n}\n\nfunction referenceOrdinalLabel(kind: MultimodalSlotKind | 'text' | 'shape', index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n if (kind === 'audio') return `音频${index + 1}`;\n if (kind === 'text') return `文本${index + 1}`;\n return `形状${index + 1}`;\n}\n\nfunction normalizeMentionLabel(label?: string): string {\n const trimmed = label?.trim();\n if (!trimmed) return '';\n return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;\n}\n\nfunction trimReferenceText(value?: string): string {\n const normalized = value?.replace(/\\s+/g, ' ').trim() || '';\n if (normalized.length <= 500) return normalized;\n return `${normalized.slice(0, 500)}...`;\n}\n\nfunction buildPromptWithReferenceBindings(prompt: string, references: IgnitionReference[] = []): string {\n const text = prompt.trim();\n const counts: Record<MultimodalSlotKind | 'text' | 'shape', number> = {\n image: 0,\n video: 0,\n audio: 0,\n text: 0,\n shape: 0,\n };\n const lines: string[] = [];\n\n for (const reference of references) {\n if (reference.type === 'image' || reference.type === 'video' || reference.type === 'audio') {\n if (!reference.href) continue;\n }\n const index = counts[reference.type]++;\n const ordinal = referenceOrdinalLabel(reference.type, index);\n const mention = normalizeMentionLabel(reference.label || reference.sourceName) || `@${ordinal}`;\n const nameNote = reference.sourceName && reference.sourceName !== mention.replace(/^@/, '')\n ? `(节点: ${reference.sourceName})`\n : '';\n if (reference.type === 'text' || reference.type === 'shape') {\n const referenceText = trimReferenceText(reference.text);\n lines.push(referenceText\n ? `${ordinal} = ${mention}${nameNote}: ${referenceText}`\n : `${ordinal} = ${mention}${nameNote}`);\n } else {\n const role = reference.slotRole && reference.slotRole !== 'unassigned' ? `,slot=${reference.slotRole}` : '';\n lines.push(`${ordinal} = ${mention}${nameNote}${role}`);\n }\n }\n\n if (lines.length === 0) return text;\n return [\n '引用绑定(请严格按这些绑定理解提示词中的 @名称,尤其不要混淆不同角色、主体、首尾帧或风格参考):',\n ...lines.map(line => `- ${line}`),\n '',\n '用户提示词:',\n text,\n ].join('\\n');\n}\n\nfunction parseTextResponseContent(json: any): string {\n const anthropicContent = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n').trim()\n : '';\n if (anthropicContent) return anthropicContent;\n\n const openAIContent = json?.choices?.[0]?.message?.content;\n if (typeof openAIContent === 'string') return openAIContent.trim();\n\n if (typeof json?.text === 'string') return json.text.trim();\n return '';\n}\n\nexport async function generateTextWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n systemPrompt?: string;\n temperature?: number;\n maxTokens?: number;\n signal?: AbortSignal;\n },\n): Promise<string> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n\n if (provider === 'google') {\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const response = await fetch(`${googleBase}/models/${encodeURIComponent(mappedModel)}:generateContent?key=${encodeURIComponent(apiKey)}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: options?.signal,\n body: JSON.stringify({\n systemInstruction: options?.systemPrompt ? { parts: [{ text: options.systemPrompt }] } : undefined,\n contents: [{ role: 'user', parts: [{ text: prompt }] }],\n generationConfig: { temperature: options?.temperature ?? 0.7, maxOutputTokens: options?.maxTokens ?? 4096 },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Google LLM 请求失败'));\n const json = await readJsonResponse<any>(response, 'Google LLM 响应');\n return json?.candidates?.[0]?.content?.parts?.map((part: { text?: string }) => part.text || '').join('\\n').trim() || '';\n }\n\n if (provider === 'anthropic' || usesAnthropicRequestFormat(key)) {\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n max_tokens: options?.maxTokens ?? 4096,\n system: options?.systemPrompt,\n messages: [{ role: 'user', content: prompt }],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, 'Anthropic LLM 请求失败'));\n return parseTextResponseContent(await readJsonResponse<any>(response, 'Anthropic LLM 响应'));\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: provider === 'openrouter' ? buildOpenRouterHeaders(apiKey) : buildProviderHeaders(apiKey, key),\n signal: options?.signal,\n body: JSON.stringify({\n model: mappedModel,\n temperature: options?.temperature ?? 0.7,\n max_tokens: options?.maxTokens ?? 4096,\n messages: [\n ...(options?.systemPrompt ? [{ role: 'system', content: options.systemPrompt }] : []),\n { role: 'user', content: prompt },\n ],\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} LLM 请求失败`));\n return parseTextResponseContent(await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} LLM 响应`));\n}\n\nexport function isGoogleImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model);\n return inferProviderFromModel(model) === 'google' && /^gemini/.test(normalized) && normalized.includes('image');\n}\n\nexport function isGoogleTextToImageModel(model: string): boolean {\n return inferProviderFromModel(model) === 'google' && /^imagen/.test(normalizeModelName(model));\n}\n\nfunction isOpenAIImageEditModel(model: string): boolean {\n const normalized = normalizeModelName(model).replace(/^openai\\//, '');\n return /^(gpt-image-2|gpt-image-1\\.5|gpt-image-1(?:-mini)?|gpt-image-1)$/.test(normalized);\n}\n\nconst GPT_IMAGE_INPUT_IMAGE_LIMIT = 16;\n\nfunction limitProviderImageInputs<T>(images: T[], provider: AIProvider, model: string, key?: UserApiKey): T[] {\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const maxImages = capability.multimodalSlots.image?.max;\n if (maxImages) {\n return images.slice(0, maxImages);\n }\n return images;\n}\n\nexport function supportsReferenceImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n return true;\n}\n\nexport function supportsMaskImageEditing(model: string): boolean {\n const provider = inferProviderFromModel(model);\n if (provider === 'google') return isGoogleImageEditModel(model);\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') return isOpenAIImageEditModel(model);\n return false;\n}\n\nfunction parseDataUrl(dataUrl: string, fallbackMimeType = 'image/png') {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (match) {\n return { mimeType: match[1], base64: match[2] };\n }\n\n const parts = dataUrl.split(',');\n return {\n mimeType: fallbackMimeType,\n base64: parts.length > 1 ? parts[1] : parts[0],\n };\n}\n\nfunction createBlobFromBase64(base64: string, mimeType: string) {\n const binary = globalThis.atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return new Blob([bytes], { type: mimeType });\n}\n\nfunction buildOpenAIImageRequestParams(\n capability: CapabilityDictionary,\n input: Record<string, unknown>,\n): Record<string, unknown> {\n const params = withSupportedParams(capability, input);\n if (capability.outputFormat && !params.output_format) {\n params.output_format = capability.outputFormat;\n }\n return params;\n}\n\nfunction appendOpenAIImageFormParams(formData: FormData, params: Record<string, unknown>) {\n for (const [key, value] of Object.entries(params)) {\n if (value === undefined || value === null || value === '') continue;\n formData.append(key, String(value));\n }\n}\n\nfunction decodeDataUrlImage(dataUrl: string) {\n const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);\n if (!match) {\n throw new Error('模型返回了无法识别的图片数据格式。');\n }\n\n return {\n newImageMimeType: match[1],\n newImageBase64: match[2],\n textResponse: null,\n };\n}\n\nfunction parseProviderJsonObject(raw: string | undefined, label: string): Record<string, unknown> {\n const trimmed = raw?.trim();\n if (!trimmed) return {};\n try {\n const parsed = JSON.parse(trimmed);\n if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error(`${label} 必须是 JSON 对象。`);\n }\n return parsed as Record<string, unknown>;\n } catch (error) {\n if (error instanceof Error && error.message.includes(label)) throw error;\n throw new Error(`${label} 不是有效 JSON,请检查逗号和引号。`);\n }\n}\n\nfunction isRunningHubSeedance20Model(modelEndpoint: string) {\n return /(?:sparkvideo|seedance)-2\\.0/i.test(modelEndpoint);\n}\n\nfunction isRunningHubVideoEndpoint(modelEndpoint: string) {\n return /rhart-video[-\\/]|skyreels-v4|image-to-video|start-end-to-video|reference-to-video|multimodal-video/i.test(modelEndpoint);\n}\n\nfunction resolveRunningHubVideoDurations(model: string): number[] {\n const catalogDurations = getRouteDurations(model);\n return catalogDurations ?? [];\n}\n\nfunction assertRunningHubPublicUrl(href: string | undefined, fieldName: string) {\n const url = href?.trim() || '';\n if (!/^https?:\\/\\//i.test(url)) {\n throw new Error(`RunningHub ${fieldName} 需要公网 http(s) URL,当前为空或仍是本地引用。`);\n }\n return url;\n}\n\nfunction uniqueLimitedUrls(urls: string[], max: number) {\n return Array.from(new Set(urls)).slice(0, max);\n}\n\nasync function blobToDataUrl(href: string): Promise<string> {\n const res = await fetch(href);\n const blob = await res.blob();\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result || ''));\n reader.onerror = () => reject(reader.error || new Error('无法读取本地媒体'));\n reader.readAsDataURL(blob);\n });\n}\n\nasync function prepareRunningHubReferences(\n apiKey: string,\n references: VideoImage[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<VideoImage[]> {\n if (references.length === 0) {\n console.log('[RH Debug] prepareRunningHubReferences: 0 refs, skip');\n return [];\n }\n const needsUpload = references.some(ref => ref.href.startsWith('data:') || ref.href.startsWith('blob:'));\n console.log('[RH Debug] prepareRunningHubReferences', { count: references.length, needsUpload, hrefs: references.map(r => r.href.slice(0, 40)) });\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(references.map(async ref => {\n if (/^https?:\\/\\//i.test(ref.href)) return ref;\n if (ref.href.startsWith('data:') && uploadModule) {\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, ref.href, options), 'uploaded image URL') };\n }\n if (ref.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(ref.href);\n return { ...ref, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded image URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nasync function prepareRunningHubSlots(\n apiKey: string,\n slots: MultimodalSlot[] = [],\n options?: { baseUrl?: string; signal?: AbortSignal },\n): Promise<MultimodalSlot[]> {\n if (slots.length === 0) return [];\n const needsUpload = slots.some(slot => slot.href.startsWith('data:') || slot.href.startsWith('blob:'));\n const uploadModule = needsUpload ? await import('./runningHubService') : null;\n return Promise.all(slots.map(async slot => {\n if (/^https?:\\/\\//i.test(slot.href)) return slot;\n if (slot.href.startsWith('data:') && uploadModule) {\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, slot.href, options), 'uploaded slot URL') };\n }\n if (slot.href.startsWith('blob:') && uploadModule) {\n const dataUrl = await blobToDataUrl(slot.href);\n return { ...slot, href: assertRunningHubPublicUrl(await uploadModule.rhUploadDataUrl(apiKey, dataUrl, options), 'uploaded slot URL') };\n }\n throw new Error('RunningHub 标准模型的参考媒体需要公网 URL;本地 data URL 会自动上传,其他本地地址暂不支持。');\n }));\n}\n\nfunction buildRouteMediaFields(\n schema: RouteCapabilitySchema,\n references: VideoImage[],\n slots: MultimodalSlot[],\n): Record<string, unknown> {\n const output: Record<string, unknown> = {};\n for (const spec of schema.media) {\n if (spec.mapping === 'all_references') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图,请连接图片节点或拖入图片后再生成。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (spec.serialization === 'array') {\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (urls[0]) {\n output[spec.field] = assertRunningHubPublicUrl(urls[0], spec.field);\n }\n } else if (spec.mapping === 'first_last_frame') {\n const first = references.find(ref => ref.slotRole === 'first_frame') || references[0];\n const last = references.find(ref => ref.slotRole === 'last_frame') || references[1];\n if (spec.field === 'firstFrameUrl') {\n if (spec.required && !first?.href) {\n throw new Error(`RunningHub ${schema.routeId} 需要首帧图片。`);\n }\n if (first?.href) output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n } else if (spec.field === 'lastFrameUrl') {\n if (last?.href) output[spec.field] = assertRunningHubPublicUrl(last.href, spec.field);\n }\n } else if (spec.mapping === 'first_frame_only') {\n const first = references[0];\n if (first?.href) {\n output[spec.field] = assertRunningHubPublicUrl(first.href, spec.field);\n }\n } else if (spec.mapping === 'reference_images') {\n if (spec.required && references.length === 0) {\n throw new Error(`RunningHub ${schema.routeId} 需要至少一张参考图。`);\n }\n const urls = references.map((ref, i) => assertRunningHubPublicUrl(ref.href, `${spec.field}[${i}]`));\n if (urls.length > spec.max) console.warn(`[RunningHub] ${schema.routeId} ${spec.field} 仅支持最多 ${spec.max} 张,已截断 ${urls.length} → ${spec.max}`);\n output[spec.field] = uniqueLimitedUrls(urls, spec.max);\n } else if (spec.mapping === 'multimodal') {\n const imageUrls = [\n ...references.map(ref => ref.href),\n ...slots.filter(slot => slot.kind === 'image').map(slot => slot.href),\n ].map((href, i) => assertRunningHubPublicUrl(href, `${spec.field}[${i}]`));\n const videoUrls = slots.filter(slot => slot.kind === 'video').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n const audioUrls = slots.filter(slot => slot.kind === 'audio').map((slot, i) => assertRunningHubPublicUrl(slot.href, `${spec.field}[${i}]`));\n if (spec.kind === 'image' && imageUrls.length > 0) output[spec.field] = uniqueLimitedUrls(imageUrls, spec.max);\n if (spec.kind === 'video' && videoUrls.length > 0) output[spec.field] = uniqueLimitedUrls(videoUrls, spec.max);\n if (spec.kind === 'audio' && audioUrls.length > 0) output[spec.field] = uniqueLimitedUrls(audioUrls, spec.max);\n }\n }\n return output;\n}\n\nfunction snapDuration(requested: number, allowed: number[]): number {\n return allowed.reduce((best, v) => Math.abs(v - requested) < Math.abs(best - requested) ? v : best, allowed[0]);\n}\n\nfunction buildRunningHubRoutePayload(\n prompt: string,\n schema: RouteCapabilitySchema,\n key: UserApiKey | undefined,\n options: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const extra = key?.extraConfig || {};\n const disableDefaults = extra.runningHubDisableDefaultPayload === 'true';\n const configuredPayload = {\n ...parseProviderJsonObject(extra.configJson, 'RunningHub 配置 JSON'),\n ...parseProviderJsonObject(extra.runningHubDefaultPayloadJson, 'RunningHub 默认载荷 JSON'),\n };\n const promptField = extra.runningHubPromptField || schema.promptField;\n\n const payload: Record<string, unknown> = {};\n\n if (!disableDefaults) {\n if (schema.resolutionDefault) payload.resolution = schema.resolutionDefault;\n if (schema.durationDefault !== undefined) payload.duration = schema.durationDefault;\n if (schema.aspectRatioField && schema.aspectRatioValues?.[0]) payload[schema.aspectRatioField] = schema.aspectRatioValues[0];\n for (const param of schema.params) {\n if (param.default !== undefined) {\n if (param.type === 'string' && param.default === 'all') {\n payload[param.field] = ['all'];\n } else {\n payload[param.field] = param.default;\n }\n }\n }\n }\n\n Object.assign(payload, configuredPayload);\n\n payload[promptField] = prompt;\n\n if (schema.aspectRatioField && options?.aspectRatio) {\n payload[schema.aspectRatioField] = options.aspectRatio;\n }\n\n if (schema.durationType && options?.durationSec && options.durationSec > 0) {\n if (schema.durationValues && schema.durationValues.length > 0) {\n const allowed = schema.durationValues.map(v => Number(v)).filter(n => !isNaN(n));\n if (allowed.length > 0) {\n if (schema.durationType === 'string') {\n payload.duration = String(snapDuration(options.durationSec, allowed));\n } else {\n payload.duration = snapDuration(options.durationSec, allowed);\n }\n }\n } else if (schema.durationType === 'string') {\n payload.duration = String(options.durationSec);\n } else {\n payload.duration = options.durationSec;\n }\n }\n\n if (options?.resolution) {\n payload.resolution = options.resolution;\n }\n\n if (options?.userParams) {\n const schemaFields = new Set(schema.params.map(p => p.field));\n for (const [key, value] of Object.entries(options.userParams)) {\n if (value !== undefined && schemaFields.has(key)) {\n payload[key] = value;\n }\n }\n }\n\n Object.assign(payload, buildRouteMediaFields(schema, options?.references || [], options?.slots || []));\n\n if (extra.runningHubOutputFormatField && extra.runningHubOutputFormat) {\n payload[extra.runningHubOutputFormatField] = extra.runningHubOutputFormat;\n }\n\n return payload;\n}\n\nfunction buildRunningHubStandardPayload(\n prompt: string,\n modelEndpoint: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n durationSec?: number;\n resolution?: string;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n userParams?: Record<string, unknown>;\n },\n): Record<string, unknown> {\n const routeSchema = getRouteSchema(modelEndpoint);\n if (routeSchema) {\n return buildRunningHubRoutePayload(prompt, routeSchema, key, options || {});\n }\n throw new Error(`RunningHub 路由 ${modelEndpoint} 未通过验证(未在 Route Catalog 中登记),暂不可执行。请先在 Route Catalog 中补齐该路由的 Capability Schema 后再使用。`);\n}\n\nfunction extractRunningHubMediaUrl(\n result: { results?: Array<{ url?: string; outputType?: string; text?: string | null }> | null },\n kind: 'image' | 'video',\n) {\n const items = result.results?.filter(item => item.url) || [];\n if (items.length === 0) return null;\n const typed = items.find(item => {\n const hint = `${item.outputType || ''} ${item.url}`.toLowerCase();\n const isVideo = /\\.(mp4|mov|webm)(?:[?#].*)?$/.test(hint) || /\\b(mp4|mov|webm|video)\\b/.test(hint);\n return kind === 'video' ? isVideo : !isVideo;\n });\n return typed?.url || items[0].url || null;\n}\n\n/**\n * 下载远程图片 URL 并转为 base64\n */\nasync function fetchImageUrlToBase64(url: string): Promise<{ newImageBase64: string; newImageMimeType: string; textResponse: null }> {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 30_000);\n try {\n const res = await fetch(url, { signal: controller.signal });\n if (!res.ok) throw new Error(`下载图片失败 (${res.status}): ${url}`);\n const blob = await res.blob();\n const mimeType = blob.type || 'image/png';\n const arrayBuffer = await blob.arrayBuffer();\n const bytes = new Uint8Array(arrayBuffer);\n // 分块转换避免 call stack 溢出(大图片 >3MB 时 spread 会爆栈)\n const chunks: string[] = [];\n for (let i = 0; i < bytes.length; i += 8192) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));\n }\n return { newImageBase64: btoa(chunks.join('')), newImageMimeType: mimeType, textResponse: null };\n } finally {\n clearTimeout(timeout);\n }\n}\n\n/**\n * 统一解析 OpenAI/Custom 图片生成响应 — 兼容多种格式:\n * 1. 标准 /images/generations → data[0].b64_json (纯 base64)\n * 2. 代理/聚合端点返回 data:URL 在 b64_json 字段\n * 3. data[0].url 远程图片链接\n * 4. chat/completions 响应 → markdown 图片链接\n */\nasync function parseOpenAIImageResponse(json: any): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n // 尝试 /images/generations 标准格式\n const firstImage = json?.data?.[0];\n if (firstImage) {\n // b64_json 字段可能是纯 base64 或 data:URL\n if (firstImage.b64_json) {\n const dataUrlMatch = firstImage.b64_json.match(/^data:([^;]+);base64,(.+)$/);\n if (dataUrlMatch) {\n return { newImageBase64: dataUrlMatch[2], newImageMimeType: dataUrlMatch[1], textResponse: null };\n }\n return { newImageBase64: firstImage.b64_json, newImageMimeType: 'image/png', textResponse: null };\n }\n // url 字段\n if (firstImage.url) {\n if (firstImage.url.startsWith('data:')) return decodeDataUrlImage(firstImage.url);\n return fetchImageUrlToBase64(firstImage.url);\n }\n }\n\n // 尝试 chat/completions 格式 — 代理用 chat 接口生图\n const content = json?.choices?.[0]?.message?.content;\n if (typeof content === 'string') {\n // 提取 markdown 图片链接 \n const mdMatch = content.match(/!\\[.*?\\]\\((https?:\\/\\/[^\\s)]+)\\)/);\n if (mdMatch) return fetchImageUrlToBase64(mdMatch[1]);\n // 纯 URL\n const urlMatch = content.match(/(https?:\\/\\/\\S+\\.(?:png|jpg|jpeg|webp|gif))/i);\n if (urlMatch) return fetchImageUrlToBase64(urlMatch[1]);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: content || null };\n}\n\nfunction buildOpenRouterHeaders(apiKey: string) {\n return {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n 'HTTP-Referer': globalThis.location?.origin || 'https://flovart.app',\n 'X-OpenRouter-Title': 'Flovart',\n };\n}\n\nfunction truncateResponseSnippet(text: string, maxLength = 200) {\n const normalized = text.replace(/\\s+/g, ' ').trim();\n if (normalized.length <= maxLength) return normalized;\n return `${normalized.slice(0, maxLength)}...`;\n}\n\nfunction looksLikeHtmlResponse(text: string) {\n return /^\\s*<(?:!doctype|html|head|body)\\b/i.test(text);\n}\n\nasync function readJsonResponse<T>(response: Response, requestLabel: string): Promise<T> {\n const contentLength = Number(response.headers?.get?.('content-length') || 0);\n if (contentLength > 50 * 1024 * 1024) {\n throw new Error(`${requestLabel} 响应体过大 (${(contentLength / 1024 / 1024).toFixed(1)} MB),已跳过解析。`);\n }\n const text = await response.text().catch(() => '');\n if (!text) {\n const json = await response.json?.().catch(() => undefined);\n return (json ?? {}) as T;\n }\n\n if (looksLikeHtmlResponse(text)) {\n throw new Error(`${requestLabel} 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`);\n }\n\n try {\n return JSON.parse(text) as T;\n } catch {\n const contentType = response.headers?.get?.('Content-Type') || 'unknown';\n throw new Error(`${requestLabel} 返回了非 JSON 响应 (${contentType}):${truncateResponseSnippet(text)}`);\n }\n}\n\nasync function readErrorResponse(response: Response, requestLabel: string): Promise<string> {\n const text = await response.text().catch(() => '');\n if (!text) return `${requestLabel} (${response.status}): ${response.statusText}`;\n\n if (looksLikeHtmlResponse(text)) {\n return `${requestLabel} (${response.status}): 返回了 HTML 页面,请检查 Base URL 是否指向 API 接口而不是网站首页。`;\n }\n\n try {\n const json = JSON.parse(text);\n const detail = json?.error?.message || json?.message || json?.detail || json?.status_msg;\n if (detail) return `${requestLabel} (${response.status}): ${detail}`;\n } catch {\n // Fall back to plain text below.\n }\n\n return `${requestLabel} (${response.status}): ${truncateResponseSnippet(text)}`;\n}\n\nfunction resolveGenerationProvider(model: string, key?: UserApiKey): AIProvider {\n if (key?.provider === 'custom') {\n const endpointFlavor = key.extraConfig?.endpointFlavor;\n if (endpointFlavor === 'openrouter-compatible') return 'openrouter';\n // 所有 custom key 统一走 OpenAI-compatible 路径,\n // 不再 fallthrough 到 inferProviderFromModel(否则 gemini-xxx 会误路由到 Google SDK)\n return 'custom';\n }\n if (key?.provider) return key.provider;\n return inferProviderFromModel(model);\n}\n\nfunction inferPromptModeHint(request: PromptEnhanceRequest) {\n const modeHintMap: Record<PromptEnhanceRequest['mode'], string> = {\n smart: 'Do intelligent enhancement with richer cinematic details, composition, and lighting.',\n style: `Rewrite with strong style intent. Preferred style preset: ${request.stylePreset || 'cinematic'}.`,\n precise: 'Preserve user intent strictly; only optimize clarity and structure.',\n translate: 'Translate and optimize prompt for model friendliness while preserving semantics.',\n };\n\n return [\n 'You are a professional prompt engineer for image and video generation.',\n 'Return ONLY valid JSON with keys: enhancedPrompt, negativePrompt, suggestions, notes.',\n 'Keep enhancedPrompt concise but vivid. Do not use markdown.',\n 'negativePrompt should be a comma-separated phrase list.',\n 'suggestions should be short keyword phrases.',\n modeHintMap[request.mode],\n ].join('\\n');\n}\n\nfunction safeParsePromptResult(raw: string, fallbackPrompt: string): PromptEnhanceResult {\n const clean = raw\n .replace(/^```json\\s*/i, '')\n .replace(/^```/i, '')\n .replace(/```$/i, '')\n .trim();\n\n try {\n const parsed = JSON.parse(clean) as Partial<PromptEnhanceResult>;\n return {\n enhancedPrompt: parsed.enhancedPrompt?.trim() || fallbackPrompt,\n negativePrompt: parsed.negativePrompt?.trim() || '',\n suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.filter(Boolean).slice(0, 8) : [],\n notes: parsed.notes?.trim() || '',\n };\n } catch {\n return {\n enhancedPrompt: fallbackPrompt,\n negativePrompt: '',\n suggestions: [],\n notes: raw || 'No response content returned by model.',\n };\n }\n}\n\nexport function inferProviderFromModel(model: string): AIProvider {\n const normalized = normalizeModelName(model);\n if (/^(rhart-image|rhart-video|runninghub\\/|runninghub-image|runninghub-video)/i.test(normalized)) return 'runningHub';\n if (/^(gemini|imagen|veo)/.test(normalized)) return 'google';\n if (/^(dall-e|gpt-image|gpt-5|gpt-4o|gpt-4\\.1|o\\d)/.test(normalized)) return 'openai';\n if (/^claude/i.test(model)) return 'anthropic';\n if (/^qwen/i.test(model)) return 'qwen';\n if (/^deepseek/i.test(model)) return 'deepseek';\n if (/^(siliconflow|deepseek-ai|Qwen)/i.test(model)) return 'siliconflow';\n if (/^(kling|keling)/i.test(model)) return 'keling';\n if (/^flux/i.test(model)) return 'flux';\n if (/^midjourney/i.test(model)) return 'midjourney';\n if (/^(grok-|xai)/i.test(model)) return 'xai';\n if (/^(minimax|abab|video-01)/i.test(model)) return 'minimax';\n if (/^(doubao|skylark|ep-|seedance|dreamina-seedance|doubao-seedance)/i.test(model) || normalized.includes('seedance')) return 'volcengine';\n if (/^(openrouter\\/|google\\/|anthropic\\/|openai\\/|meta-llama\\/|x-ai\\/)/i.test(model)) return 'openrouter';\n return 'custom';\n}\n\nfunction abortError(signal?: AbortSignal): Error {\n const reason = signal?.reason;\n if (reason instanceof Error) return reason;\n if (typeof DOMException !== 'undefined') {\n return new DOMException(typeof reason === 'string' ? reason : '生成已停止', 'AbortError');\n }\n const error = new Error(typeof reason === 'string' ? reason : '生成已停止');\n error.name = 'AbortError';\n return error;\n}\n\nfunction throwIfAborted(signal?: AbortSignal): void {\n if (signal?.aborted) throw abortError(signal);\n}\n\nfunction sleep(ms: number, signal?: AbortSignal) {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n let timeout: ReturnType<typeof setTimeout>;\n const onAbort = () => {\n clearTimeout(timeout);\n reject(abortError(signal));\n };\n timeout = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nfunction uniqueUrls(values: Array<string | undefined>) {\n return Array.from(new Set(values.filter((value): value is string => Boolean(value))));\n}\n\nfunction getUnifiedApiBaseCandidates(baseUrl: string) {\n const trimmed = baseUrl.replace(/\\/+$/, '');\n const direct = trimmed.replace(/\\/(?:api\\/)?v1$/i, '');\n\n try {\n const parsed = new URL(trimmed);\n const pathname = parsed.pathname.replace(/\\/+$/, '');\n const pathnameWithoutVersion = pathname.replace(/\\/(?:api\\/)?v1$/i, '');\n const pathBase = pathnameWithoutVersion && pathnameWithoutVersion !== '/' ? `${parsed.origin}${pathnameWithoutVersion}` : parsed.origin;\n return uniqueUrls([direct, pathBase, parsed.origin]);\n } catch {\n return uniqueUrls([direct]);\n }\n}\n\nfunction extractTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id;\n}\n\nfunction normalizeSeedanceModel(model: string) {\n const normalized = normalizeModelName(model);\n if (normalized === 'seedance-2.0' || normalized === 'seedance-2-0') {\n return DEFAULT_SEEDANCE_MODEL;\n }\n return model || DEFAULT_SEEDANCE_MODEL;\n}\n\nfunction extractSeedanceQueryTaskId(payload: any) {\n return payload?.id\n || payload?.data?.id\n || payload?.task?.id\n || payload?.data?.task?.id\n || payload?.task_id\n || payload?.data?.task_id;\n}\n\nfunction extractSeedanceUpstreamTaskId(payload: any) {\n return payload?.task_id\n || payload?.data?.task_id\n || payload?.metadata?.upstream_task_id\n || payload?.data?.metadata?.upstream_task_id;\n}\n\nfunction extractVideoStatus(payload: any) {\n const rawStatus = payload?.status\n || payload?.data?.status\n || payload?.data?.task_status\n || payload?.state\n || payload?.task?.status\n || payload?.data?.task?.status;\n const normalized = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : '';\n if (['success', 'succeed', 'succeeded', 'completed', 'complete', 'done'].includes(normalized)) return 'succeeded';\n if (['fail', 'failed', 'error', 'cancelled', 'canceled', 'expired'].includes(normalized)) return 'failed';\n if (['queued', 'pending', 'running', 'processing', 'in_progress', 'created', 'submitted'].includes(normalized)) return 'running';\n return normalized;\n}\n\nfunction extractFailureReason(payload: any) {\n return payload?.fail_reason || payload?.data?.fail_reason || payload?.message || payload?.error?.message || payload?.data?.task_status_msg || payload?.status_msg;\n}\n\nfunction extractVideoOutputUrl(payload: any) {\n return payload?.data?.output\n || payload?.data?.outputs?.[0]\n || payload?.output\n || payload?.outputs?.[0]\n || payload?.content?.video_url\n || payload?.data?.video_url\n || payload?.data?.content?.video_url\n || payload?.video?.url\n || payload?.data?.video?.url\n || payload?.result?.video_url\n || payload?.data?.result?.video_url\n || payload?.result?.videos?.[0]?.url\n || payload?.data?.result?.videos?.[0]?.url\n || payload?.data?.task_result?.videos?.[0]?.url;\n}\n\nasync function generateVideoWithUnifiedAsyncApi(\n prompt: string,\n model: string,\n key: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n const apiKey = requireApiKey('custom', key);\n const normalizedBaseUrl = getBaseUrl('custom', key);\n const apiBaseCandidates = getUnifiedApiBaseCandidates(normalizedBaseUrl);\n const aspectRatio = options?.aspectRatio || '16:9';\n const onProgress = options?.onProgress || (() => {});\n let lastError: Error | null = null;\n\n for (const apiBase of apiBaseCandidates) {\n try {\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model,\n prompt,\n aspect_ratio: aspectRatio,\n };\n\n const imageHrefs = (options?.references ?? [])\n .map(r => r.href)\n .filter(Boolean);\n if (imageHrefs.length) {\n createBody.images = imageHrefs;\n }\n\n const createRes = await fetch(`${apiBase}/v2/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n });\n\n if (!createRes.ok) {\n const failure = await readErrorResponse(createRes, '统一视频接口提交失败');\n if (createRes.status === 404 || createRes.status === 405) {\n lastError = new Error(failure);\n continue;\n }\n throw new Error(failure);\n }\n\n const createJson = await readJsonResponse<any>(createRes, '统一视频接口提交响应');\n const taskId = extractTaskId(createJson);\n if (!taskId) {\n throw new Error('统一视频接口未返回 task_id');\n }\n\n let delay = 2000;\n const pollStart = Date.now();\n const MAX_POLL_MS = 600_000; // 10 分钟超时\n while (true) {\n if (Date.now() - pollStart > MAX_POLL_MS) {\n throw new Error('视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(delay <= 2000 ? '任务已提交,正在排队...' : '正在生成视频,请稍候...');\n const queryRes = await fetch(`${apiBase}/v2/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, '统一视频接口查询失败'));\n }\n\n const queryJson = await readJsonResponse<any>(queryRes, '统一视频接口查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n throw new Error(`视频生成失败: ${extractFailureReason(queryJson) || 'Unknown error'}`);\n }\n\n if (status === 'succeeded') {\n const outputUrl = extractVideoOutputUrl(queryJson);\n if (!outputUrl) {\n throw new Error('视频生成完成但未返回下载链接');\n }\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(outputUrl);\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n await sleep(delay);\n delay = Math.min(delay * 2, 8000);\n }\n } catch (error) {\n lastError = error instanceof Error ? error : new Error(String(error));\n }\n }\n\n throw lastError || new Error('当前自定义端点未暴露可用的视频统一接口。');\n}\n\nfunction seedanceReferenceLabel(kind: MultimodalSlotKind, index: number): string {\n if (kind === 'image') return `图片${index + 1}`;\n if (kind === 'video') return `视频${index + 1}`;\n return `音频${index + 1}`;\n}\n\nfunction buildSeedancePromptText(prompt: string, slots: MultimodalSlot[]): string {\n const counts: Partial<Record<MultimodalSlotKind, number>> = {};\n const labels = slots.map((slot) => {\n counts[slot.kind] = counts[slot.kind] || 0;\n const label = seedanceReferenceLabel(slot.kind, counts[slot.kind]);\n counts[slot.kind] = counts[slot.kind] + 1;\n const alias = normalizeMentionLabel(slot.label);\n return alias ? `${label}=${alias}` : label;\n });\n const text = prompt.trim();\n if (!labels.length) return text;\n return `参考素材编号:${labels.join('、')}。请按这些编号理解提示词中的图片、视频和音频引用,角色和主体不要混淆。\\n\\n${text}`;\n}\n\nfunction buildSeedanceContentItems(prompt: string, slots: MultimodalSlot[], capability: CapabilityDictionary): Array<Record<string, unknown>> {\n const filteredSlots = filterMultimodalSlots(slots, capability);\n const text = buildSeedancePromptText(prompt, filteredSlots);\n const contentItems: Array<Record<string, unknown>> = text ? [{ type: 'text', text }] : [];\n for (const slot of filteredSlots) {\n const role = normalizeMultimodalRole(slot);\n if (slot.kind === 'image') {\n contentItems.push({\n type: 'image_url',\n image_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'video') {\n contentItems.push({\n type: 'video_url',\n video_url: { url: slot.href },\n role,\n });\n } else if (slot.kind === 'audio') {\n contentItems.push({\n type: 'audio_url',\n audio_url: { url: slot.href },\n role,\n });\n }\n }\n return contentItems;\n}\n\n/** 返回模型支持的能力标签(emoji 形式) */\nexport function getModelCapabilityTags(model: string): string {\n const provider = inferProviderFromModel(model);\n const caps = DEFAULT_PROVIDER_MODELS[provider];\n if (!caps) return '';\n const tags: string[] = [];\n if (caps.text?.includes(model)) tags.push('💬');\n if (caps.image?.includes(model)) tags.push('🖼️');\n if (caps.video?.includes(model)) tags.push('🎬');\n return tags.join('');\n}\n\nasync function enhancePromptWithOpenAICompatible(\n request: PromptEnhanceRequest,\n model: string,\n provider: AIProvider,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n };\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model,\n temperature: 0.6,\n messages: [\n { role: 'system', content: inferPromptModeHint(request) },\n { role: 'user', content: request.prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${provider} LLM 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = json?.choices?.[0]?.message?.content || '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\nasync function enhancePromptWithAnthropic(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const apiKey = requireApiKey('anthropic', key);\n const baseUrl = getBaseUrl('anthropic', key);\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model,\n max_tokens: 1024,\n system: inferPromptModeHint(request),\n messages: [{ role: 'user', content: request.prompt }],\n }),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n\n const json = await response.json();\n const raw = Array.isArray(json?.content)\n ? json.content.map((item: { text?: string }) => item.text || '').join('\\n')\n : '';\n return safeParsePromptResult(raw, request.prompt);\n}\n\n/**\n * 【函数】统一的提示词润色入口\n *\n * 根据模型名称自动推断 provider,路由到对应的润色实现。\n * 所有 provider 都通过 key 参数即时传入 API Key,避免依赖全局状态。\n *\n * @param request - 润色请求(原始提示词 + 模式)\n * @param model - 模型名称(用于推断 provider)\n * @param key - 用户配置的 API Key(可选,从 App.tsx state 传入)\n */\nexport async function enhancePromptWithProvider(\n request: PromptEnhanceRequest,\n model: string,\n key?: UserApiKey\n): Promise<PromptEnhanceResult> {\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n // 传入 key?.key 确保使用用户配置的 API Key,而非仅依赖全局 runtimeConfig\n return enhancePromptWithGemini(request, key?.key);\n }\n\n if (provider === 'anthropic') {\n return enhancePromptWithAnthropic(request, model, key);\n }\n\n return enhancePromptWithOpenAICompatible(request, model, provider, key);\n}\n\n/**\n * 构建反推 Prompt 的系统指令。\n * 根据 UI 语言 + 图片元数据动态生成,确保输出跟随用户语言偏好。\n * 风格统一为 AI 图像生成器可直接使用的自然语言描述。\n */\nfunction buildReversePromptInstruction(lang: 'en' | 'zho', meta?: { width?: number; height?: number }): string {\n const metaHint = meta?.width && meta?.height\n ? (lang === 'zho'\n ? `\\n图片尺寸 ${meta.width}×${meta.height},宽高比约 ${(meta.width / meta.height).toFixed(2)}。请将宽高比信息融入描述。`\n : `\\nImage dimensions ${meta.width}×${meta.height}, aspect ratio ~${(meta.width / meta.height).toFixed(2)}. Incorporate aspect ratio context.`)\n : '';\n if (lang === 'zho') {\n return [\n '你是一名顶级 AI 图像提示词工程师。',\n '分析给定图片,生成一段可用于 AI 图像生成器直接重现该图的详细提示词。',\n '包含:主体、构图、拍摄角度、光线、色彩、情绪、艺术风格、媒介及精细细节。',\n '如果画面中有明显应避免的元素(如水印、模糊、畸变),在末尾用「负面提示:」列出。',\n '仅输出提示词文本,使用中文,不加解释、不加 markdown、不加前缀。',\n metaHint,\n ].filter(Boolean).join('\\n');\n }\n return [\n 'You are an expert AI image prompt engineer.',\n 'Analyze the given image and generate a detailed prompt that could recreate it with an AI image generator.',\n 'Include: subject, composition, camera angle, lighting, color palette, mood, artistic style, medium, and fine details.',\n 'If there are obvious elements to avoid (e.g. watermarks, blur, distortion), append them at the end after \"Negative prompt:\".',\n 'Output ONLY the prompt text. No explanations, no markdown, no prefix.',\n metaHint,\n ].filter(Boolean).join('\\n');\n}\n\n/**\n * 【函数】图片反推提示词(Reverse Prompt / Describe Image)— 非流式版本\n *\n * 根据用户配置的 text model 路由到支持 vision 的 LLM,传入图片并返回描述提示词。\n * 当前支持:google、openai(及兼容接口)、anthropic、openrouter。\n */\nexport async function reversePromptWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key?: UserApiKey,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n if (usesAnthropicRequestFormat(key)) {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { anthropic: true }),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return (json?.content || []).map((b: { text?: string }) => b.text || '').join('\\n').trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible vision)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const json = await response.json();\n return json?.choices?.[0]?.message?.content?.trim() || '';\n}\n\n/**\n * 【函数】图片反推提示词 — 流式版本 (SSE Streaming)\n *\n * 逐 token 回传文本到 onChunk 回调,配合 AbortSignal 支持随时取消。\n * Google 使用 streamGenerateContent,OpenAI/Anthropic 使用 SSE stream。\n * 返回完整文本(所有 chunk 拼接)。\n */\nexport async function reversePromptStreamWithProvider(\n imageHref: string,\n mimeType: string,\n model: string,\n key: UserApiKey | undefined,\n onChunk: (text: string) => void,\n signal?: AbortSignal,\n lang: 'en' | 'zho' = 'en',\n meta?: { width?: number; height?: number },\n): Promise<string> {\n const instruction = buildReversePromptInstruction(lang, meta);\n const provider = resolveGenerationProvider(model, key);\n let full = '';\n\n if (provider === 'google') {\n const apiKey = requireApiKey(provider, key);\n const effectiveModel = model || 'gemini-2.5-flash';\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const googleBase = key?.baseUrl ? normalizeProviderBaseUrl('google', key.baseUrl) : getGeminiRestBaseUrl();\n const url = `${googleBase}/models/${encodeURIComponent(effectiveModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;\n const response = await fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal,\n body: JSON.stringify({\n contents: [{\n parts: [\n { text: instruction },\n { inlineData: { mimeType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Google Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json?.candidates?.[0]?.content?.parts?.[0]?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n if (provider === 'anthropic') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const base64Data = imageHref.includes(',') ? imageHref.split(',')[1] : imageHref;\n const mediaType = mimeType || 'image/png';\n const response = await fetch(`${baseUrl}/messages`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-api-key': apiKey,\n 'anthropic-version': '2023-06-01',\n },\n signal,\n body: JSON.stringify({\n model: model || 'claude-sonnet-4-6',\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Data } },\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`Anthropic Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n if (json.type === 'content_block_delta') {\n const chunk = json.delta?.text || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n }\n\n // OpenAI / OpenRouter / Custom / DeepSeek / Qwen / etc. (OpenAI-compatible SSE)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const headers: Record<string, string> = provider === 'openrouter'\n ? buildOpenRouterHeaders(apiKey)\n : buildProviderHeaders(apiKey, key);\n\n const imageContent = imageHref.startsWith('data:')\n ? { type: 'image_url' as const, image_url: { url: imageHref } }\n : { type: 'image_url' as const, image_url: { url: imageHref } };\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers,\n signal,\n body: JSON.stringify({\n model: mapProviderModel(model || 'gpt-5.4-mini', key),\n max_tokens: 1024,\n stream: true,\n messages: [{\n role: 'user',\n content: [\n { type: 'text', text: instruction },\n imageContent,\n ],\n }],\n }),\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n throw new Error(`${PROVIDER_LABELS[provider] || provider} Vision 请求失败 (${response.status}): ${text || response.statusText}`);\n }\n const reader = response.body?.getReader();\n if (!reader) throw new Error('无法获取响应流');\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n buffer = lines.pop() || '';\n for (const line of lines) {\n if (!line.startsWith('data: ')) continue;\n const payload = line.slice(6).trim();\n if (payload === '[DONE]') continue;\n try {\n const json = JSON.parse(payload);\n const chunk = json.choices?.[0]?.delta?.content || '';\n if (chunk) { full += chunk; onChunk(chunk); }\n } catch { /* skip malformed JSON */ }\n }\n }\n return full.trim();\n}\n\n/**\n * 【函数】统一的图片生成入口\n *\n * 根据模型名称路由到 Google Imagen / OpenAI DALL-E 等。\n * 当前支持:google、openai、custom。\n *\n * @param prompt - 图片描述提示词\n * @param model - 模型名称\n * @param key - 用户 API Key\n */\nexport async function generateImageWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n images?: VideoImage[],\n options?: { signal?: AbortSignal; aspectRatio?: VideoAspectRatio; resolution?: string; quality?: string; webSearch?: boolean },\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n\n // Runtime 托管的网页 Key:明文在系统 Keyring,经 Runtime 执行并读回产物。\n if (key?.runtimeManaged) {\n const productModel = model.startsWith('flovart:') ? model : (key.defaultModel || model);\n const result = await runRuntimeMediaGeneration({\n command: 'generate.image',\n args: {\n prompt,\n productModel,\n credentialId: key.runtimeManaged.credentialId,\n aspectRatio: options?.aspectRatio,\n resolution: options?.resolution || '1k',\n },\n signal: options?.signal,\n });\n const base64 = await blobToBase64(result.blob);\n return { newImageBase64: base64, newImageMimeType: result.mimeType, textResponse: null };\n }\n const refs = limitProviderImageInputs(images ?? [], provider, model, key);\n\n if (provider === 'google') {\n if (isGoogleImageEditModel(model)) {\n return editImage(refs, prompt, undefined, key?.key, options?.signal, {\n model,\n aspectRatio: options?.aspectRatio === 'adaptive' ? undefined : options?.aspectRatio,\n imageSize: options?.resolution,\n webSearch: options?.webSearch,\n baseUrl: key?.baseUrl,\n });\n }\n return generateImageFromText(prompt, key?.key, options?.signal, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model);\n const runningHubRefs = await prepareRunningHubReferences(apiKey, refs, {\n baseUrl,\n signal: options?.signal,\n });\n const { rhRunTask } = await import('./runningHubService');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n references: runningHubRefs,\n aspectRatio: options?.aspectRatio,\n resolution: options?.resolution,\n userParams: {\n ...(options?.webSearch !== undefined ? { webSearch: options.webSearch } : {}),\n ...(options?.quality !== undefined ? { hd: options.quality === 'hd' || options.quality === 'high' } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n });\n console.log('[RH Debug] rhRunTask image done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const imageUrl = extractRunningHubMediaUrl(result, 'image');\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 未返回图片 URL。',\n };\n }\n return fetchImageUrlToBase64(imageUrl);\n }\n\n if (provider === 'openrouter') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 图片生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 图片生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const imageSize = getOpenAIImageSize(options?.resolution, options?.aspectRatio);\n\n // With reference images: use /images/edits (multipart) or chat/completions fallback\n if (refs.length > 0) {\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n refs.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n try {\n const editsRes = await fetch(`${baseUrl}/images/edits`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n if (editsRes.ok) {\n const json = await readJsonResponse<any>(editsRes, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(editsRes, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions with images\n if (provider === 'custom') {\n const chatContent: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n chatContent.push({ type: 'image_url', image_url: { url: image.href } });\n }\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: chatContent }],\n stream: false,\n }),\n });\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Text-only: try /images/generations first\n const preferredFormat = provider === 'custom' ? 'url' : 'b64_json';\n const imageParams = buildOpenAIImageRequestParams(capability, {\n size: imageSize,\n quality: options?.quality || key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n });\n try {\n const response = await fetch(`${baseUrl}/images/generations`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n prompt,\n ...imageParams,\n ...(!officialGptImage ? { response_format: preferredFormat } : {}),\n }),\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n } catch (err) {\n if (options?.signal?.aborted) throw err;\n if (provider !== 'custom') throw err;\n }\n\n // Custom fallback: chat/completions text-only\n if (provider === 'custom') {\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content: prompt }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片生成响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return { newImageBase64: null, newImageMimeType: null, textResponse: null };\n }\n\n // Generic chat/completions path for all other providers (anthropic, qwen, deepseek, siliconflow, volcengine, etc.)\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of refs) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n signal: options?.signal,\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider] || provider} 图片生成失败`));\n }\n\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider] || provider} 图片生成响应`);\n return parseOpenAIImageResponse(json);\n}\n\nexport async function editImageWithProvider(\n images: ImageInput[],\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: { mask?: ImageInput }\n): Promise<{ newImageBase64: string | null; newImageMimeType: string | null; textResponse: string | null }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n const inputImages = limitProviderImageInputs(images, provider, model, key);\n\n if (provider === 'google') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 Google 图片模型只支持纯文本生图,请切换到 Gemini 图像编辑模型。');\n }\n return editImage(inputImages, prompt, options?.mask, key?.key, undefined, { model, baseUrl: key?.baseUrl });\n }\n\n if (provider === 'openrouter') {\n if (options?.mask) {\n throw new Error('OpenRouter 当前不支持遮罩局部重绘。请切换到 Google Gemini 或 OpenAI GPT Image 模型。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const content = [\n { type: 'text', text: prompt },\n ...inputImages.map((image) => ({\n type: 'image_url',\n image_url: { url: image.href },\n })),\n ];\n\n const response = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildOpenRouterHeaders(apiKey),\n body: JSON.stringify({\n model,\n messages: [{ role: 'user', content }],\n modalities: ['image', 'text'],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(await readErrorResponse(response, 'OpenRouter 参考图生成失败'));\n }\n\n const json = await readJsonResponse<any>(response, 'OpenRouter 参考图生成响应');\n const imageUrl = json?.choices?.[0]?.message?.images?.[0]?.image_url?.url;\n if (!imageUrl) {\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: json?.choices?.[0]?.message?.content || 'OpenRouter 未返回图片结果。',\n };\n }\n\n return decodeDataUrlImage(imageUrl);\n }\n\n if (provider === 'openai' || provider === 'custom' || provider === 'openai_compatible') {\n // custom/openai_compatible provider 跳过模型名检测——聚合端点的模型名不一定匹配 OpenAI 命名规则\n if (provider === 'openai') {\n if (!supportsReferenceImageEditing(model)) {\n throw new Error('当前 OpenAI 图片模型不支持参考图编辑。请切换到 GPT Image 模型。');\n }\n if (options?.mask && !supportsMaskImageEditing(model)) {\n throw new Error('当前模型不支持遮罩局部重绘。请切换到支持编辑的 GPT Image 或 Gemini 模型。');\n }\n }\n\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const mappedModel = mapProviderModel(model, key);\n const capability = getCapabilityDictionary(mappedModel, provider);\n const officialGptImage = provider === 'openai' && isOpenAIImageEditModel(mappedModel);\n const formData = new FormData();\n formData.append('model', mappedModel);\n formData.append('prompt', prompt);\n if (!officialGptImage) {\n formData.append('response_format', provider === 'custom' || provider === 'openai_compatible' ? 'url' : 'b64_json');\n }\n appendOpenAIImageFormParams(formData, buildOpenAIImageRequestParams(capability, {\n size: '1024x1024',\n quality: key?.extraConfig?.imageQuality,\n background: key?.extraConfig?.imageBackground,\n moderation: key?.extraConfig?.moderation,\n output_format: key?.extraConfig?.outputFormat,\n output_compression: key?.extraConfig?.outputCompression ? Number(key.extraConfig.outputCompression) : undefined,\n }));\n\n inputImages.forEach((image, index) => {\n const parsed = parseDataUrl(image.href, image.mimeType);\n formData.append(\n 'image',\n createBlobFromBase64(parsed.base64, image.mimeType),\n `reference-${index}.${image.mimeType.split('/')[1] || 'png'}`,\n );\n });\n\n if (options?.mask) {\n const parsedMask = parseDataUrl(options.mask.href, options.mask.mimeType);\n formData.append(\n 'mask',\n createBlobFromBase64(parsedMask.base64, options.mask.mimeType),\n `mask.${options.mask.mimeType.split('/')[1] || 'png'}`,\n );\n }\n\n // 尝试 /images/edits 标准端点\n try {\n const response = await fetch(`${baseUrl}/images/edits`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key, { contentType: false }),\n body: formData,\n });\n\n if (response.ok) {\n const json = await readJsonResponse<any>(response, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n const parsed = await parseOpenAIImageResponse(json);\n if (parsed.newImageBase64) return parsed;\n }\n\n if (provider !== 'custom' && provider !== 'openai_compatible') {\n throw new Error(await readErrorResponse(response, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n } catch (err) {\n if (provider !== 'custom' && provider !== 'openai_compatible') throw err;\n }\n\n // Custom fallback: 通过 chat/completions 进行图片编辑\n if (provider === 'custom' || provider === 'openai_compatible') {\n const content: any[] = [{ type: 'text', text: prompt }];\n for (const image of images) {\n content.push({ type: 'image_url', image_url: { url: image.href } });\n }\n if (options?.mask) {\n content.push({ type: 'image_url', image_url: { url: options.mask.href } });\n }\n\n const chatResponse = await fetch(`${baseUrl}/chat/completions`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mappedModel,\n messages: [{ role: 'user', content }],\n stream: false,\n }),\n });\n\n if (!chatResponse.ok) {\n throw new Error(await readErrorResponse(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑失败`));\n }\n\n const chatJson = await readJsonResponse<any>(chatResponse, `${PROVIDER_LABELS[provider]} 图片编辑响应`);\n return parseOpenAIImageResponse(chatJson);\n }\n\n return {\n newImageBase64: null,\n newImageMimeType: null,\n textResponse: '图片编辑请求成功,但未返回图片结果。',\n };\n }\n\n throw new Error(`当前模型 ${model} 暂不支持参考图编辑。`);\n}\n\nexport interface SeedanceVideoTaskHandle {\n providerId: 'volcengine';\n modelId: string;\n taskId: string;\n baseUrl: string;\n createdAt: number;\n metadata?: Record<string, unknown>;\n}\n\nexport type SeedanceVideoPollResult =\n | { status: 'running'; remoteStatus?: string; raw?: unknown }\n | { status: 'succeeded'; videoUrl: string; raw?: unknown }\n | { status: 'failed'; error: string; remoteStatus?: string; raw?: unknown };\n\nexport class SeedanceSubmissionUnknownError extends Error {\n readonly code = 'SEEDANCE_SUBMISSION_UNKNOWN';\n constructor(cause?: unknown) {\n super('Seedance 提交结果未知:供应商可能已经创建并计费。已禁止自动重试或切换线路,请先到任务记录核对。', { cause });\n this.name = 'SeedanceSubmissionUnknownError';\n }\n}\n\nexport async function submitSeedanceVideoTask(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n slots?: MultimodalSlot[];\n references?: VideoImage[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<SeedanceVideoTaskHandle> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = getBaseUrl('volcengine', key);\n const mappedModel = normalizeSeedanceModel(mapProviderModel(model || DEFAULT_SEEDANCE_MODEL, key));\n const capability = getCapabilityDictionary(mappedModel, 'volcengine');\n const rawSlots = options?.slots?.length ? options.slots : multimodalSlotsFromLegacyReferences(options?.references ?? []);\n const slots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, 'volcengine');\n validateSeedanceSlots(slots, capability);\n const resolvedSlots = await resolveSeedanceSlotUrls(slots);\n throwIfAborted(options?.signal);\n\n const createBody: Record<string, unknown> = {\n model: mappedModel,\n content: buildSeedanceContentItems(prompt, resolvedSlots, capability),\n ...withSupportedParams(capability, {\n ratio: normalizeSeedanceRatio(options?.aspectRatio || '16:9'),\n duration: normalizeSeedanceDuration(options?.durationSec),\n resolution: normalizeSeedanceResolution(options?.resolution, mappedModel),\n frames: options?.frames,\n seed: options?.seed,\n camera_fixed: options?.cameraFixed,\n watermark: options?.watermark,\n return_last_frame: options?.returnLastFrame,\n generate_audio: options?.generateAudio,\n service_tier: options?.serviceTier,\n safety_identifier: options?.safetyIdentifier,\n }),\n };\n\n let createRes: Response;\n try {\n createRes = await fetch(`${baseUrl}/contents/generations/tasks`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n signal: options?.signal,\n body: JSON.stringify(createBody),\n });\n } catch (error) {\n throw new SeedanceSubmissionUnknownError(error);\n }\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Seedance 视频生成请求失败'));\n }\n const createJson = await readJsonResponse<any>(createRes, 'Seedance 视频生成创建响应');\n const taskId = extractSeedanceQueryTaskId(createJson);\n const upstreamTaskId = extractSeedanceUpstreamTaskId(createJson);\n if (!taskId) throw new Error('Seedance 视频生成未返回任务 id');\n\n return {\n providerId: 'volcengine',\n modelId: mappedModel,\n taskId: String(taskId),\n baseUrl,\n createdAt: Date.now(),\n metadata: {\n rawSubmission: createJson,\n upstreamTaskId: upstreamTaskId ? String(upstreamTaskId) : undefined,\n },\n };\n}\n\nexport async function pollSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoPollResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = handle.baseUrl || getBaseUrl('volcengine', key);\n const queryRes = await fetch(`${baseUrl}/contents/generations/tasks/${encodeURIComponent(handle.taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Seedance 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Seedance 任务查询响应');\n const status = extractVideoStatus(queryJson);\n\n if (status === 'failed') {\n return {\n status: 'failed',\n error: extractFailureReason(queryJson) || 'Unknown error',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n\n if (status === 'succeeded') {\n const videoUrl = extractVideoOutputUrl(queryJson);\n if (!videoUrl) {\n return {\n status: 'failed',\n error: 'Seedance 视频生成完成但未返回下载链接',\n remoteStatus: status,\n raw: queryJson,\n };\n }\n return { status: 'succeeded', videoUrl, raw: queryJson };\n }\n\n return { status: 'running', remoteStatus: status || undefined, raw: queryJson };\n}\n\nexport async function downloadSeedanceVideoResult(videoUrl: string, options?: { signal?: AbortSignal }): Promise<{ videoBlob: Blob; mimeType: string }> {\n throwIfAborted(options?.signal);\n console.log('[RH Debug] downloadSeedanceVideoResult start', { url: videoUrl.slice(0, 80) });\n const response = await fetch(videoUrl, { signal: options?.signal });\n if (!response.ok) {\n console.error('[RH Debug] downloadSeedanceVideoResult !ok', { status: response.status, statusText: response.statusText });\n throw new Error(`视频下载失败: ${response.statusText}`);\n }\n const blob = await response.blob();\n const mimeType = response.headers.get('Content-Type') || 'video/mp4';\n console.log('[RH Debug] downloadSeedanceVideoResult done', { size: blob.size, mimeType });\n return { videoBlob: blob, mimeType };\n}\n\n/**\n * 区分官方 Ark 与第三方 OpenAI-compatible 网关:\n * - Ark 官方:baseUrl 含 `ark.*.volces.com` 或路径含 `/api/v3`,惯例 DELETE `/contents/generations/tasks/{id}`\n * - 网关(如 Scnet):路径含 `/api/llm/v1`,惯例 POST `/tasks/{id}/cancel`\n * - 其他:fallback 到 Ark 风格(保留向后兼容)。\n */\nfunction detectSeedanceCancelRoute(baseUrl: string): 'ark' | 'gateway' {\n const lower = (baseUrl || '').toLowerCase();\n if (lower.includes('/api/llm/v1') || lower.includes('/v1/tasks')) return 'gateway';\n if (lower.includes('volces.com') || lower.includes('/api/v3')) return 'ark';\n return 'ark';\n}\n\nexport type SeedanceVideoCancelResult = {\n canceled: boolean;\n reason: 'ok' | 'not_queued' | 'not_cancellable' | 'unsupported' | 'network_error';\n upstreamStillRunning?: boolean;\n message?: string;\n raw?: unknown;\n};\n\n/**\n * 取消 Seedance 上游任务。仅对 queued 阶段的成功取消返回 `canceled: true`。\n * 上游已开始 processing、或上游不支持取消时返回 `canceled:false, upstreamStillRunning:true`,\n * 让前端走“放弃等待”的退路(仅本地 abortSignal 不再轮询上游)。\n */\nexport async function cancelSeedanceVideoTask(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal; reason?: string },\n): Promise<SeedanceVideoCancelResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const route = detectSeedanceCancelRoute(baseUrl);\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n if (route === 'ark') {\n // 官方 Ark:DELETE /contents/generations/tasks/{id}。409=不可取消;200/204=已取消。\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n method: 'DELETE',\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } else {\n // 网关风格:POST /tasks/{id}/cancel;body 可携带 reason。\n res = await fetch(`${baseUrl}/tasks/${taskId}/cancel`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },\n body: JSON.stringify({ reason: options?.reason || 'user_abort' }),\n signal: options?.signal,\n });\n }\n } catch (error) {\n return {\n canceled: false,\n reason: 'network_error',\n upstreamStillRunning: true,\n message: error instanceof Error ? error.message : '网络错误,无法确认取消结果',\n };\n }\n\n if (res.status === 405 || res.status === 404) {\n // 网关/官方未实现取消端点\n return {\n canceled: false,\n reason: 'unsupported',\n upstreamStillRunning: true,\n message: '当前 Seedance 渠道不支持取消,请等待完成或放弃等待',\n };\n }\n\n let raw: any = undefined;\n try {\n raw = await res.json();\n } catch {\n // 204 No Content 等成功无 body 的情况\n }\n\n if (res.ok) {\n // Scnet 风格可能返回 { code: 0 } 或 { success: true }; Ark 200 返回 { cancelled: true }\n // 兼容两种解析:cancelled=true / code===0 / success===true 视为成功\n const okFlag =\n raw?.cancelled === true\n || raw?.data?.cancelled === true\n || raw?.code === 0\n || raw?.success === true\n || (route === 'ark' && res.status === 200 && raw == null);\n return {\n canceled: Boolean(okFlag),\n reason: okFlag ? 'ok' : 'not_cancellable',\n upstreamStillRunning: !okFlag,\n message: okFlag ? undefined : (raw?.message || raw?.msg || '上游响应未确认取消'),\n raw,\n };\n }\n\n if (res.status === 409 || res.status === 400) {\n // Ark: 409 task_not_cancellable (已 processing);网关: 400 已开始\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || '任务已开始执行,上游无法取消',\n raw,\n };\n }\n\n return {\n canceled: false,\n reason: 'not_cancellable',\n upstreamStillRunning: true,\n message: raw?.message || raw?.msg || `上游返回 ${res.status}`,\n raw,\n };\n}\n\nexport type SeedanceVideoReconcileResult = {\n status: 'succeeded' | 'failed' | 'cancelled' | 'expired' | 'unknown';\n amount?: number;\n currency?: string;\n totalTokens?: number;\n reason?: string;\n raw?: unknown;\n};\n\nfunction parseSeedanceVideoUsage(raw: any, fallbackStatus: SeedanceVideoReconcileResult['status'] = 'unknown'): SeedanceVideoReconcileResult {\n const remoteStatus = String(raw?.status || raw?.data?.status || raw?.data?.task_status || raw?.task?.status || raw?.state || '').toLowerCase();\n let status: SeedanceVideoReconcileResult['status'] = fallbackStatus;\n if (['succeeded', 'succeed', 'success', 'completed', 'complete', 'done'].includes(remoteStatus)) status = 'succeeded';\n else if (['cancelled', 'canceled'].includes(remoteStatus)) status = 'cancelled';\n else if (['failed', 'fail', 'error'].includes(remoteStatus)) status = 'failed';\n else if (['expired', 'timeout'].includes(remoteStatus)) status = 'expired';\n const billingRaw = raw?.data?.billing || raw?.data?.usage?.billing || raw?.billing || raw?.usage?.billing;\n const amount = typeof billingRaw?.amount === 'number' ? billingRaw.amount : undefined;\n const currency = typeof billingRaw?.currency === 'string' ? billingRaw.currency : raw?.data?.currency || raw?.currency;\n const totalTokens = typeof raw?.usage?.total_tokens === 'number' ? raw.usage.total_tokens\n : typeof raw?.data?.usage?.total_tokens === 'number' ? raw.data.usage.total_tokens\n : typeof raw?.usage?.token_usage?.total_tokens === 'number' ? raw.usage.token_usage.total_tokens\n : undefined;\n return { status, amount, currency, totalTokens, raw };\n}\n\n/**\n * 查询上游任务的真实账单/token 使用量。复用 GET 任务查询接口;不修改 handle。\n *\n * - 网关(Scnet/Lemondata)常见字段:`data.billing.amount` / `data.usage.billing.amount` + `currency`\n * - Volcengine Ark 官方常见字段:`usage.total_tokens`(仅 token,无 billing 金额)\n *\n * 调用方拿到返回值后:\n * - amount 存在 → actualCost = amount,billableState = 'actual'\n * - 仅 totalTokens → actualTokens = totalTokens,billableState 保持 'unknown'\n * - status === 'unknown' → 不变更 usageRecord\n */\nexport async function reconcileSeedanceVideoUsage(\n handle: SeedanceVideoTaskHandle,\n key?: UserApiKey,\n options?: { signal?: AbortSignal },\n): Promise<SeedanceVideoReconcileResult> {\n throwIfAborted(options?.signal);\n const apiKey = requireApiKey('volcengine', key);\n const baseUrl = (handle.baseUrl || getBaseUrl('volcengine', key)).replace(/\\/$/, '');\n const taskId = encodeURIComponent(handle.taskId);\n\n let res: Response;\n try {\n res = await fetch(`${baseUrl}/contents/generations/tasks/${taskId}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n } catch (error) {\n return { status: 'unknown', reason: error instanceof Error ? error.message : '网络错误' };\n }\n if (!res.ok) {\n return { status: 'unknown', reason: `上游返回 ${res.status}` };\n }\n let raw: any;\n try {\n raw = await res.json();\n } catch {\n return { status: 'unknown', reason: '响应不是合法 JSON' };\n }\n\n return parseSeedanceVideoUsage(raw);\n}\n\n/**\n * 【函数】统一的视频生成入口\n *\n * 根据模型名称路由到 Google Veo / MiniMax video-01 等。\n * 当前支持:google、minimax、custom(OpenAI-compatible /videos)。\n *\n * @param prompt - 视频描述提示词\n * @param model - 模型名称(如 veo-3.1-generate-preview, video-01)\n * @param key - 用户 API Key\n * @param options - 可选参数:aspectRatio、onProgress、image(首帧图)\n */\nexport async function generateVideoWithProvider(\n prompt: string,\n model: string,\n key?: UserApiKey,\n options?: {\n aspectRatio?: VideoAspectRatio;\n onProgress?: (message: string) => void;\n references?: VideoImage[];\n slots?: MultimodalSlot[];\n durationSec?: number;\n resolution?: string;\n frames?: number;\n seed?: number;\n cameraFixed?: boolean;\n watermark?: boolean;\n returnLastFrame?: boolean;\n generateAudio?: boolean;\n serviceTier?: string;\n safetyIdentifier?: string;\n generationSubmode?: ProductModelMode;\n signal?: AbortSignal;\n onProviderTaskLifecycle?: (event: ProviderTaskLifecycleEvent) => void | Promise<void>;\n },\n): Promise<{ videoBlob: Blob; mimeType: string }> {\n assertResolvedUpstreamModel(model);\n const provider = resolveGenerationProvider(model, key);\n\n // Runtime 托管的网页 Key:明文在系统 Keyring,经 Runtime 执行并读回产物 Blob。\n if (key?.runtimeManaged) {\n const productModel = model.startsWith('flovart:') ? model : (key.defaultModel || model);\n const result = await runRuntimeMediaGeneration({\n command: 'generate.video',\n args: {\n prompt,\n productModel,\n credentialId: key.runtimeManaged.credentialId,\n durationSec: options?.durationSec,\n aspectRatio: options?.aspectRatio || '16:9',\n resolution: options?.resolution || '720p',\n generateAudio: options?.generateAudio ?? false,\n },\n onProgress: options?.onProgress,\n signal: options?.signal,\n });\n return { videoBlob: result.blob, mimeType: result.mimeType };\n }\n const onProgress = options?.onProgress || (() => {});\n const aspectRatio = options?.aspectRatio || '16:9';\n const runningHubEndpoint = provider === 'runningHub'\n ? assertRunningHubModelEndpoint(mapProviderModel(model, key) || key?.defaultModel || model)\n : undefined;\n const legacyReferences = options?.references ?? [];\n const hasExplicitSlots = !!options?.slots?.length;\n const rawSlots = hasExplicitSlots ? options.slots! : multimodalSlotsFromLegacyReferences(legacyReferences);\n const multimodalSlots = adaptVideoSlotsForMode(rawSlots, options?.generationSubmode, provider, runningHubEndpoint);\n const references: VideoImage[] = multimodalSlots.filter(slot => slot.kind === 'image').map(slot => ({\n href: slot.href,\n mimeType: slot.mimeType,\n slotRole: String(slot.role || 'unassigned'),\n label: slot.label,\n }));\n const firstImageSlot = multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'first_frame')\n || multimodalSlots.find(slot => slot.kind === 'image');\n const usesFirstFrame = !options?.generationSubmode || options.generationSubmode === 'image-to-video' || options.generationSubmode === 'first-last-frame';\n const firstFrame = usesFirstFrame\n ? references.find(r => r.slotRole === 'first_frame')\n || references[0]\n || (firstImageSlot ? { href: firstImageSlot.href, mimeType: firstImageSlot.mimeType, slotRole: String(firstImageSlot.role || 'unassigned') } : undefined)\n : undefined;\n throwIfAborted(options?.signal);\n\n if (provider === 'runningHub') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const modelEndpoint = runningHubEndpoint!;\n const allImageRefs = references;\n const uploadOptions = { baseUrl, signal: options?.signal };\n const runningHubRefs = await prepareRunningHubReferences(apiKey, allImageRefs, uploadOptions);\n const runningHubSlots = await prepareRunningHubSlots(apiKey, hasExplicitSlots ? multimodalSlots : [], uploadOptions);\n const { rhRunTask } = await import('./runningHubService');\n onProgress('Submitting RunningHub video task...');\n const result = await rhRunTask(apiKey, modelEndpoint, buildRunningHubStandardPayload(prompt, modelEndpoint, key, {\n aspectRatio,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n references: runningHubRefs,\n slots: runningHubSlots,\n userParams: {\n ...(options?.generateAudio !== undefined ? { generateAudio: options.generateAudio } : {}),\n ...(options?.seed !== undefined ? { seed: options.seed } : {}),\n ...(options?.returnLastFrame !== undefined ? { returnLastFrame: options.returnLastFrame } : {}),\n },\n }), {\n baseUrl,\n signal: options?.signal,\n onProgress: (status, attempt) => onProgress(`RunningHub ${status} (${attempt})`),\n });\n console.log('[RH Debug] rhRunTask video done', { status: result.status, resultsCount: result.results?.length, results: result.results?.map(r => ({ url: r.url?.slice(0, 60), outputType: r.outputType })) });\n const videoUrl = extractRunningHubMediaUrl(result, 'video');\n if (!videoUrl) {\n throw new Error(result.results?.map(item => item.text).filter(Boolean).join('\\n') || 'RunningHub 视频任务完成但未返回视频 URL。');\n }\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'google') {\n const lastFrameSlot = options?.generationSubmode === 'first-last-frame'\n ? multimodalSlots.find(slot => slot.kind === 'image' && slot.role === 'last_frame')\n : undefined;\n const referenceImages = options?.generationSubmode === 'reference-to-video'\n ? multimodalSlots.filter(slot => slot.kind === 'image' && slot.role !== 'first_frame' && slot.role !== 'last_frame').slice(0, 3)\n : [];\n return generateVideo(prompt, aspectRatio === '9:16' ? '9:16' : '16:9', onProgress, firstFrame, key?.key, {\n model,\n durationSec: options?.durationSec,\n resolution: options?.resolution,\n generateAudio: options?.generateAudio,\n signal: options?.signal,\n lastFrame: lastFrameSlot ? { href: lastFrameSlot.href, mimeType: lastFrameSlot.mimeType } : undefined,\n referenceImages: referenceImages.map(reference => ({ href: reference.href, mimeType: reference.mimeType })),\n baseUrl: key?.baseUrl,\n });\n }\n\n if (provider === 'minimax') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Step 1: Submit video generation task\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = { model, prompt };\n if (firstFrame) {\n createBody.first_frame_image = firstFrame.href;\n }\n\n const createRes = await fetch(`${baseUrl}/video_generation`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'MiniMax 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'MiniMax 视频生成创建响应');\n const taskId = createJson?.task_id;\n if (!taskId) {\n throw new Error('MiniMax 视频生成未返回 task_id');\n }\n\n // Step 2: Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let fileId: string | undefined;\n const miniMaxPollStart = Date.now();\n while (true) {\n if (Date.now() - miniMaxPollStart > 600_000) {\n throw new Error('MiniMax 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/query/video_generation?task_id=${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'MiniMax 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'MiniMax 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'Fail' || status === 'fail') {\n throw new Error(`MiniMax 视频生成失败: ${queryJson?.status_msg || 'Unknown error'}`);\n }\n if (status === 'Success' || status === 'success') {\n fileId = queryJson?.file_id;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!fileId) {\n throw new Error('MiniMax 视频生成完成但未返回 file_id');\n }\n\n // Step 3: Download via file retrieve endpoint\n onProgress('Downloading generated video...');\n const fileRes = await fetch(`${baseUrl}/files/retrieve?file_id=${encodeURIComponent(fileId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!fileRes.ok) {\n throw new Error(await readErrorResponse(fileRes, 'MiniMax 文件下载失败'));\n }\n const fileJson = await readJsonResponse<any>(fileRes, 'MiniMax 文件下载响应');\n const downloadUrl = fileJson?.file?.download_url;\n if (!downloadUrl) {\n throw new Error('MiniMax 未返回视频下载链接');\n }\n\n const videoRes = await fetch(downloadUrl, { signal: options?.signal });\n if (!videoRes.ok) {\n throw new Error(`视频下载失败: ${videoRes.statusText}`);\n }\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'keling') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n\n // Kling AI video generation\n onProgress('Submitting video generation task...');\n const createBody: Record<string, unknown> = {\n model_name: model || 'kling-v1',\n prompt,\n cfg_scale: 0.5,\n mode: options?.resolution?.toLowerCase() === '1080p' ? 'pro' : 'std',\n aspect_ratio: aspectRatio.replace(':', ':'),\n duration: String(options?.durationSec || 5),\n };\n if (firstFrame) {\n createBody.image = firstFrame.href;\n createBody.type = 'img2video';\n } else {\n createBody.type = 'text2video';\n }\n\n const createRes = await fetch(`${baseUrl}/videos/generations`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'Keling 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'Keling 视频生成创建响应');\n const taskId = createJson?.data?.task_id;\n if (!taskId) throw new Error('Keling 视频生成未返回 task_id');\n\n // Poll for completion\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const kelingPollStart = Date.now();\n while (true) {\n if (Date.now() - kelingPollStart > 600_000) {\n throw new Error('Keling 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/generations/${encodeURIComponent(taskId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'Keling 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'Keling 任务查询响应');\n const status = queryJson?.data?.task_status;\n\n if (status === 'failed') {\n throw new Error(`Keling 视频生成失败: ${queryJson?.data?.task_status_msg || 'Unknown error'}`);\n }\n if (status === 'succeed') {\n videoUrl = queryJson?.data?.task_result?.videos?.[0]?.url;\n break;\n }\n }\n\n if (!videoUrl) throw new Error('Keling 视频生成完成但未返回下载链接');\n\n onProgress('Downloading generated video...');\n const videoRes = await fetch(videoUrl, { signal: options?.signal });\n if (!videoRes.ok) throw new Error(`视频下载失败: ${videoRes.statusText}`);\n const videoBlob = await videoRes.blob();\n const mimeType = videoRes.headers.get('Content-Type') || 'video/mp4';\n return { videoBlob, mimeType };\n }\n\n if (provider === 'xai') {\n const apiKey = requireApiKey(provider, key);\n const baseUrl = getBaseUrl(provider, key);\n const submode: ProductModelMode = options?.generationSubmode || 'text-to-video';\n\n onProgress('Submitting video generation task...');\n let createPath: string;\n const createBody: Record<string, unknown> = { model, prompt };\n\n if (submode === 'video-extension') {\n createPath = `${baseUrl}/videos/extensions`;\n const videoSlot = multimodalSlots.find(slot => slot.kind === 'video');\n if (!videoSlot) {\n throw new Error('xAI 视频扩展至少需要 1 个视频素材作为驱动视频。');\n }\n createBody.video = { url: videoSlot.href };\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n } else {\n createPath = `${baseUrl}/videos/generations`;\n if (aspectRatio) {\n createBody.aspect_ratio = aspectRatio;\n }\n if (options?.resolution) {\n createBody.resolution = options.resolution;\n }\n if (options?.durationSec != null) {\n createBody.duration = options.durationSec;\n }\n if (firstFrame) {\n createBody.image = { url: firstFrame.href };\n }\n }\n\n const createRes = await fetch(createPath, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${apiKey}`,\n },\n body: JSON.stringify(createBody),\n signal: options?.signal,\n });\n\n if (!createRes.ok) {\n throw new Error(await readErrorResponse(createRes, 'xAI 视频生成请求失败'));\n }\n\n const createJson = await readJsonResponse<any>(createRes, 'xAI 视频生成创建响应');\n const requestId = createJson?.request_id;\n if (!requestId) {\n throw new Error('xAI 视频生成未返回 request_id');\n }\n\n const progressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let messageIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n\n let videoUrl: string | undefined;\n const xaiPollStart = Date.now();\n while (true) {\n if (Date.now() - xaiPollStart > 600_000) {\n throw new Error('xAI 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(progressMessages[messageIndex % progressMessages.length]);\n messageIndex++;\n await sleep(10000, options?.signal);\n\n const queryRes = await fetch(`${baseUrl}/videos/${encodeURIComponent(requestId)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n signal: options?.signal,\n });\n if (!queryRes.ok) {\n throw new Error(await readErrorResponse(queryRes, 'xAI 任务查询失败'));\n }\n const queryJson = await readJsonResponse<any>(queryRes, 'xAI 任务查询响应');\n const status = queryJson?.status;\n\n if (status === 'failed') {\n throw new Error(`xAI 视频生成失败: ${queryJson?.error || queryJson?.message || 'Unknown error'}`);\n }\n if (status === 'done') {\n videoUrl = queryJson?.output?.url;\n break;\n }\n // Otherwise still processing, continue polling\n }\n\n if (!videoUrl) {\n throw new Error('xAI 视频生成完成但未返回视频 URL');\n }\n\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(videoUrl, { signal: options?.signal });\n }\n\n if (provider === 'volcengine') {\n const handle = await submitSeedanceVideoTask(prompt, model, key, {\n ...options,\n aspectRatio,\n slots: multimodalSlots,\n });\n await options?.onProviderTaskLifecycle?.({ phase: 'submitted', providerTaskId: handle.taskId, submittedAt: handle.createdAt });\n const seedanceProgressMessages = ['Rendering frames...', 'Compositing video...', 'Applying final touches...', 'Almost there...'];\n let seedanceMsgIndex = 0;\n onProgress('Generation started, this may take a few minutes.');\n const seedancePollStart = Date.now();\n let cancelPromise: Promise<SeedanceVideoCancelResult> | undefined;\n const requestUpstreamCancel = () => cancelPromise ||= cancelSeedanceVideoTask(handle, key, { reason: 'user_abort' }).then(async result => {\n await options?.onProviderTaskLifecycle?.({ phase: 'cancelled', providerTaskId: handle.taskId, canceled: result.canceled, upstreamStillRunning: result.upstreamStillRunning, message: result.message });\n const usage = await reconcileSeedanceVideoUsage(handle, key);\n if (usage.status !== 'unknown' || usage.amount !== undefined || usage.totalTokens !== undefined) {\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n }\n return result;\n });\n const handleAbort = () => { void requestUpstreamCancel(); };\n options?.signal?.addEventListener('abort', handleAbort, { once: true });\n\n try {\n while (true) {\n if (Date.now() - seedancePollStart > 600_000) {\n throw new Error('Seedance 视频生成超时(已等待超过 10 分钟)');\n }\n onProgress(seedanceProgressMessages[seedanceMsgIndex % seedanceProgressMessages.length]);\n seedanceMsgIndex++;\n await sleep(10000, options?.signal);\n\n const result = await pollSeedanceVideoTask(handle, key, { signal: options?.signal });\n if (result.status === 'failed') {\n const usage = parseSeedanceVideoUsage(result.raw, 'failed');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n throw new Error(`Seedance 视频生成失败: ${result.error || 'Unknown error'}`);\n }\n if (result.status === 'succeeded') {\n const usage = parseSeedanceVideoUsage(result.raw, 'succeeded');\n await options?.onProviderTaskLifecycle?.({ phase: 'usage', providerTaskId: handle.taskId, status: usage.status, amount: usage.amount, currency: usage.currency, totalTokens: usage.totalTokens });\n onProgress('Downloading generated video...');\n return downloadSeedanceVideoResult(result.videoUrl, { signal: options?.signal });\n }\n await options?.onProviderTaskLifecycle?.({ phase: 'running', providerTaskId: handle.taskId, remoteStatus: result.remoteStatus });\n }\n } catch (error) {\n if (options?.signal?.aborted || error instanceof Error && error.name === 'AbortError') await requestUpstreamCancel();\n throw error;\n } finally {\n options?.signal?.removeEventListener('abort', handleAbort);\n }\n }\n\n if (provider === 'custom') {\n if (!key) {\n throw new Error('未配置自定义视频端点的 API Key。');\n }\n return generateVideoWithUnifiedAsyncApi(prompt, model, key, options);\n }\n\n throw new Error(\n `当前暂不支持使用 ${PROVIDER_LABELS[provider] || provider} 进行视频生成。` +\n `请切换到 Google Veo、MiniMax video-01、Keling 或 Seedance 视频模型。`\n );\n}\n\nexport async function executeUnifiedIgnition(input: UnifiedIgnitionInput): Promise<UnifiedIgnitionResult> {\n const capability = inferCapabilityFromModelName(input.modelId);\n const prompt = input.prompt.trim();\n\n if (!prompt) {\n return { ok: false, elementId: input.elementId, capability, errorMessage: '请输入提示词后再点火。' };\n }\n\n try {\n if (input.signal?.aborted) throw input.signal.reason || new DOMException('生成已停止', 'AbortError');\n const params = sanitizeProductGenerationParams(input.productModelId, {\n mode: input.generationSubmode,\n aspectRatio: input.aspectRatio,\n resolution: input.resolution,\n quality: input.quality,\n durationSec: input.durationSec,\n generateAudio: input.generateAudio,\n webSearch: input.webSearch,\n realPersonCheck: input.realPersonCheck,\n referenceCount: input.references?.length || 0,\n }, { provider: input.apiKeyPayload?.provider, routeId: input.modelId });\n if (capability === 'video') {\n const referenceKinds = (input.references || [])\n .filter((reference): reference is IgnitionReference & { type: 'image' | 'video' | 'audio' } => reference.type === 'image' || reference.type === 'video' || reference.type === 'audio')\n .map(reference => reference.type);\n const issue = explainReferenceCompatibility(input.productModelId, params.mode as ProductModelMode, referenceKinds, { provider: input.apiKeyPayload?.provider, routeId: input.modelId });\n if (issue) throw new Error(issue);\n }\n const effectivePrompt = buildPromptWithReferenceBindings(prompt, input.references);\n if (capability === 'video') {\n const videoRefs = getImageReferencesForIgnition(input.references);\n const videoSlots = getMultimodalSlotsForIgnition(input.references);\n const result = await generateVideoWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, {\n aspectRatio: params.aspectRatio || getDynamicParamSchema(input.modelId).defaultAspectRatio || '16:9',\n durationSec: params.durationSec,\n resolution: params.resolution,\n generateAudio: params.generateAudio,\n watermark: input.watermark,\n generationSubmode: params.mode,\n references: videoRefs,\n slots: videoSlots,\n signal: input.signal,\n onProgress: message => input.onProgress?.(35, message),\n onProviderTaskLifecycle: input.onProviderTaskLifecycle,\n });\n const mediaUrl = URL.createObjectURL(result.videoBlob);\n return { ok: true, elementId: input.elementId, mediaUrl, mimeType: result.mimeType, capability };\n }\n\n const imageReferences = getImageReferencesForIgnition(input.references);\n const result = await generateImageWithProvider(effectivePrompt, input.modelId, input.apiKeyPayload, imageReferences, {\n signal: input.signal,\n aspectRatio: params.aspectRatio,\n resolution: params.resolution,\n quality: params.quality,\n webSearch: params.webSearch,\n });\n\n if (!result.newImageBase64 || !result.newImageMimeType) {\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: result.textResponse || '生成网关未返回可用图片。',\n };\n }\n\n return {\n ok: true,\n elementId: input.elementId,\n mediaUrl: `data:${result.newImageMimeType};base64,${result.newImageBase64}`,\n mimeType: result.newImageMimeType,\n capability,\n textResponse: result.textResponse,\n };\n } catch (error) {\n const reason = input.signal?.aborted ? input.signal.reason : error;\n const aborted = input.signal?.aborted || (error instanceof Error && error.name === 'AbortError');\n return {\n ok: false,\n elementId: input.elementId,\n capability,\n errorMessage: aborted\n ? (reason instanceof Error && reason.name === 'TimeoutError' ? reason.message : '生成已停止,可重新发起。')\n : error instanceof Error ? error.message : '多模态点火失败。',\n };\n }\n}\n\nexport interface ImageToolInput {\n href: string;\n mimeType: string;\n}\n\nexport interface ImageToolLayer {\n name: string;\n dataUrl: string;\n width: number;\n height: number;\n offsetX: number;\n offsetY: number;\n}\n\nexport type ImageToolTask = 'upscale' | 'remove-background' | 'enhance';\n\nexport interface ImageToolResult {\n dataUrl: string;\n mimeType: string;\n width: number;\n height: number;\n}\n\nfunction getAgentBaseUrl(provider: AIProvider, key?: UserApiKey): string {\n return getBaseUrl(provider, key).replace(/\\/$/, '');\n}\n\nfunction dataUrlFromMaybeBase64(value: unknown, mimeType: string): string | null {\n if (typeof value !== 'string' || !value) return null;\n return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;\n}\n\nexport async function splitImageLayersWithProvider(\n image: ImageToolInput,\n model: string,\n key?: UserApiKey,\n): Promise<ImageToolLayer[]> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /split-layers 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/split-layers`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task: 'layer-segmentation',\n image: { data: base64Payload, mimeType: image.mimeType },\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图层拆分请求失败'));\n const json = await readJsonResponse<any>(response, '图层拆分响应');\n const rawLayers = (json.layers || json.results || json.data || []) as Array<Record<string, any>>;\n return rawLayers.map((layer, index) => {\n const mimeType = layer.mimeType || layer.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(layer.imageBase64 || layer.base64 || layer.image_data || layer.dataUrl || layer.image_url, mimeType);\n if (!dataUrl) return null;\n return {\n name: layer.name || layer.label || `Layer ${index + 1}`,\n dataUrl,\n width: Number(layer.width || layer.bbox?.width || layer.box?.width || layer.bounds?.width || 0),\n height: Number(layer.height || layer.bbox?.height || layer.box?.height || layer.bounds?.height || 0),\n offsetX: Number(layer.x || layer.bbox?.x || layer.box?.x || layer.bounds?.x || 0),\n offsetY: Number(layer.y || layer.bbox?.y || layer.box?.y || layer.bounds?.y || 0),\n };\n }).filter((layer): layer is ImageToolLayer => !!layer);\n}\n\nexport async function runImageAgentWithProvider(\n image: ImageToolInput,\n task: ImageToolTask,\n model: string,\n key?: UserApiKey,\n options?: Record<string, unknown>,\n): Promise<ImageToolResult> {\n const provider = key?.provider || resolveGenerationProvider(model, key);\n if (!key?.baseUrl) {\n throw new Error('未配置图像工具端点 Base URL。请在供应商设置中填写支持 /agent 的 API 地址。');\n }\n\n const apiKey = requireApiKey(provider, key);\n const base64Payload = image.href.includes(',') ? image.href.split(',')[1] : image.href;\n const response = await fetch(`${getAgentBaseUrl(provider, key)}/agent`, {\n method: 'POST',\n headers: buildProviderHeaders(apiKey, key),\n body: JSON.stringify({\n model: mapProviderModel(model, key),\n task,\n image: { data: base64Payload, mimeType: image.mimeType },\n options: options || {},\n }),\n });\n if (!response.ok) throw new Error(await readErrorResponse(response, '图片代理请求失败'));\n const json = await readJsonResponse<any>(response, '图片代理响应');\n const raw = json.result || json.image || json.data || json;\n const mimeType = raw.mimeType || raw.mime_type || image.mimeType || 'image/png';\n const dataUrl = dataUrlFromMaybeBase64(raw.imageBase64 || raw.base64 || raw.image_data || raw.dataUrl || raw.image_url, mimeType);\n if (!dataUrl) throw new Error('图片代理未返回可用图片数据。');\n return {\n dataUrl,\n mimeType,\n width: Number(raw.width || 0),\n height: Number(raw.height || 0),\n };\n}\n\n/**\n * 自省诊断 — 根据用户已配置的 API Key 集合,检查各能力覆盖情况并返回警告\n *\n * @param keys - 用户当前所有 API Key(来自 App.tsx state: userApiKeys)\n * @returns covered: 已覆盖能力列表,missing: 缺失的能力,warnings: 具体提示信息\n */\nexport function diagnoseKeyCapabilities(keys: UserApiKey[]): {\n covered: AICapability[];\n missing: AICapability[];\n warnings: string[];\n} {\n const ALL_CAPS: AICapability[] = ['text', 'image', 'video'];\n const coveredSet = new Set<AICapability>();\n const warnings: string[] = [];\n\n for (const key of keys) {\n const caps = key.capabilities?.length ? key.capabilities : inferCapabilitiesByProvider(key.provider);\n for (const c of caps) coveredSet.add(c);\n }\n\n const covered = ALL_CAPS.filter(c => coveredSet.has(c));\n const missing = ALL_CAPS.filter(c => !coveredSet.has(c));\n\n if (missing.includes('text')) warnings.push('未配置文本模型 API Key — 提示词润色、AI 对话功能不可用');\n if (missing.includes('image')) warnings.push('未配置图片模型 API Key — AI 绘图、图片编辑功能不可用');\n if (missing.includes('video')) warnings.push('未配置视频模型 API Key — AI 视频生成功能不可用');\n\n // 检查是否有 Google key (核心能力依赖)\n const hasGoogle = keys.some(k => k.provider === 'google' && k.key);\n if (!hasGoogle && keys.length > 0) {\n warnings.push('建议配置 Google API Key — Gemini 3 / Imagen 4 / Veo 3.1 是当前最强图像和视频模型');\n }\n\n if (keys.length === 0) {\n warnings.push('尚未配置任何 API Key — 所有 AI 功能不可用,请先在设置中添加');\n }\n\n return { covered, missing, warnings };\n}\n\n// --- Structured capability reasons ---------------------------------------------------\n\nexport type CapabilityStatus = {\n capability: AICapability;\n supported: boolean;\n reason: string;\n};\n\n/**\n * Return per-capability support status **with human-readable reasons**.\n * Unlike `diagnoseKeyCapabilities` (which is consumed by DiagnosticBar),\n * this function is intended for panels that need to explain *why*\n * a capability is available or missing.\n */\nexport function explainKeyCapabilities(keys: UserApiKey[]): CapabilityStatus[] {\n const covered = new Set<AICapability>(\n keys.flatMap(key =>\n key.capabilities?.length\n ? key.capabilities\n : inferCapabilitiesByProvider(key.provider),\n ),\n );\n\n return [\n {\n capability: 'text',\n supported: covered.has('text'),\n reason: covered.has('text')\n ? '至少一个文本模型 Key 可用。'\n : '未找到文本模型 Key — 提示词润色、AI 对话不可用。',\n },\n {\n capability: 'image',\n supported: covered.has('image'),\n reason: covered.has('image')\n ? '至少一个图片模型 Key 可用。'\n : '未找到图片模型 Key — AI 绘图、图片编辑不可用。',\n },\n {\n capability: 'video',\n supported: covered.has('video'),\n reason: covered.has('video')\n ? '至少一个视频模型 Key 可用。'\n : '未找到视频模型 Key — AI 视频生成不可用。',\n },\n ];\n}\n"}}, "components/OnboardingWizard.tsx": {"00cb21f445793d666f08232eea73c359cbd0e39d": {"t": 1786068605.2417228, "n": 32998, "h": "e64ec948ddd00ef4", "c": "/**\n * ============================================\n * 新用户 API Key 引导向导 (Onboarding Wizard)\n * ============================================\n *\n * 【功能】\n * 当用户首次使用(没有任何 API Key 配置)时,自动弹出引导弹窗,\n * 用最简单的 3 步流程帮助小白用户完成 API Key 的配置。\n *\n * 【步骤】\n * Step 1: 欢迎页 — 介绍 Flovart 并引导用户获取 API Key\n * Step 2: 输入 API Key — 一个输入框 + 自动验证\n * Step 3: 完成 — 确认配置成功,可以开始创作\n *\n * 【设计原则】\n * - 小白友好:默认 Google Gemini,只需粘贴一个 Key\n * - 自动推断 capabilities\n * - 验证通过才允许继续\n * - 可随时跳过(点击\"稍后再说\")\n */\n\nimport React, { useState } from 'react';\nimport type { AIProvider, AICapability, ModelItem, UserApiKey } from '../types';\nimport { validateApiKey } from '../services/aiGateway';\nimport { fetchModelsForProvider, type FetchModelsResult } from '../services/modelFetcher';\n\ninterface OnboardingWizardProps {\n /** 是否显示弹窗 */\n isOpen: boolean;\n /** 关闭/跳过弹窗 */\n onClose: () => void;\n /** 保存新 API Key 的回调 */\n onAddApiKey: (payload: Omit<UserApiKey, 'id' | 'createdAt' | 'updatedAt'>) => void;\n /** 当前亮/暗主题 */\n resolvedTheme: 'light' | 'dark';\n}\n\n/** 各步骤标题 */\nconst STEPS = [\n { title: '欢迎使用 Flovart', subtitle: '让我们花 30 秒完成配置' },\n { title: '粘贴你的 API Key', subtitle: '只需一步,即可开始 AI 创作' },\n { title: '配置完成 🎉', subtitle: '一切就绪,开始创作吧' },\n] as const;\n\n/** Provider 对应的默认 capabilities */\nconst PROVIDER_CAPABILITIES: Record<AIProvider, AICapability[]> = {\n google: ['text', 'image', 'video'],\n xai: ['text', 'video'],\n openai: ['text'],\n anthropic: ['text'],\n qwen: ['text'],\n deepseek: ['text'],\n siliconflow: ['text', 'image'],\n keling: ['image', 'video'],\n flux: ['image'],\n midjourney: ['image'],\n runningHub: ['image'],\n minimax: ['text', 'image', 'video'],\n volcengine: ['text'],\n openrouter: ['text', 'image'],\n openai_compatible: ['text', 'image'],\n custom: ['text', 'image'],\n};\n\n/** Provider 可读标签 */\nconst PROVIDER_LABELS: Record<string, string> = {\n google: 'Google Gemini',\n openai: 'OpenAI',\n anthropic: 'Anthropic Claude',\n qwen: 'Qwen 通义千问',\n deepseek: 'DeepSeek',\n siliconflow: 'SiliconFlow',\n runningHub: 'RunningHub 标准模型',\n keling: 'Keling 可灵',\n flux: 'Flux',\n midjourney: 'Midjourney',\n custom: '自定义 / 第三方',\n};\n\nconst CAPABILITY_LABELS: Record<AICapability, string> = {\n text: '✏️ LLM润色',\n image: '🖼️ 图片生成',\n video: '🎬 视频生成',\n agent: '图像工具',\n};\n\nconst ENDPOINT_FLAVOR_LABELS: Record<'google' | 'openai-compatible' | 'openrouter-compatible', string> = {\n google: 'Google 原生风格',\n 'openai-compatible': 'OpenAI 兼容风格',\n 'openrouter-compatible': 'OpenRouter 风格',\n};\n\nexport const OnboardingWizard: React.FC<OnboardingWizardProps> = ({\n isOpen,\n onClose,\n onAddApiKey,\n resolvedTheme,\n}) => {\n const [step, setStep] = useState(0);\n const [provider, setProvider] = useState<AIProvider>('google');\n const [apiKey, setApiKey] = useState('');\n const [showKey, setShowKey] = useState(false);\n const [isValidating, setIsValidating] = useState(false);\n const [error, setError] = useState<string | null>(null);\n // ── 自定义第三方 API 专用状态 ──\n const [customBaseUrl, setCustomBaseUrl] = useState('');\n const [customCaps, setCustomCaps] = useState<AICapability[]>(['text', 'image']);\n const [customModelInput, setCustomModelInput] = useState('');\n const [isDetectingModels, setIsDetectingModels] = useState(false);\n const [detectedCapabilities, setDetectedCapabilities] = useState<AICapability[]>([]);\n const [detectedModels, setDetectedModels] = useState<ModelItem[]>([]);\n const [endpointFlavor, setEndpointFlavor] = useState<'google' | 'openai-compatible' | 'openrouter-compatible' | null>(null);\n const [showAdvancedCustom, setShowAdvancedCustom] = useState(false);\n\n if (!isOpen) return null;\n\n const isDark = resolvedTheme === 'dark';\n\n // ── 样式工具 ──\n const cardBg = 'isl-shell';\n const textPrimary = 'text-[var(--isl-ink)]';\n const textSecondary = 'text-[var(--isl-ink-soft)]';\n const inputClass = 'isl-well w-full px-4 py-3 text-sm text-[var(--isl-ink)] outline-none placeholder:text-[var(--isl-ink-ghost)]';\n const primaryBtn = 'isl-go px-6 py-3 text-sm';\n const secondaryBtn = 'isl-chip px-5 py-3 text-sm';\n\n const parseModelItems = (value: string): ModelItem[] => value\n .split(',')\n .map(item => item.trim())\n .filter(Boolean)\n .map(id => ({ id, name: id }));\n\n const mergeModelItems = (primary: ModelItem[], secondary: ModelItem[]): ModelItem[] => {\n const merged = new Map<string, ModelItem>();\n for (const item of [...primary, ...secondary]) {\n if (!merged.has(item.id)) {\n merged.set(item.id, item);\n }\n }\n return Array.from(merged.values());\n };\n\n const resetCustomDetection = () => {\n setDetectedCapabilities([]);\n setDetectedModels([]);\n setEndpointFlavor(null);\n };\n\n const applyDetectedResult = (result: FetchModelsResult) => {\n const models = result.models.map(model => ({ id: model.id, name: model.name || model.id, capability: model.capability }));\n setDetectedModels(models);\n setDetectedCapabilities(result.capabilitySummary || Array.from(new Set(result.models.map(model => model.capability))));\n setEndpointFlavor(result.endpointFlavor || null);\n };\n\n const detectCustomEndpoint = async (silent = false): Promise<FetchModelsResult | null> => {\n if (!customBaseUrl.trim()) {\n if (!silent) setError('请先填写 Base URL');\n return null;\n }\n if (!apiKey.trim()) {\n if (!silent) setError('请先填写 API Key');\n return null;\n }\n\n setIsDetectingModels(true);\n if (!silent) setError(null);\n try {\n const result = await fetchModelsForProvider('custom', apiKey.trim(), customBaseUrl.trim());\n if (result.ok) {\n applyDetectedResult(result);\n } else if (!silent) {\n setError(result.error || '自动识别失败,请手动勾选能力');\n }\n return result;\n } catch (err) {\n if (!silent) {\n setError(err instanceof Error ? err.message : '自动识别失败,请手动勾选能力');\n }\n return null;\n } finally {\n setIsDetectingModels(false);\n }\n };\n\n /**\n * 验证并保存 API Key\n * 验证通过后自动推断 provider capabilities 并保存\n */\n const handleValidateAndSave = async () => {\n if (!apiKey.trim()) return;\n if (provider === 'custom' && !customBaseUrl.trim()) {\n setError('自定义提供商必须填写 Base URL');\n return;\n }\n setIsValidating(true);\n setError(null);\n\n try {\n const baseUrlForValidation = provider === 'custom' ? customBaseUrl.trim() || undefined : undefined;\n const result = await validateApiKey(provider, apiKey.trim(), baseUrlForValidation);\n if (result.ok) {\n // 验证通过,保存 Key 并进入完成页\n const manualModels = provider === 'custom' ? parseModelItems(customModelInput) : [];\n let caps = provider === 'custom'\n ? (detectedCapabilities.length > 0 ? detectedCapabilities : customCaps)\n : PROVIDER_CAPABILITIES[provider];\n let modelsToSave: ModelItem[] | undefined;\n let customModels: string[] | undefined;\n let defaultModel: string | undefined;\n let extraConfig: Record<string, string> | undefined;\n\n if (provider === 'custom') {\n const detectionResult = await detectCustomEndpoint(true);\n const fetchedModels = detectionResult?.ok\n ? detectionResult.models.map(model => ({ id: model.id, name: model.name || model.id, capability: model.capability }))\n : detectedModels;\n const resolvedEndpointFlavor = detectionResult?.endpointFlavor\n || endpointFlavor\n || (/openrouter/i.test(customBaseUrl) ? 'openrouter-compatible' : 'openai-compatible');\n\n if (fetchedModels.length > 0) {\n modelsToSave = mergeModelItems(fetchedModels, manualModels);\n caps = detectionResult?.capabilitySummary && detectionResult.capabilitySummary.length > 0\n ? detectionResult.capabilitySummary\n : detectionResult?.ok\n ? Array.from(new Set(detectionResult.models.map(model => model.capability)))\n : caps;\n defaultModel = fetchedModels[0]?.id;\n } else {\n modelsToSave = manualModels;\n defaultModel = modelsToSave[0]?.id;\n }\n\n customModels = modelsToSave.map(model => model.id);\n extraConfig = resolvedEndpointFlavor ? { endpointFlavor: resolvedEndpointFlavor } : undefined;\n }\n\n onAddApiKey({\n provider,\n capabilities: caps,\n key: apiKey.trim(),\n name: provider === 'custom'\n ? (() => { try { return `自定义 (${new URL(customBaseUrl.trim()).host})`; } catch { return '自定义 API'; } })()\n : `${PROVIDER_LABELS[provider] || provider} Key`,\n status: 'ok',\n isDefault: true,\n ...(provider === 'custom' && {\n baseUrl: customBaseUrl.trim(),\n models: modelsToSave,\n customModels,\n defaultModel,\n extraConfig,\n }),\n });\n setStep(2);\n } else {\n setError(result.message || 'API Key 无效,请检查后重试');\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : '验证时发生错误');\n } finally {\n setIsValidating(false);\n }\n };\n\n /** 回车提交 */\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (e.key === 'Enter' && apiKey.trim() && !isValidating) {\n handleValidateAndSave();\n }\n };\n\n return (\n <div className=\"fixed inset-0 z-200 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center\">\n <div\n className={`relative flex max-h-[calc(100dvh-0.5rem)] w-full max-w-120 flex-col overflow-hidden rounded-t-4xl border shadow-[0_48px_120px_rgba(0,0,0,0.2)] sm:max-h-[calc(100dvh-2rem)] sm:w-[90%] sm:rounded-4xl ${cardBg}`}\n onClick={(e) => e.stopPropagation()}\n >\n {/* ── 进度指示器(固定顶部) ── */}\n <div className=\"shrink-0 px-8 pt-8 pb-2\">\n <div className=\"flex justify-center gap-2\">\n {STEPS.map((_, i) => (\n <div\n key={i}\n className={`h-1.5 rounded-full transition-all duration-300 ${\n i === step\n ? 'w-8 bg-[var(--isl-mint)]'\n : i < step\n ? 'w-4 bg-[var(--isl-mint-deep)]'\n : 'w-4 bg-[var(--isl-border)]'\n }`}\n />\n ))}\n </div>\n </div>\n\n {/* ── 滚动内容区 ── */}\n <div className=\"min-h-0 flex-1 overflow-y-auto px-8 pb-8\">\n\n {/* ── Step 0: 欢迎页 ── */}\n {step === 0 && (\n <div className=\"text-center\">\n <div className=\"mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-3xl bg-[var(--isl-mint)] text-3xl\" style={{ boxShadow: '0 4px 0 0 var(--isl-edge-mint)' }}>\n 🎨\n </div>\n <h2 className={`mb-2 text-xl font-bold ${textPrimary}`}>\n {STEPS[0].title}\n </h2>\n <p className={`mb-2 text-sm ${textSecondary}`}>\n {STEPS[0].subtitle}\n </p>\n <p className={`mb-8 text-sm leading-relaxed ${textSecondary}`}>\n Flovart 使用 AI 帮你在工作流中生成图片和视频。<br />\n 你可以直接接入 <strong className={textPrimary}>Google Gemini</strong>,也可以填入你自己的兼容端点,向导会尽量自动识别模型和能力。\n </p>\n\n <div className=\"space-y-3\">\n <button type=\"button\" onClick={() => setStep(1)} className={primaryBtn + ' w-full'}>\n 开始配置 →\n </button>\n <a\n href=\"https://aistudio.google.com/apikey\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className={`block text-center text-sm font-medium text-blue-500 hover:text-blue-600`}\n >\n 还没有 API Key?点击这里免费获取 ↗\n </a>\n <button type=\"button\" onClick={onClose} className={secondaryBtn + ' w-full'}>\n 稍后再说\n </button>\n </div>\n </div>\n )}\n\n {/* ── Step 1: 输入 API Key ── */}\n {step === 1 && (\n <div>\n <h2 className={`mb-1 text-lg font-bold ${textPrimary}`}>\n {STEPS[1].title}\n </h2>\n <p className={`mb-6 text-sm ${textSecondary}`}>\n {STEPS[1].subtitle}\n </p>\n\n {/* Provider 选择(默认 Google,可切换) */}\n <div className=\"mb-4\">\n <label className={`mb-2 block text-xs font-semibold uppercase tracking-wider ${textSecondary}`}>\n AI 服务商\n </label>\n <div className=\"flex flex-wrap gap-2\">\n {(['google', 'openai', 'anthropic', 'deepseek', 'siliconflow', 'runningHub', 'qwen', 'keling', 'flux', 'midjourney', 'custom'] as AIProvider[]).map(p => (\n <button\n key={p}\n type=\"button\"\n onClick={() => {\n setProvider(p);\n setError(null);\n setShowAdvancedCustom(false);\n resetCustomDetection();\n }}\n className={`rounded-full border px-3 py-1.5 text-xs font-medium transition ${\n provider === p\n ? isDark\n ? 'border-blue-500 bg-blue-500/20 text-blue-400'\n : 'border-blue-500 bg-blue-50 text-blue-600'\n : isDark\n ? 'border-[#2A3140] text-[#98A2B3] hover:bg-[#1B2029]'\n : 'border-[#E4E7EC] text-[#667085] hover:bg-[#F9FAFB]'\n }`}\n >\n {PROVIDER_LABELS[p] || p}\n </button>\n ))}\n </div>\n </div>\n\n {/* API Key 输入框 */}\n <div className=\"mb-4\">\n <label className={`mb-2 block text-xs font-semibold uppercase tracking-wider ${textSecondary}`}>\n API Key\n </label>\n <div className=\"relative\">\n <input\n value={apiKey}\n onChange={(e) => {\n setApiKey(e.target.value);\n setError(null);\n if (provider === 'custom') resetCustomDetection();\n }}\n onKeyDown={handleKeyDown}\n type={showKey ? 'text' : 'password'}\n placeholder={provider === 'google' ? 'AIzaSy...' : 'sk-...'}\n className={inputClass}\n autoFocus\n />\n <button\n type=\"button\"\n onClick={() => setShowKey(prev => !prev)}\n className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs ${textSecondary} hover:${textPrimary}`}\n >\n {showKey ? '隐藏' : '显示'}\n </button>\n </div>\n </div>\n\n {provider === 'custom' && (\n <div className=\"mb-4\">\n <button\n type=\"button\"\n onClick={() => setShowAdvancedCustom(prev => !prev)}\n className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left text-sm font-medium transition ${\n isDark ? 'border-[#2A3140] bg-[#161A22] text-[#D0D5DD] hover:bg-[#1B2029]' : 'border-[#E4E7EC] bg-[#F8FAFC] text-[#344054] hover:bg-white'\n }`}\n >\n <span>高级第三方 API 设置</span>\n <span className=\"text-xs opacity-70\">{showAdvancedCustom ? '收起' : '展开'}</span>\n </button>\n </div>\n )}\n\n {provider === 'custom' && showAdvancedCustom && (\n <div className={`mb-4 rounded-3xl border p-4 ${isDark ? 'border-[#2A3140] bg-[#10141B]' : 'border-[#E4E7EC] bg-[#FBFCFE]'}`}>\n <div className=\"mb-4\">\n <label className={`mb-2 block text-xs font-semibold uppercase tracking-wider ${textSecondary}`}>\n Base URL <span className=\"normal-case text-red-400\">*</span>\n </label>\n <input\n value={customBaseUrl}\n onChange={(e) => {\n setCustomBaseUrl(e.target.value);\n resetCustomDetection();\n }}\n onKeyDown={event => event.stopPropagation()}\n onKeyUp={event => event.stopPropagation()}\n placeholder=\"https://api.example.com/v1\"\n className={inputClass}\n />\n </div>\n <div className=\"mb-4\">\n <label className={`mb-2 block text-xs font-semibold uppercase tracking-wider ${textSecondary}`}>\n 支持的能力\n </label>\n <div className=\"flex flex-wrap gap-2\">\n {(['text', 'image', 'video'] as AICapability[]).map(cap => (\n <button\n key={cap}\n type=\"button\"\n onClick={() => setCustomCaps(prev =>\n prev.includes(cap) ? prev.filter(c => c !== cap) : [...prev, cap]\n )}\n className={`rounded-full border px-3 py-1.5 text-xs font-medium transition ${\n customCaps.includes(cap)\n ? isDark\n ? 'border-green-500 bg-green-500/20 text-green-400'\n : 'border-green-500 bg-green-50 text-green-600'\n : isDark\n ? 'border-[#2A3140] text-[#98A2B3] hover:bg-[#1B2029]'\n : 'border-[#E4E7EC] text-[#667085] hover:bg-[#F9FAFB]'\n }`}\n >\n {cap === 'text' ? 'LLM 润色' : cap === 'image' ? '图片生成' : '视频生成'}\n </button>\n ))}\n </div>\n </div>\n <div>\n <label className={`mb-2 block text-xs font-semibold uppercase tracking-wider ${textSecondary}`}>\n 模型名称 <span className=\"normal-case opacity-60\">(可选, 逗号分隔)</span>\n </label>\n <input\n value={customModelInput}\n onChange={(e) => setCustomModelInput(e.target.value)}\n placeholder=\"gpt-5.4, claude-opus-4-6, ...\"\n className={inputClass}\n />\n </div>\n </div>\n )}\n\n {provider === 'custom' && (\n <div className=\"mb-4 space-y-3\">\n <button\n type=\"button\"\n onClick={() => detectCustomEndpoint(false)}\n disabled={!customBaseUrl.trim() || !apiKey.trim() || isDetectingModels}\n className={`${secondaryBtn} w-full rounded-2xl border ${\n isDark ? 'border-[#2A3140] bg-[#161A22]' : 'border-[#E4E7EC] bg-[#F8FAFC]'\n } disabled:cursor-not-allowed disabled:opacity-50`}\n >\n {isDetectingModels ? '正在自动识别模型与能力...' : '自动识别模型与能力'}\n </button>\n\n {(endpointFlavor || detectedCapabilities.length > 0 || detectedModels.length > 0) && (\n <div className={`rounded-2xl border px-4 py-3 text-xs leading-relaxed ${\n isDark ? 'border-[#2A3140] bg-[#161A22] text-[#D0D5DD]' : 'border-[#E4E7EC] bg-[#F8FAFC] text-[#475467]'\n }`}>\n {endpointFlavor && (\n <div>\n 兼容端点识别:<strong className=\"ml-1\">{ENDPOINT_FLAVOR_LABELS[endpointFlavor]}</strong>\n </div>\n )}\n {detectedCapabilities.length > 0 && (\n <div className=\"mt-1\">\n 自动识别能力:{detectedCapabilities.map(cap => CAPABILITY_LABELS[cap]).join(' / ')}\n </div>\n )}\n {detectedModels.length > 0 && (\n <div className=\"mt-1\">已识别 {detectedModels.length} 个模型,保存后会自动写入默认模型。</div>\n )}\n </div>\n )}\n </div>\n )}\n\n {/* 小提示 */}\n <div className={`mb-4 rounded-2xl p-3 text-xs leading-relaxed ${isDark ? 'bg-[#161A22] text-[#98A2B3]' : 'bg-[#F8FAFC] text-[#667085]'}`}>\n {provider === 'google' && (\n <>\n 💡 <strong>获取方法</strong>:访问{' '}\n <a href=\"https://aistudio.google.com/apikey\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-500 underline\">\n Google AI Studio\n </a>\n {' '}→ 点击「Create API Key」→ 复制粘贴到这里。\n <br />\n <span className=\"mt-1 inline-block\">一个 Key 即可使用文生图、图生图和视频生成全部功能。</span>\n </>\n )}\n {provider === 'openai' && (\n <>\n 💡 访问{' '}\n <a href=\"https://platform.openai.com/api-keys\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-blue-500 underline\">\n OpenAI Platform\n </a>\n {' '}→ 创建新 Key → 复制粘贴到这里。\n </>\n )}\n {provider === 'anthropic' && '💡 支持 Claude 模型的提示词润色功能。'}\n {provider === 'qwen' && '💡 支持通义千问模型的提示词润色功能。'}\n {provider === 'custom' && (\n <>\n 💡 优先支持 <strong className={textPrimary}>OpenAI 兼容 / OpenRouter 风格</strong> 的第三方 API。<br />\n 填入 Base URL(如 <code className=\"text-blue-500\">https://api.xxx.com/v1</code>)和 API Key 后,可先点一次“自动识别模型与能力”。\n <br />\n <span className=\"mt-1 inline-block\">适用于 Ollama / vLLM / LiteLLM / OneAPI / New API / 各类中转站等,但不同中转站对图片编辑和视频能力支持并不完全一致。</span>\n </>\n )}\n </div>\n\n {/* 错误提示 */}\n {error && (\n <div className={`mb-4 rounded-2xl px-4 py-3 text-sm ${\n isDark ? 'bg-[#3A1616] text-[#FDA29B]' : 'bg-[#FEF3F2] text-[#B42318]'\n }`}>\n ✗ {error}\n </div>\n )}\n\n {/* 自动推断的功能 */}\n <div className={`mb-6 flex items-center gap-2 text-xs ${textSecondary}`}>\n <span>自动启用:</span>\n {(provider === 'custom'\n ? (detectedCapabilities.length > 0 ? detectedCapabilities : customCaps)\n : PROVIDER_CAPABILITIES[provider]).map(cap => (\n <span key={cap} className={`rounded-full px-2 py-0.5 ${isDark ? 'bg-[#1B2330] text-[#7CB4FF]' : 'bg-[#EFF6FF] text-[#175CD3]'}`}>\n {CAPABILITY_LABELS[cap]}\n </span>\n ))}\n </div>\n </div>\n )}\n\n {/* ── Step 2: 完成 ── */}\n {step === 2 && (\n <div className=\"text-center\">\n <div className=\"mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-3xl bg-gradient-to-br from-green-400 to-emerald-600 text-3xl shadow-lg\">\n ✨\n </div>\n <h2 className={`mb-2 text-xl font-bold ${textPrimary}`}>\n {STEPS[2].title}\n </h2>\n <p className={`mb-6 text-sm ${textSecondary}`}>\n {STEPS[2].subtitle}\n </p>\n\n <div className={`mx-auto mb-6 max-w-xs rounded-2xl p-4 text-left ${isDark ? 'bg-[#161A22]' : 'bg-[#F8FAFC]'}`}>\n <div className={`mb-2 text-xs font-semibold ${textSecondary}`}>已配置</div>\n <div className=\"flex items-center gap-2\">\n <span className=\"rounded-full bg-green-500/20 px-2 py-1 text-xs font-medium text-green-500\">✓ 已验证</span>\n <span className={`text-sm font-medium ${textPrimary}`}>{PROVIDER_LABELS[provider] || provider}</span>\n </div>\n <div className={`mt-2 text-xs ${textSecondary}`}>\n 可用功能:{(provider === 'custom' ? customCaps : PROVIDER_CAPABILITIES[provider]).map(c =>\n c === 'text' ? 'LLM润色' : c === 'image' ? '图片生成' : c === 'video' ? '视频生成' : 'Agent'\n ).join('、')}\n </div>\n </div>\n\n <div className={`mb-6 rounded-2xl p-4 text-left text-xs leading-relaxed ${isDark ? 'bg-[#161A22] text-[#98A2B3]' : 'bg-[#F8FAFC] text-[#667085]'}`}>\n <div className=\"mb-2 font-semibold\">💡 快速上手</div>\n <ol className=\"ml-4 list-decimal space-y-1\">\n <li>在底部输入栏输入提示词,如「一只在星空下飞翔的猫」</li>\n <li>点击「生成」或按 Enter</li>\n <li>AI 生成的图片会自动写回工作流节点</li>\n <li>选中图片后输入新提示词可以进一步编辑</li>\n </ol>\n </div>\n\n <button type=\"button\" onClick={onClose} className={primaryBtn + ' w-full'}>\n 开始创作 🎨\n </button>\n </div>\n )}\n </div>{/* end scrollable area */}\n\n {/* ── Step 1 固定底部按钮区 ── */}\n {step === 1 && (\n <div className={`shrink-0 border-t px-8 py-4 ${isDark ? 'border-[#2A3140]' : 'border-[#E4E7EC]'}`}>\n <div className=\"flex gap-3\">\n <button type=\"button\" onClick={() => { setStep(0); setError(null); }} className={secondaryBtn}>\n ← 返回\n </button>\n <button\n type=\"button\"\n onClick={handleValidateAndSave}\n disabled={!apiKey.trim() || isValidating}\n className={`${primaryBtn} flex-1 disabled:cursor-not-allowed disabled:opacity-50`}\n >\n {isValidating ? (\n <span className=\"flex items-center justify-center gap-2\">\n <svg className=\"h-4 w-4 animate-spin\" viewBox=\"0 0 24 24\" fill=\"none\">\n <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\" />\n <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\" />\n </svg>\n 验证中...\n </span>\n ) : '验证并保存 →'}\n </button>\n </div>\n </div>\n )}\n\n </div>\n </div>\n );\n};\n"}}}} |