Confirm sync fallback for unsupported image streams

This commit is contained in:
Codex
2026-05-13 03:45:22 +00:00
parent 54e6ab6750
commit 813ffbfa8b
10 changed files with 288 additions and 104 deletions

View File

@@ -1,7 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';
import { ImageGenerationClient, Config, HeaderUtils } from 'coze-coding-dev-sdk';
import { buildCustomApiHeaders, fetchWithRetry, parseCustomApiError, parseCustomApiJsonWithProgress } from '@/lib/custom-api-fetch';
import {
STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX,
buildCustomApiHeaders,
fetchWithRetry,
parseCustomApiError,
parseCustomApiJsonWithProgress,
} from '@/lib/custom-api-fetch';
import {
getAspectRatioPromptHint,
inferImageParamsFromPrompt,
@@ -17,9 +23,7 @@ import {
type ImageApiTemplate,
} from '@/lib/image-api-templates';
import {
compressImageBufferForUpstream,
dataUrlToImageBuffer,
imageBufferToDataUrl,
} from '@/lib/server-image-compression';
interface CustomApiConfig {
@@ -33,7 +37,6 @@ interface CustomApiConfig {
const GENERATION_TIMEOUT = Number(process.env.IMAGE_GENERATION_TIMEOUT_MS || 900_000);
const GENERATION_TIMEOUT_SECONDS = GENERATION_TIMEOUT / 1000;
const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 700 * 1024);
interface TargetImageSize {
width: number;
@@ -47,6 +50,17 @@ interface PersistedImageResult {
bytes: number;
}
interface QualifiedImageResult {
url: string;
width: number;
height: number;
bytes: number;
}
function syncFallbackConfirmationError(message: string): string {
return `${STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX}${message}`;
}
export const runtime = 'nodejs';
function parseImageSize(size: string | undefined): TargetImageSize | null {
@@ -221,7 +235,7 @@ async function persistQualifiedImageUrls(
targetSize: TargetImageSize | null,
context: string,
): Promise<{ images: string[]; rejected: string[] }> {
const images: string[] = [];
const images: QualifiedImageResult[] = [];
const rejected: string[] = [];
for (const url of urls) {
@@ -238,7 +252,7 @@ async function persistQualifiedImageUrls(
continue;
}
console.log(`[${context}] Accepted generated image:`, `${persisted.width}x${persisted.height}`, 'bytes:', persisted.bytes);
images.push(persisted.url);
images.push(persisted);
} catch (err) {
const message = err instanceof Error ? err.message : '图片处理失败';
console.warn(`[${context}] Failed to persist generated image:`, message);
@@ -246,7 +260,8 @@ async function persistQualifiedImageUrls(
}
}
return { images, rejected };
images.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.bytes - a.bytes);
return { images: images.map(image => image.url), rejected };
}
async function fetchCustomImageGeneration(
@@ -263,7 +278,17 @@ async function fetchCustomImageGeneration(
);
if (!response.ok) {
return { ok: false, response, errorText: await response.text() };
const errorText = await response.text();
if (requestBody.stream !== false && response.status === 524) {
return {
ok: false,
response,
errorText: syncFallbackConfirmationError(
'上游流式生图没有持续返回数据,最终被 Cloudflare 判定超时。请确认是否重新发起同步生图请求;同步请求可能耗时更久,且仍受上游网关超时限制。',
),
};
}
return { ok: false, response, errorText };
}
const data = await parseCustomApiJsonWithProgress(response, onProgress);
@@ -538,7 +563,10 @@ async function tryImageStrategy(
const errorText = await response.text();
console.warn(`[Custom API img2img → ${strategyName} FAILED]`, response.status, errorText.slice(0, 200));
return { success: false, error: parseCustomApiError(response.status, errorText), status: response.status, strategyName };
const parsedError = body.stream !== false && response.status === 524
? syncFallbackConfirmationError('上游流式生图没有持续返回数据,最终被 Cloudflare 判定超时。请确认是否重新发起同步生图请求;同步请求可能耗时更久,且仍受上游网关超时限制。')
: parseCustomApiError(response.status, errorText);
return { success: false, error: parsedError, status: response.status, strategyName };
} catch (err) {
const msg = err instanceof Error ? err.message : '请求异常';
console.warn(`[Custom API img2img → ${strategyName} ERROR]`, msg);
@@ -620,7 +648,10 @@ async function tryEditsWithFormData(
const errorText = await response.text();
console.warn(`[Custom API img2img → ${strategyName} FAILED]`, response.status, errorText.slice(0, 200));
return { success: false, error: parseCustomApiError(response.status, errorText), status: response.status, strategyName };
const parsedError = fields.stream !== 'false' && response.status === 524
? syncFallbackConfirmationError('上游流式生图没有持续返回数据,最终被 Cloudflare 判定超时。请确认是否重新发起同步生图请求;同步请求可能耗时更久,且仍受上游网关超时限制。')
: parseCustomApiError(response.status, errorText);
return { success: false, error: parsedError, status: response.status, strategyName };
} catch (err) {
const msg = err instanceof Error ? err.message : '请求异常';
console.warn(`[Custom API img2img → ${strategyName} ERROR]`, msg);
@@ -652,6 +683,7 @@ async function customApiImageToImage(
guidanceScale?: number,
style?: unknown,
user?: unknown,
stream?: boolean,
onProgress?: (progress: Record<string, unknown>) => void | Promise<void>,
): Promise<NextResponse> {
const endpoint = customApiConfig.apiUrl;
@@ -689,20 +721,7 @@ async function customApiImageToImage(
}
if (imageBuffer) {
try {
const compressed = await compressImageBufferForUpstream(
{ buffer: imageBuffer, mimeType: imageMimeType },
{ maxBytes: MAX_UPSTREAM_REFERENCE_IMAGE_BYTES },
);
if (compressed.changed) {
console.log('[Custom API img2img] Compressed reference image:', compressed.originalBytes, '→', compressed.buffer.length);
}
imageBuffer = compressed.buffer;
imageMimeType = compressed.mimeType;
normalizedImage = imageBufferToDataUrl({ buffer: imageBuffer, mimeType: imageMimeType });
} catch (err) {
console.warn('[Custom API img2img] Reference image compression failed, using original:', err instanceof Error ? err.message : err);
}
console.log('[Custom API img2img] Using original reference image without platform compression:', imageBuffer.length, 'bytes');
}
// Upload reference image to S3 to get a public URL (for strategies that use URL instead of file upload)
@@ -753,6 +772,7 @@ async function customApiImageToImage(
guidanceScale,
style,
user,
stream,
imageUrl,
base64Image: rawBase64,
strength: denoisingStrength,
@@ -852,6 +872,7 @@ export async function POST(request: NextRequest) {
style,
user,
guidanceScale = 7,
stream,
image,
strength,
customApiConfig,
@@ -870,6 +891,7 @@ export async function POST(request: NextRequest) {
style?: string;
user?: string;
guidanceScale?: number;
stream?: boolean;
image?: string;
strength?: number;
customApiConfig?: CustomApiConfig;
@@ -942,6 +964,7 @@ export async function POST(request: NextRequest) {
hasImage: !!image,
strength,
promptLength: prompt.length,
stream: stream !== false,
}));
// ---- Custom API mode ----
@@ -968,6 +991,7 @@ export async function POST(request: NextRequest) {
guidanceScale,
style,
user,
stream,
handleUpstreamProgress,
);
}
@@ -997,6 +1021,7 @@ export async function POST(request: NextRequest) {
guidanceScale,
style,
user,
stream,
});
const n = templatedRequest.requestCount;
const customApiSize = templatedRequest.requestSize;

View File

@@ -84,7 +84,7 @@ function explainGenerationError(message: string): string {
normalized.includes('content too large') ||
normalized.includes('参考图请求体过大')
) {
return '图生图请求里的参考图内容超过了上游接口网关限制。平台会自动压缩参考图;如果仍失败,通常需要减少参考图数量、换更小图片,或让 API 供应商调高图生图上传限制。';
return '图生图请求里的参考图内容超过了上游接口网关限制。平台不会压缩用户图片;请更换更小的参考图,或让 API 供应商调高图生图上传限制。';
}
if (

View File

@@ -1,7 +1,9 @@
'use client';
import { GenerationLoadingPanel } from '@/components/create/generation-loading-panel';
import { Button } from '@/components/ui/button';
import type { GenerationJobStatus } from '@/lib/generation-job-client';
import { AlertTriangle, Loader2 } from 'lucide-react';
export type ActiveGenerationTask = {
id: string;
@@ -10,13 +12,82 @@ export type ActiveGenerationTask = {
estimateSeconds: number;
jobStatus: GenerationJobStatus | null;
finalCountdownSeconds: number | null;
syncConfirmation?: {
message: string;
confirming?: boolean;
};
};
type GenerationTaskListProps = {
tasks: ActiveGenerationTask[];
onConfirmSync?: (taskId: string) => void;
onCancelSync?: (taskId: string) => void;
};
export function GenerationTaskList({ tasks }: GenerationTaskListProps) {
function TaskContent({
task,
title,
className = '',
onConfirmSync,
onCancelSync,
}: {
task: ActiveGenerationTask;
title: string;
className?: string;
onConfirmSync?: (taskId: string) => void;
onCancelSync?: (taskId: string) => void;
}) {
if (task.syncConfirmation) {
return (
<div className={`flex min-h-[260px] w-full flex-col justify-center px-6 py-8 ${className}`}>
<div className="mx-auto max-w-md space-y-4 text-left">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
<div className="min-w-0">
<p className="font-medium text-foreground">{title}</p>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{task.syncConfirmation.message}
</p>
</div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={task.syncConfirmation.confirming}
onClick={() => onCancelSync?.(task.id)}
>
</Button>
<Button
type="button"
size="sm"
disabled={task.syncConfirmation.confirming}
onClick={() => onConfirmSync?.(task.id)}
>
{task.syncConfirmation.confirming ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
</Button>
</div>
</div>
</div>
);
}
return (
<GenerationLoadingPanel
startedAt={task.startedAt}
estimateSeconds={task.estimateSeconds}
jobStatus={task.jobStatus}
finalCountdownSeconds={task.finalCountdownSeconds}
title={title}
className={className}
/>
);
}
export function GenerationTaskList({ tasks, onConfirmSync, onCancelSync }: GenerationTaskListProps) {
if (tasks.length === 0) return null;
if (tasks.length === 1) {
@@ -24,12 +95,11 @@ export function GenerationTaskList({ tasks }: GenerationTaskListProps) {
return (
<div className="liquid-glass min-h-[300px] overflow-hidden rounded-2xl border-dashed text-muted-foreground">
<GenerationLoadingPanel
startedAt={task.startedAt}
estimateSeconds={task.estimateSeconds}
jobStatus={task.jobStatus}
finalCountdownSeconds={task.finalCountdownSeconds}
<TaskContent
task={task}
title={task.title}
onConfirmSync={onConfirmSync}
onCancelSync={onCancelSync}
/>
</div>
);
@@ -49,13 +119,12 @@ export function GenerationTaskList({ tasks }: GenerationTaskListProps) {
key={task.id}
className="liquid-glass min-h-[260px] overflow-hidden rounded-2xl border-dashed text-muted-foreground"
>
<GenerationLoadingPanel
startedAt={task.startedAt}
estimateSeconds={task.estimateSeconds}
jobStatus={task.jobStatus}
finalCountdownSeconds={task.finalCountdownSeconds}
<TaskContent
task={task}
title={`${task.title} #${index + 1}`}
className="min-h-[260px] px-5 py-10"
onConfirmSync={onConfirmSync}
onCancelSync={onCancelSync}
/>
</div>
))}

View File

@@ -49,6 +49,14 @@ import { useImageStylePresets } from '@/lib/style-presets-client';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
const IMAGE_TO_IMAGE_DRAFT_KEY = 'miaojing:image-to-image-draft';
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
function parseStreamUnsupportedSyncMessage(error: unknown): string | null {
const message = error instanceof Error ? error.message : String(error || '');
if (!message.includes(STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX)) return null;
return message.split(STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX).pop()?.trim()
|| '上游接口不支持流式生图请求。是否重新发起同步生图请求?';
}
interface RefImage {
id: string;
@@ -82,6 +90,7 @@ export function ImageToImagePanel() {
const [results, setResults] = useState<string[]>([]);
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
const generating = activeTasks.length > 0;
// History
@@ -238,12 +247,10 @@ export function ImageToImagePanel() {
}
void (async () => {
const refs: RefImage[] = [];
let compressedCount = 0;
for (const file of imageFiles) {
try {
const result = await compressImageFileForUpload(file);
if (result.compressed) compressedCount += 1;
refs.push({
id: `ref-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
dataUrl: result.dataUrl,
@@ -259,9 +266,6 @@ export function ImageToImagePanel() {
if (refs.length > 0) {
setRefImages(prev => [...prev, ...refs]);
}
if (compressedCount > 0) {
toast.info(`已自动压缩 ${compressedCount} 张参考图`);
}
})();
}, []);
@@ -350,6 +354,36 @@ export function ImageToImagePanel() {
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
}, []);
const requestSyncConfirmation = useCallback((taskId: string, message: string) => new Promise<boolean>((resolve) => {
syncConfirmationResolversRef.current.set(taskId, resolve);
updateActiveTask(taskId, {
syncConfirmation: {
message,
},
jobStatus: null,
finalCountdownSeconds: null,
});
}), [updateActiveTask]);
const handleConfirmSync = useCallback((taskId: string) => {
updateActiveTask(taskId, {
syncConfirmation: {
message: '已确认同步生图,正在重新提交请求。',
confirming: true,
},
});
const resolve = syncConfirmationResolversRef.current.get(taskId);
syncConfirmationResolversRef.current.delete(taskId);
resolve?.(true);
}, [updateActiveTask]);
const handleCancelSync = useCallback((taskId: string) => {
const resolve = syncConfirmationResolversRef.current.get(taskId);
syncConfirmationResolversRef.current.delete(taskId);
resolve?.(false);
removeActiveTask(taskId);
}, [removeActiveTask]);
// Generate
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) { toast.error('请输入创作描述'); return; }
@@ -411,11 +445,32 @@ export function ImageToImagePanel() {
requestBody = { ...requestBody, model: api.modelName, customApiConfig: { systemApiId: api.id, modelName: api.modelName } };
}
}
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; error?: string }>(
'image',
requestBody,
payload,
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
);
let data: { images?: string[]; error?: string };
try {
data = await runJob({ ...requestBody, stream: true });
} catch (error) {
const confirmationMessage = parseStreamUnsupportedSyncMessage(error);
if (!confirmationMessage) throw error;
const confirmed = await requestSyncConfirmation(taskId, confirmationMessage);
if (!confirmed) return;
updateActiveTask(taskId, {
title: '正在同步生成图片',
startedAt: Date.now(),
jobStatus: null,
finalCountdownSeconds: null,
syncConfirmation: undefined,
});
data = await runJob({
...requestBody,
clientRequestId: `img2img-sync-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
stream: false,
});
}
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
if (data.images && data.images.length > 0) {
setResults(prev => [...data.images!, ...prev]);
@@ -466,8 +521,11 @@ export function ImageToImagePanel() {
setGenerationError(createGenerationError(err instanceof Error ? err.message : '网络错误,请重试'));
}
}
finally { removeActiveTask(taskId); }
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, strength, refImages, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, credits, membershipEnabled, resolveGenerationParams, removeActiveTask, updateActiveTask]);
finally {
syncConfirmationResolversRef.current.delete(taskId);
removeActiveTask(taskId);
}
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, strength, refImages, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, credits, membershipEnabled, resolveGenerationParams, removeActiveTask, updateActiveTask, requestSyncConfirmation]);
const handleDownload = useCallback(async (url: string, index: number) => {
const result = await downloadFile(url, `miaojing-img2img-${Date.now()}-${index}.png`);
@@ -687,7 +745,7 @@ export function ImageToImagePanel() {
{/* Right: Results + History */}
<div className="min-w-0 space-y-4">
{generating ? (
<GenerationTaskList tasks={activeTasks} />
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
) : generationError ? (
<GenerationErrorPanel error={generationError} />
) : results.length > 0 ? (

View File

@@ -47,6 +47,14 @@ import { useImageStylePresets } from '@/lib/style-presets-client';
import { GenerationTaskList, type ActiveGenerationTask } from '@/components/create/generation-task-list';
const TEXT_TO_IMAGE_DRAFT_KEY = 'miaojing:text-to-image-draft';
const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
function parseStreamUnsupportedSyncMessage(error: unknown): string | null {
const message = error instanceof Error ? error.message : String(error || '');
if (!message.includes(STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX)) return null;
return message.split(STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX).pop()?.trim()
|| '上游接口不支持流式生图请求。是否重新发起同步生图请求?';
}
export function TextToImagePanel() {
const { user, accessToken } = useAuth();
@@ -72,6 +80,7 @@ export function TextToImagePanel() {
const [generationError, setGenerationError] = useState<GenerationErrorState | null>(null);
const [optimizing, setOptimizing] = useState(false);
const activeSubmissionSignaturesRef = useRef(new Set<string>());
const syncConfirmationResolversRef = useRef(new Map<string, (confirmed: boolean) => void>());
const generating = activeTasks.length > 0;
// History state
@@ -227,6 +236,36 @@ export function TextToImagePanel() {
setActiveTasks(prev => prev.filter(task => task.id !== taskId));
}, []);
const requestSyncConfirmation = useCallback((taskId: string, message: string) => new Promise<boolean>((resolve) => {
syncConfirmationResolversRef.current.set(taskId, resolve);
updateActiveTask(taskId, {
syncConfirmation: {
message,
},
jobStatus: null,
finalCountdownSeconds: null,
});
}), [updateActiveTask]);
const handleConfirmSync = useCallback((taskId: string) => {
updateActiveTask(taskId, {
syncConfirmation: {
message: '已确认同步生图,正在重新提交请求。',
confirming: true,
},
});
const resolve = syncConfirmationResolversRef.current.get(taskId);
syncConfirmationResolversRef.current.delete(taskId);
resolve?.(true);
}, [updateActiveTask]);
const handleCancelSync = useCallback((taskId: string) => {
const resolve = syncConfirmationResolversRef.current.get(taskId);
syncConfirmationResolversRef.current.delete(taskId);
resolve?.(false);
removeActiveTask(taskId);
}, [removeActiveTask]);
// Generate
const handleGenerate = useCallback(async () => {
if (!prompt.trim()) { toast.error('请输入创作描述'); return; }
@@ -306,17 +345,40 @@ export function TextToImagePanel() {
const runSingleTask = async (taskId: string, index: number) => {
try {
const data = await runGenerationJob<{ images?: string[]; error?: string }>(
const runJob = (payload: Record<string, unknown>) => runGenerationJob<{ images?: string[]; error?: string }>(
'image',
{ ...requestBodyBase, count: 1, clientRequestId: `${batchId}-${index + 1}` },
payload,
{ timeoutMs: 900_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
);
let data: { images?: string[]; error?: string };
try {
data = await runJob({ ...requestBodyBase, count: 1, clientRequestId: `${batchId}-${index + 1}`, stream: true });
} catch (error) {
const confirmationMessage = parseStreamUnsupportedSyncMessage(error);
if (!confirmationMessage) throw error;
const confirmed = await requestSyncConfirmation(taskId, confirmationMessage);
if (!confirmed) return [];
updateActiveTask(taskId, {
title: '正在同步生成图片',
startedAt: Date.now(),
jobStatus: null,
finalCountdownSeconds: null,
syncConfirmation: undefined,
});
data = await runJob({
...requestBodyBase,
count: 1,
clientRequestId: `${batchId}-${index + 1}-sync-${Date.now()}`,
stream: false,
});
}
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
if (!data.images || data.images.length === 0) {
throw new Error(data.error || '图片生成失败');
}
return data.images;
} finally {
syncConfirmationResolversRef.current.delete(taskId);
removeActiveTask(taskId);
}
};
@@ -385,7 +447,7 @@ export function TextToImagePanel() {
finally {
if (submissionSignature) activeSubmissionSignaturesRef.current.delete(submissionSignature);
}
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, guidanceScale, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, membershipEnabled, resolveGenerationParams, removeActiveTask, updateActiveTask]);
}, [prompt, negativePrompt, selectedModel, outputFormat, imageQuality, selectedStylePreset, guidanceScale, user, imageKeys, systemImageApis, getCurrentModelLabel, addRecord, membershipEnabled, resolveGenerationParams, removeActiveTask, updateActiveTask, requestSyncConfirmation]);
// Download
const handleDownload = useCallback(async (url: string, index: number) => {
@@ -552,7 +614,7 @@ export function TextToImagePanel() {
<div className="min-w-0 space-y-4">
{/* Results area */}
{generating ? (
<GenerationTaskList tasks={activeTasks} />
<GenerationTaskList tasks={activeTasks} onConfirmSync={handleConfirmSync} onCancelSync={handleCancelSync} />
) : generationError ? (
<GenerationErrorPanel error={generationError} />
) : results.length > 0 ? (

View File

@@ -99,13 +99,8 @@ function jpegName(fileName: string): string {
export async function compressImageFileForUpload(
file: File,
options: CompressionOptions = {},
_options: CompressionOptions = {},
): Promise<BrowserCompressedImage> {
const maxDimension = options.maxDimension ?? DEFAULT_MAX_DIMENSION;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const initialQuality = options.initialQuality ?? DEFAULT_INITIAL_QUALITY;
const minQuality = options.minQuality ?? DEFAULT_MIN_QUALITY;
if (!file.type.startsWith('image/')) {
throw new Error('请上传图片文件');
}
@@ -113,49 +108,15 @@ export async function compressImageFileForUpload(
let sourceInfo: Awaited<ReturnType<typeof loadImageSource>> | null = null;
try {
sourceInfo = await loadImageSource(file);
const scale = Math.min(1, maxDimension / Math.max(sourceInfo.width, sourceInfo.height));
if (file.size <= maxBytes && scale >= 1 && /^image\/(jpeg|jpg|png|webp)$/i.test(file.type)) {
const dataUrl = await fileToDataUrl(file);
return {
dataUrl,
name: file.name,
width: sourceInfo.width,
height: sourceInfo.height,
originalBytes: file.size,
compressedBytes: dataUrlByteLength(dataUrl),
compressed: false,
};
}
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(sourceInfo.width * scale));
canvas.height = Math.max(1, Math.round(sourceInfo.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('浏览器不支持图片压缩');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(sourceInfo.source, 0, 0, canvas.width, canvas.height);
let bestBlob: Blob | null = null;
for (let quality = initialQuality; quality >= minQuality; quality -= 0.08) {
const blob = await canvasToBlob(canvas, 'image/jpeg', Math.max(minQuality, quality));
bestBlob = blob;
if (blob.size <= maxBytes) break;
}
if (!bestBlob) throw new Error('图片压缩失败');
const dataUrl = await blobToDataUrl(bestBlob);
const dataUrl = await fileToDataUrl(file);
return {
dataUrl,
name: jpegName(file.name),
width: canvas.width,
height: canvas.height,
name: file.name,
width: sourceInfo.width,
height: sourceInfo.height,
originalBytes: file.size,
compressedBytes: dataUrlByteLength(dataUrl),
compressed: bestBlob.size < file.size || scale < 1 || file.type !== 'image/jpeg',
compressed: false,
};
} finally {
sourceInfo?.close();

View File

@@ -18,6 +18,7 @@ type UpstreamProgress = Record<string, unknown> & {
const STREAM_EVENTS_FIELD = '__streamEvents';
const STREAM_TEXT_FIELD = '__streamText';
export const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
/**
* Default headers that mimic a browser-like HTTP client.
@@ -281,8 +282,15 @@ export async function parseCustomApiJsonWithProgress(
export function parseCustomApiError(status: number, rawBody: string): string {
const trimmed = rawBody.trim();
if (trimmed.startsWith(STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX)) return trimmed;
if (
/stream/i.test(trimmed)
&& /(not support|not supported|unsupported|disable|disabled|invalid|不支持|未开启|关闭|不兼容)/i.test(trimmed)
) {
return `${STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX}上游接口不支持当前流式生图请求。请确认是否重新发起同步生图请求;同步请求可能耗时更久,且仍受上游网关超时限制。`;
}
if (status === 413 || /request entity too large|payload too large|content too large/i.test(trimmed)) {
return '参考图请求体过大,上游模型服务拒绝接收。平台已自动压缩参考图;如果仍失败,请减少参考图数量、上传更小图片,或让 API 供应商提高图生图上传限制。';
return '参考图请求体过大,上游模型服务拒绝接收。平台不会压缩用户图片;请更换更小的参考图,或让 API 供应商提高图生图上传限制。';
}
if (status === 524 || /cloudflare|error code 524|a timeout occurred|origin web server timed out/i.test(trimmed)) {
return '上游 API 同步生图请求超时Cloudflare 524。请确认该供应商已开启流式生图或异步任务接口高分辨率生图不要走会长时间无响应的同步接口。';

View File

@@ -27,7 +27,7 @@ export const genericJsonImageTemplate: ImageApiTemplate = {
n: requestCount,
size: requestSize,
response_format: 'b64_json',
stream: true,
stream: input.stream !== false,
};
if (input.negativePrompt) body.negative_prompt = input.negativePrompt;
if (input.guidanceScale && input.guidanceScale !== 7) body.guidance_scale = input.guidanceScale;
@@ -57,7 +57,7 @@ export const genericJsonImageTemplate: ImageApiTemplate = {
const editsFields: Record<string, string> = {
model: input.modelName,
prompt: input.prompt,
stream: 'true',
stream: input.stream === false ? 'false' : 'true',
size: requestSize,
};
if (requestCount > 1) editsFields.n = String(requestCount);
@@ -65,7 +65,7 @@ export const genericJsonImageTemplate: ImageApiTemplate = {
const chatBody: Record<string, unknown> = {
model: input.modelName,
stream: true,
stream: input.stream !== false,
messages: [
{
role: 'user',
@@ -84,7 +84,7 @@ export const genericJsonImageTemplate: ImageApiTemplate = {
prompt: input.prompt,
n: requestCount,
size: requestSize,
stream: true,
stream: input.stream !== false,
init_image: input.base64Image,
denoising_strength: denoisingStrength,
response_format: 'b64_json',

View File

@@ -96,7 +96,7 @@ function applyOpenAICompatibleExtras(body: Record<string, unknown>, input: TextT
if (isGptImageModel(input.modelName)) {
body.output_format = input.outputFormat;
body.quality = input.imageQuality;
body.stream = true;
body.stream = input.stream !== false;
} else if (isDallE3Model(input.modelName)) {
body.quality = input.imageQuality === 'high' ? 'hd' : 'standard';
if (input.style === 'natural' || input.style === 'vivid') body.style = input.style;
@@ -122,7 +122,7 @@ function buildOpenAICompatibleImageEditFields(input: ImageToImageTemplateInput,
const fields: Record<string, string> = {
model: input.modelName,
prompt: input.prompt,
stream: 'true',
stream: input.stream === false ? 'false' : 'true',
};
if (requestSize) fields.size = requestSize;
if (requestCount > 1) fields.n = String(requestCount);
@@ -175,7 +175,7 @@ export const openAICompatibleImageTemplate: ImageApiTemplate = {
const formFields = buildOpenAICompatibleImageEditFields(input, requestCount, requestSize);
const chatBody: Record<string, unknown> = {
model: input.modelName,
stream: true,
stream: input.stream !== false,
messages: [
{
role: 'user',
@@ -195,7 +195,7 @@ export const openAICompatibleImageTemplate: ImageApiTemplate = {
prompt: input.prompt,
n: requestCount,
size: requestSize,
stream: true,
stream: input.stream !== false,
init_image: input.base64Image,
denoising_strength: denoisingStrength,
};

View File

@@ -20,6 +20,7 @@ export type TextToImageTemplateInput = {
guidanceScale?: number;
style?: unknown;
user?: unknown;
stream?: boolean;
};
export type TextToImageTemplateResult = {