Confirm sync fallback for unsupported image streams
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -84,7 +84,7 @@ function explainGenerationError(message: string): string {
|
||||
normalized.includes('content too large') ||
|
||||
normalized.includes('参考图请求体过大')
|
||||
) {
|
||||
return '图生图请求里的参考图内容超过了上游接口网关限制。平台会自动压缩参考图;如果仍失败,通常需要减少参考图数量、换更小图片,或让 API 供应商调高图生图上传限制。';
|
||||
return '图生图请求里的参考图内容超过了上游接口网关限制。平台不会压缩用户图片;请更换更小的参考图,或让 API 供应商调高图生图上传限制。';
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)。请确认该供应商已开启流式生图或异步任务接口;高分辨率生图不要走会长时间无响应的同步接口。';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export type TextToImageTemplateInput = {
|
||||
guidanceScale?: number;
|
||||
style?: unknown;
|
||||
user?: unknown;
|
||||
stream?: boolean;
|
||||
};
|
||||
|
||||
export type TextToImageTemplateResult = {
|
||||
|
||||
Reference in New Issue
Block a user