Spaces:
Running
Running
| import { | |
| MAX_IMAGE_COUNT, | |
| MAX_PROMPT_LENGTH, | |
| MAX_UPLOAD_BYTES, | |
| RequestValidationError, | |
| validateApiBaseUrl, | |
| type GptImageModel, | |
| type ValidOutputFormat | |
| } from './image-request-utils'; | |
| import { | |
| GPT_IMAGE_2_EDGE_MULTIPLE, | |
| GPT_IMAGE_2_MAX_ASPECT, | |
| GPT_IMAGE_2_MAX_EDGE, | |
| GPT_IMAGE_2_MAX_PIXELS, | |
| GPT_IMAGE_2_MIN_PIXELS, | |
| validateGptImage2Size | |
| } from './size-utils'; | |
| import type { AgentErrorDiagnostics } from './api-error-response'; | |
| export const AGENT_API_VERSION = '1.0.0'; | |
| export const AGENT_SCHEMA_VERSION = '2026-05-20'; | |
| export const AGENT_DEFAULT_SQLITE_PATH = 'generated-images/.agent-state/agent.sqlite'; | |
| export const AGENT_DEFAULT_LEASE_MS = 10 * 60 * 1000; | |
| export const AGENT_DEFAULT_REQUEST_TTL_SECONDS = 24 * 60 * 60; | |
| export const AGENT_DEFAULT_RECOVERY_INTERVAL_MS = 30 * 1000; | |
| export const AGENT_MODELS = ['gpt-image-1', 'gpt-image-1-mini', 'gpt-image-1.5', 'gpt-image-2'] as const; | |
| export const AGENT_OUTPUT_FORMATS = ['png', 'jpeg', 'webp'] as const; | |
| export const AGENT_RESPONSE_MODES = ['path', 'base64', 'both'] as const; | |
| export const AGENT_QUALITIES = ['low', 'medium', 'high', 'auto'] as const; | |
| export const AGENT_BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const; | |
| export const AGENT_MODERATIONS = ['low', 'auto'] as const; | |
| export const AGENT_LEGACY_SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const; | |
| export const AGENT_JOB_STATES = ['queued', 'running', 'succeeded', 'failed', 'expired'] as const; | |
| export type AgentStateBackend = 'memory' | 'sqlite' | 'postgres'; | |
| export type AgentAuthScheme = 'bearer' | 'x-app-password-hash'; | |
| export type AgentResponseMode = (typeof AGENT_RESPONSE_MODES)[number]; | |
| export type AgentQuality = (typeof AGENT_QUALITIES)[number]; | |
| export type AgentBackground = (typeof AGENT_BACKGROUNDS)[number]; | |
| export type AgentModeration = (typeof AGENT_MODERATIONS)[number]; | |
| export type AgentJobState = (typeof AGENT_JOB_STATES)[number]; | |
| export type AgentGenerateRequest = { | |
| model: GptImageModel; | |
| prompt: string; | |
| n: number; | |
| size: string; | |
| quality: AgentQuality; | |
| output_format: ValidOutputFormat; | |
| output_compression?: number; | |
| background: AgentBackground; | |
| moderation: AgentModeration; | |
| response_mode: AgentResponseMode; | |
| }; | |
| export type AgentImageResponseItem = { | |
| id: string; | |
| filename: string; | |
| content_url: string; | |
| metadata_url: string; | |
| output_format: string; | |
| mime_type: string; | |
| size_bytes: number; | |
| width: number | null; | |
| height: number | null; | |
| b64_json?: string; | |
| }; | |
| export type AgentImageResponse = { | |
| request_id: string; | |
| idempotency_key: string; | |
| cached: boolean; | |
| images: AgentImageResponseItem[]; | |
| usage?: unknown; | |
| created_at: string; | |
| }; | |
| export type AgentJobStatusResponse = { | |
| job: { | |
| id: string; | |
| request_id: string; | |
| idempotency_key: string; | |
| mode: 'generate' | 'edit'; | |
| state: AgentJobState; | |
| created_at: string; | |
| updated_at: string; | |
| expires_at: string; | |
| result_url?: string; | |
| retry_after_seconds?: number; | |
| error?: { | |
| code: string; | |
| message: string; | |
| retryable: boolean; | |
| details?: Record<string, unknown>; | |
| upstream_status?: number; | |
| diagnostics?: AgentErrorDiagnostics; | |
| }; | |
| }; | |
| }; | |
| export type AgentCapabilities = { | |
| api_version: string; | |
| schema_version: string; | |
| auth: { | |
| required: boolean; | |
| schemes: AgentAuthScheme[]; | |
| }; | |
| endpoints: Record<string, string>; | |
| defaults: { | |
| model: GptImageModel; | |
| response_mode: AgentResponseMode; | |
| state_backend: AgentStateBackend; | |
| }; | |
| limits: { | |
| max_prompt_length: number; | |
| max_images: number; | |
| max_upload_mb: number; | |
| partial_images: { min: number; max: number }; | |
| }; | |
| model_limits: { | |
| 'gpt-image-2': { | |
| max_edge: number; | |
| max_pixels: number; | |
| edge_multiple: number; | |
| max_aspect: number; | |
| min_pixels: number; | |
| recommended_presets: Array<{ name: string; size: string; purpose: string }>; | |
| high_4k_risk: { | |
| applies_to: string[]; | |
| guidance: string; | |
| }; | |
| }; | |
| }; | |
| agent_streaming: { | |
| generate: { | |
| supported: false; | |
| mode: 'non_streaming_only'; | |
| endpoint: string; | |
| }; | |
| edit: { | |
| supported: false; | |
| mode: 'non_streaming_only'; | |
| endpoint: string; | |
| }; | |
| page_sse: { | |
| supported: true; | |
| mode: 'form_data_sse'; | |
| endpoint: string; | |
| contract: 'page_ui_only'; | |
| }; | |
| }; | |
| agent_jobs: { | |
| supported: true; | |
| mode: 'job_polling'; | |
| intended_for: string[]; | |
| endpoints: { | |
| create_generate_job: string; | |
| get_job: string; | |
| get_job_result: string; | |
| }; | |
| states: readonly string[]; | |
| current_guidance: string; | |
| }; | |
| supported: { | |
| models: readonly string[]; | |
| output_formats: readonly string[]; | |
| response_modes: readonly string[]; | |
| qualities: readonly string[]; | |
| backgrounds: readonly string[]; | |
| moderations: readonly string[]; | |
| legacy_sizes: readonly string[]; | |
| }; | |
| storage: { | |
| image_storage_mode: string; | |
| postgres_configured: boolean; | |
| }; | |
| idempotency: { | |
| required: boolean; | |
| header: string; | |
| ttl_seconds: number; | |
| }; | |
| }; | |
| type FieldErrors = Record<string, string>; | |
| function isOneOf<T extends readonly string[]>(value: string, allowed: T): value is T[number] { | |
| return (allowed as readonly string[]).includes(value); | |
| } | |
| function readStringField(body: Record<string, unknown>, field: string, fallback?: string): string | undefined { | |
| const value = body[field]; | |
| if (value === undefined || value === null) return fallback; | |
| return typeof value === 'string' ? value : undefined; | |
| } | |
| function readIntegerField( | |
| body: Record<string, unknown>, | |
| field: string, | |
| fallback: number, | |
| min: number, | |
| max: number, | |
| fields: FieldErrors | |
| ): number { | |
| const value = body[field]; | |
| if (value === undefined || value === null || value === '') return fallback; | |
| const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; | |
| if (!Number.isInteger(parsed) || parsed < min || parsed > max) { | |
| fields[field] = `必须是 ${min} 到 ${max} 之间的整数`; | |
| return fallback; | |
| } | |
| return parsed; | |
| } | |
| function validatePrompt(value: unknown, fields: FieldErrors): string { | |
| if (typeof value !== 'string' || value.trim().length === 0) { | |
| fields.prompt = '必填'; | |
| return ''; | |
| } | |
| if (value.length > MAX_PROMPT_LENGTH) { | |
| fields.prompt = `长度不能超过 ${MAX_PROMPT_LENGTH} 个字符`; | |
| } | |
| return value; | |
| } | |
| function readModel(body: Record<string, unknown>, fields: FieldErrors): GptImageModel { | |
| const value = readStringField(body, 'model', 'gpt-image-2'); | |
| if (!value || !isOneOf(value, AGENT_MODELS)) { | |
| fields.model = `必须是以下值之一:${AGENT_MODELS.join(', ')}`; | |
| return 'gpt-image-2'; | |
| } | |
| return value; | |
| } | |
| function readSize(body: Record<string, unknown>, model: GptImageModel, fields: FieldErrors, fallback: string): string { | |
| const value = readStringField(body, 'size', fallback); | |
| if (!value) { | |
| fields.size = '必须是字符串'; | |
| return fallback; | |
| } | |
| if (model !== 'gpt-image-2' && !isOneOf(value, AGENT_LEGACY_SIZES)) { | |
| fields.size = `${model} 的 size 必须是以下值之一:${AGENT_LEGACY_SIZES.join(', ')}`; | |
| return fallback; | |
| } | |
| if (model === 'gpt-image-2' && value !== 'auto') { | |
| const match = /^(\d+)x(\d+)$/.exec(value); | |
| if (!match) { | |
| fields.size = '必须是 auto 或 WxH 格式的尺寸值'; | |
| return value; | |
| } | |
| const validation = validateGptImage2Size(Number(match[1]), Number(match[2])); | |
| if (!validation.valid) { | |
| fields.size = validation.reason; | |
| } | |
| } | |
| return value; | |
| } | |
| function readOutputFormat(body: Record<string, unknown>, fields: FieldErrors): ValidOutputFormat { | |
| const rawValue = readStringField(body, 'output_format', 'png'); | |
| const normalized = rawValue?.toLowerCase() === 'jpg' ? 'jpeg' : rawValue?.toLowerCase(); | |
| if (!normalized || !isOneOf(normalized, AGENT_OUTPUT_FORMATS)) { | |
| fields.output_format = `必须是以下值之一:${AGENT_OUTPUT_FORMATS.join(', ')}`; | |
| return 'png'; | |
| } | |
| return normalized; | |
| } | |
| function readResponseMode(body: Record<string, unknown>, fields: FieldErrors): AgentResponseMode { | |
| const value = readStringField(body, 'response_mode', 'path'); | |
| if (!value || !isOneOf(value, AGENT_RESPONSE_MODES)) { | |
| fields.response_mode = `必须是以下值之一:${AGENT_RESPONSE_MODES.join(', ')}`; | |
| return 'path'; | |
| } | |
| return value; | |
| } | |
| function readQuality(body: Record<string, unknown>, fields: FieldErrors): AgentQuality { | |
| const value = readStringField(body, 'quality', 'high'); | |
| if (!value || !isOneOf(value, AGENT_QUALITIES)) { | |
| fields.quality = `必须是以下值之一:${AGENT_QUALITIES.join(', ')}`; | |
| return 'high'; | |
| } | |
| return value; | |
| } | |
| function readBackground(body: Record<string, unknown>, model: GptImageModel, fields: FieldErrors): AgentBackground { | |
| const value = readStringField(body, 'background', 'auto'); | |
| if (!value || !isOneOf(value, AGENT_BACKGROUNDS)) { | |
| fields.background = `必须是以下值之一:${AGENT_BACKGROUNDS.join(', ')}`; | |
| return 'auto'; | |
| } | |
| if (model === 'gpt-image-2' && value === 'transparent') { | |
| fields.background = 'gpt-image-2 不支持 transparent 背景'; | |
| } | |
| return value; | |
| } | |
| function readModeration(body: Record<string, unknown>, fields: FieldErrors): AgentModeration { | |
| const value = readStringField(body, 'moderation', 'auto'); | |
| if (!value || !isOneOf(value, AGENT_MODERATIONS)) { | |
| fields.moderation = `必须是以下值之一:${AGENT_MODERATIONS.join(', ')}`; | |
| return 'auto'; | |
| } | |
| return value; | |
| } | |
| function readOutputCompression( | |
| body: Record<string, unknown>, | |
| outputFormat: ValidOutputFormat, | |
| fields: FieldErrors | |
| ): number | undefined { | |
| const value = body.output_compression; | |
| if (value === undefined || value === null || value === '') return undefined; | |
| if (outputFormat === 'png') { | |
| fields.output_compression = '仅适用于 jpeg 或 webp 输出'; | |
| return undefined; | |
| } | |
| const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; | |
| if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { | |
| fields.output_compression = '必须是 0 到 100 之间的整数'; | |
| return undefined; | |
| } | |
| return parsed; | |
| } | |
| export function validateAgentGenerateRequest(body: unknown): AgentGenerateRequest { | |
| if (typeof body !== 'object' || body === null || Array.isArray(body)) { | |
| throw new RequestValidationError('请求正文必须是 JSON 对象。'); | |
| } | |
| const objectBody = body as Record<string, unknown>; | |
| const fields: FieldErrors = {}; | |
| const model = readModel(objectBody, fields); | |
| const prompt = validatePrompt(objectBody.prompt, fields); | |
| const n = readIntegerField(objectBody, 'n', 1, 1, MAX_IMAGE_COUNT, fields); | |
| const size = readSize(objectBody, model, fields, '1024x1024'); | |
| const quality = readQuality(objectBody, fields); | |
| const outputFormat = readOutputFormat(objectBody, fields); | |
| const outputCompression = readOutputCompression(objectBody, outputFormat, fields); | |
| const background = readBackground(objectBody, model, fields); | |
| const moderation = readModeration(objectBody, fields); | |
| const responseMode = readResponseMode(objectBody, fields); | |
| if (Object.keys(fields).length > 0) { | |
| throw new RequestValidationError(JSON.stringify({ fields }), 422); | |
| } | |
| return { | |
| model, | |
| prompt, | |
| n, | |
| size, | |
| quality, | |
| output_format: outputFormat, | |
| ...(outputCompression !== undefined ? { output_compression: outputCompression } : {}), | |
| background, | |
| moderation, | |
| response_mode: responseMode | |
| }; | |
| } | |
| export function readAgentStateBackend(env: Record<string, string | undefined>): AgentStateBackend { | |
| const backend = env.AGENT_STATE_BACKEND?.trim().toLowerCase(); | |
| if (!backend) return 'sqlite'; | |
| if (backend === 'memory' || backend === 'sqlite' || backend === 'postgres') return backend; | |
| throw new RequestValidationError('AGENT_STATE_BACKEND 必须是 memory、sqlite 或 postgres。', 500); | |
| } | |
| export function readAgentRequestTtlSeconds(env: Record<string, string | undefined>): number { | |
| return readPositiveIntegerEnv(env, 'AGENT_REQUEST_TTL_SECONDS', AGENT_DEFAULT_REQUEST_TTL_SECONDS); | |
| } | |
| export function readAgentLeaseMs(env: Record<string, string | undefined>): number { | |
| return readPositiveIntegerEnv(env, 'AGENT_REQUEST_LEASE_MS', AGENT_DEFAULT_LEASE_MS); | |
| } | |
| export function readAgentRecoveryIntervalMs(env: Record<string, string | undefined>): number { | |
| return readPositiveIntegerEnv(env, 'AGENT_RECOVERY_INTERVAL_MS', AGENT_DEFAULT_RECOVERY_INTERVAL_MS); | |
| } | |
| export function readAgentSqlitePath(env: Record<string, string | undefined>): string { | |
| return env.AGENT_SQLITE_PATH?.trim() || AGENT_DEFAULT_SQLITE_PATH; | |
| } | |
| export function readAgentPublicBaseUrl(env: Record<string, string | undefined>): string { | |
| const value = env.AGENT_PUBLIC_BASE_URL?.trim(); | |
| if (!value) return '/'; | |
| let parsed: URL; | |
| try { | |
| parsed = new URL(value); | |
| } catch { | |
| throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 格式无效。', 500); | |
| } | |
| if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { | |
| throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 必须是 http 或 https URL。', 500); | |
| } | |
| if (parsed.username || parsed.password || parsed.search || parsed.hash) { | |
| throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 不能包含凭据、查询参数或片段。', 500); | |
| } | |
| return parsed.toString().replace(/\/$/, ''); | |
| } | |
| export function validateOptionalAgentApiBaseUrl(baseUrl: string | undefined): void { | |
| if (baseUrl) validateApiBaseUrl(baseUrl); | |
| } | |
| export function buildAgentAuthCapabilities(env: Record<string, string | undefined>): AgentCapabilities['auth'] { | |
| if (env.AGENT_API_TOKEN?.trim()) { | |
| return { required: true, schemes: ['bearer'] }; | |
| } | |
| if (env.APP_PASSWORD?.trim()) { | |
| return { required: true, schemes: ['x-app-password-hash'] }; | |
| } | |
| return { required: false, schemes: [] }; | |
| } | |
| function readPositiveIntegerEnv(env: Record<string, string | undefined>, fieldName: string, fallback: number): number { | |
| const value = env[fieldName]?.trim(); | |
| if (!value) return fallback; | |
| if (!/^\d+$/.test(value)) { | |
| throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500); | |
| } | |
| const parsed = Number(value); | |
| if (!Number.isSafeInteger(parsed) || parsed < 1) { | |
| throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500); | |
| } | |
| return parsed; | |
| } | |
| export function buildAgentCapabilities(env: Record<string, string | undefined>): AgentCapabilities { | |
| return { | |
| api_version: AGENT_API_VERSION, | |
| schema_version: AGENT_SCHEMA_VERSION, | |
| auth: buildAgentAuthCapabilities(env), | |
| endpoints: { | |
| capabilities: '/api/agent/capabilities', | |
| openapi: '/api/agent/openapi.json', | |
| generate: '/api/agent/images/generate', | |
| edit: '/api/agent/images/edit', | |
| create_generate_job: '/api/agent/jobs/images/generate', | |
| job: '/api/agent/jobs/{id}', | |
| job_result: '/api/agent/jobs/{id}/result', | |
| artifact_metadata: '/api/agent/artifacts/{id}', | |
| artifact_content: '/api/agent/artifacts/{id}/content', | |
| artifact_delete: '/api/agent/artifacts/{id}' | |
| }, | |
| defaults: { | |
| model: 'gpt-image-2', | |
| response_mode: 'path', | |
| state_backend: readAgentStateBackend(env) | |
| }, | |
| limits: { | |
| max_prompt_length: MAX_PROMPT_LENGTH, | |
| max_images: MAX_IMAGE_COUNT, | |
| max_upload_mb: MAX_UPLOAD_BYTES / 1024 / 1024, | |
| partial_images: { min: 1, max: 3 } | |
| }, | |
| model_limits: { | |
| 'gpt-image-2': { | |
| max_edge: GPT_IMAGE_2_MAX_EDGE, | |
| max_pixels: GPT_IMAGE_2_MAX_PIXELS, | |
| edge_multiple: GPT_IMAGE_2_EDGE_MULTIPLE, | |
| max_aspect: GPT_IMAGE_2_MAX_ASPECT, | |
| min_pixels: GPT_IMAGE_2_MIN_PIXELS, | |
| recommended_presets: [ | |
| { name: 'square', size: '2048x2048', purpose: '通用正方形构图' }, | |
| { name: 'landscape', size: '3072x2048', purpose: '横向宽幅构图' }, | |
| { name: 'portrait', size: '2048x3072', purpose: '纵向主体构图' } | |
| ], | |
| high_4k_risk: { | |
| applies_to: ['quality=high', 'max_edge>=3072', 'long_running_upstream'], | |
| guidance: '高质量 4K 级请求可能耗时数分钟;失败应归类为上游长耗时风险,不代表低负载路径不可用。' | |
| } | |
| } | |
| }, | |
| agent_streaming: { | |
| generate: { | |
| supported: false, | |
| mode: 'non_streaming_only', | |
| endpoint: '/api/agent/images/generate' | |
| }, | |
| edit: { | |
| supported: false, | |
| mode: 'non_streaming_only', | |
| endpoint: '/api/agent/images/edit' | |
| }, | |
| page_sse: { | |
| supported: true, | |
| mode: 'form_data_sse', | |
| endpoint: '/api/images', | |
| contract: 'page_ui_only' | |
| } | |
| }, | |
| agent_jobs: { | |
| supported: true, | |
| mode: 'job_polling', | |
| intended_for: ['quality=high', 'max_edge>=3072', 'long_running_upstream', 'manual_billable_gate'], | |
| endpoints: { | |
| create_generate_job: '/api/agent/jobs/images/generate', | |
| get_job: '/api/agent/jobs/{id}', | |
| get_job_result: '/api/agent/jobs/{id}/result' | |
| }, | |
| states: AGENT_JOB_STATES, | |
| current_guidance: | |
| '对 4K/high 或长耗时请求优先使用 job polling:先创建 generate job,再轮询状态,最后读取 result。运行中 job 会刷新 lease。当前执行模型为同实例后台任务,不是跨实例持久队列。' | |
| }, | |
| supported: { | |
| models: AGENT_MODELS, | |
| output_formats: AGENT_OUTPUT_FORMATS, | |
| response_modes: AGENT_RESPONSE_MODES, | |
| qualities: AGENT_QUALITIES, | |
| backgrounds: AGENT_BACKGROUNDS, | |
| moderations: AGENT_MODERATIONS, | |
| legacy_sizes: AGENT_LEGACY_SIZES | |
| }, | |
| storage: { | |
| image_storage_mode: env.NEXT_PUBLIC_IMAGE_STORAGE_MODE || (env.VERCEL === '1' ? 'indexeddb' : 'fs'), | |
| postgres_configured: Boolean(env.AGENT_DATABASE_URL || env.AGENT_DB_PASSWORD || env.AGENT_DB_PASSWORD_FILE) | |
| }, | |
| idempotency: { | |
| required: true, | |
| header: 'Idempotency-Key', | |
| ttl_seconds: readAgentRequestTtlSeconds(env) | |
| } | |
| }; | |
| } | |