fix: run fal gpt image edit asynchronously
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-28 22:16:57 +08:00
parent ce03c85811
commit 44dee6314a
12 changed files with 2110 additions and 441 deletions

View File

@ -25,6 +25,10 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import type { ChatModel } from "@/lib/ai/models";
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
import { myProvider } from "@/lib/ai/providers";
import {
isFalGptImage2EditModelId,
submitFalImageTask,
} from "@/lib/ai/providers/fal-image";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime";
import { shouldForceInlineVisionInputs } from "@/lib/ai/model-compat";
@ -135,6 +139,11 @@ import {
buildAgentToolVideoMarkdown,
prependImageModelBadge,
} from "@/lib/ai/runtime/agent-media-markdown";
import {
parseImageAltMetadata,
saveGeneratedImages,
} from "@/lib/ai/runtime/generated-image-persistence";
import type { FalImageEditTaskMetadata } from "@/lib/ai/runtime/fal-image-edit-task";
import { saveAssistantTextOnlyMessage } from "@/lib/ai/runtime/assistant-message-persistence";
import { resolveCanvasAgentClarificationGate } from "@/lib/ai/runtime/middlewares/canvas-agent-clarification-gate";
import {
@ -1463,6 +1472,13 @@ async function generateImageWithFailoverCandidate(params: {
}) {
const { candidate, messages, imageParams, fallbackImageParams } = params;
const providerType = String(candidate.provider?.type || "");
const candidateRuntimeModelId = String(candidate.model?.modelId || candidate.model?.id || "");
if (
providerType === "fal-image" &&
isFalGptImage2EditModelId(candidateRuntimeModelId)
) {
throw new Error("FAL GPT Image 2 Edit 仅支持异步队列调用,已跳过同步故障迁移");
}
const candidateLanguageModel = await getLanguageModelById(candidate.model.id);
if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") {
throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`);
@ -2381,57 +2397,6 @@ async function convertMessagesWithImages(
return convertedMessages;
}
function parseImageAltMetadata(altText: string, thumbnailUrl: string): {
originalUrl: string;
imageSize: string;
} {
let originalUrl = thumbnailUrl;
let imageSize = "2K";
// 格式1: thumb|||originalUrl|||size (新格式)
if (altText.includes("thumb|||")) {
const content = altText.substring(altText.indexOf("thumb|||") + 8);
const parts = content.split("|||");
originalUrl = parts[0] || thumbnailUrl;
imageSize = parts[1] || "2K";
}
// 格式2: thumb$$originalUrl$$size (中间格式,可能被 LaTeX 解析器破坏)
else if (altText.includes("thumb$$")) {
const content = altText.substring(altText.indexOf("thumb$$") + 7);
const parts = content.split("$$");
originalUrl = parts[0] || thumbnailUrl;
imageSize = parts[1] || "2K";
}
// 格式3: thumb|originalUrl|size (旧格式)
else if (altText.includes("thumb|")) {
const content = altText.substring(altText.indexOf("thumb|") + 6);
const parts = content.split("|");
originalUrl = parts[0] || thumbnailUrl;
imageSize = parts[1] || "2K";
}
// 回退策略:从缩略图 URL 推断原图 URL
if (originalUrl === thumbnailUrl) {
if (thumbnailUrl.includes("gemini-thumb-")) {
originalUrl = thumbnailUrl.replace("gemini-thumb-", "gemini-orig-");
} else if (thumbnailUrl.includes("openai-thumb-")) {
originalUrl = thumbnailUrl.replace("openai-thumb-", "openai-orig-");
} else if (thumbnailUrl.includes("volcano-thumb-")) {
originalUrl = thumbnailUrl.replace("volcano-thumb-", "volcano-orig-");
} else if (thumbnailUrl.includes("bfl-image-thumb-")) {
originalUrl = thumbnailUrl.replace("bfl-image-thumb-", "bfl-image-orig-");
} else if (thumbnailUrl.includes("alibaba-image-thumb-")) {
originalUrl = thumbnailUrl.replace("alibaba-image-thumb-", "alibaba-image-orig-");
} else if (thumbnailUrl.includes("ruxa-image-thumb-")) {
originalUrl = thumbnailUrl.replace("ruxa-image-thumb-", "ruxa-image-orig-");
} else if (thumbnailUrl.includes("fal-image-thumb-")) {
originalUrl = thumbnailUrl.replace("fal-image-thumb-", "fal-image-orig-");
}
}
return { originalUrl, imageSize };
}
function extractOriginalImageUrlsFromText(text: string): string[] {
return extractImageUrlsFromText(text);
}
@ -2784,204 +2749,6 @@ function resolveModelRefByRuntimeModelId(
return String(match?.model?.id || fallbackRef || normalizedModelId || "");
}
/**
* 解析生图 Markdown 并保存图片记录到数据库
* 支持三种格式:thumb|||url|||size、thumb$$url$$size、thumb|url|size
*/
async function saveGeneratedImages(
imageMarkdown: string,
userId: string,
chatId: string,
messageId: string,
prompt: string,
modelId: string,
options?: {
sourceAssetIds?: string[];
sourceImageUrls?: string[];
relationType?: "generated_from" | "edited_from";
sourceType?: string;
projectId?: string | null;
userPrompt?: string;
optimizedPrompt?: string;
requestedImageCount?: number;
aspectRatio?: string;
imageSize?: string;
},
): Promise<void> {
// 匹配 Markdown 图片格式: ![alt](url)
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...imageMarkdown.matchAll(imageRegex)];
let savedImageCount = 0;
const savedAssetIds: string[] = [];
const savedOriginalUrls: string[] = [];
let relationSourceAssetIds = Array.isArray(options?.sourceAssetIds)
? options.sourceAssetIds.filter((assetId): assetId is string => Boolean(assetId))
: [];
if (
relationSourceAssetIds.length === 0 &&
Array.isArray(options?.sourceImageUrls) &&
options.sourceImageUrls.length > 0
) {
try {
const sourceAssets = await getMediaAssetsByUserAndUrls({
userId,
urls: options.sourceImageUrls,
});
relationSourceAssetIds = sourceAssets.map((asset) => asset.id);
} catch (sourceLookupError) {
console.error("[Gallery] Failed to resolve source assets by URL:", sourceLookupError);
}
}
for (const match of matches) {
const altText = match[1] || "";
const thumbnailUrl = match[2];
const { originalUrl, imageSize } = parseImageAltMetadata(altText, thumbnailUrl);
try {
const [savedImage] = await db.insert(generatedImage).values({
userId,
chatId,
messageId,
thumbnailUrl,
originalUrl,
imageSize,
prompt,
modelId,
}).returning();
savedImageCount += 1;
if (!savedOriginalUrls.includes(originalUrl)) {
savedOriginalUrls.push(originalUrl);
}
const media = await upsertMediaAsset({
userId,
chatId,
projectId: options?.projectId ?? null,
mediaType: "image",
sourceType: options?.sourceType || "generated",
status: "ready",
originalUrl,
previewUrl: originalUrl,
thumbnailUrl,
displayName: prompt?.slice(0, 80) || "AI 生成图片",
prompt,
modelId,
sourceMessageId: messageId,
sourceTable: "GeneratedImage",
sourceRowId: savedImage?.id || null,
primaryParentAssetId: relationSourceAssetIds[0] || null,
metadata: {
imageSize,
},
});
if (media?.id && !savedAssetIds.includes(media.id)) {
savedAssetIds.push(media.id);
}
if (media && relationSourceAssetIds.length > 0) {
for (const sourceAssetId of relationSourceAssetIds) {
if (!sourceAssetId) continue;
await createMediaAssetRelation({
userId,
chatId,
projectId: options?.projectId ?? null,
fromAssetId: sourceAssetId,
toAssetId: media.id,
relationType: options?.relationType || "generated_from",
sourceMessageId: messageId,
metadata: {
modelId,
prompt,
sourceImageUrls: options?.sourceImageUrls || [],
},
});
}
}
console.log(`[Gallery] Saved image: ${originalUrl.substring(0, 80)}...`);
} catch (error) {
console.error("[Gallery] Failed to save image record:", error);
}
}
if (savedOriginalUrls.length > 0) {
try {
const resultAssetIds = savedAssetIds.length > 0 ? savedAssetIds : null;
await persistImageWorkflowTurn({
userId,
chatId,
projectId: options?.projectId || null,
currentIntent: options?.relationType === "edited_from" ? "image_edit" : "image_gen",
currentFocusAssetIds: resultAssetIds,
currentFocusImageUrls: savedOriginalUrls,
lastGeneratedAssetIds: resultAssetIds,
lastGeneratedImageUrls: savedOriginalUrls,
lastResolvedTargetAssetIds: resultAssetIds,
lastResolvedTargetImageUrls: savedOriginalUrls,
lastMessageId: messageId,
lastUserPrompt: String(options?.userPrompt || prompt || "").trim() || prompt,
lastOptimizedPrompt:
String(
options?.optimizedPrompt ||
(options?.relationType !== "edited_from" ? prompt : "") ||
"",
).trim() || undefined,
lastRequestedImageCount: options?.requestedImageCount || savedOriginalUrls.length,
lastAspectRatio: options?.aspectRatio,
lastImageSize: options?.imageSize,
lastSourceImageUrls: options?.sourceImageUrls || null,
lastResultImageUrls: savedOriginalUrls,
});
} catch (memoryError) {
console.warn("[Gallery] Failed to update working state with generated images:", memoryError);
}
}
if (savedImageCount <= 0) return;
try {
const pointsConfig = await getPointsSystemConfig();
if (!pointsConfig.enabled) return;
const userData = await getUserById({ id: userId }).catch(() => undefined);
if (userData?.isHighVip) {
console.log(`[Points] Skip image points for VIP user=${userId} model=${modelId}`);
return;
}
const modelConfig = await resolveBillingModelByReference(modelId);
const unitCost = getModelCreditCost(modelConfig, "image");
if (unitCost <= 0) return;
const totalCost = unitCost * savedImageCount;
const consumeResult = await consumeUserPoints({
userId,
amount: totalCost,
reason: "image_generation",
modelId: modelConfig?.id || modelId,
chatId,
messageId,
note: `模型(${modelConfig?.displayName || modelId}) 生图 ${savedImageCount} 张`,
});
if (!consumeResult.ok) {
console.warn(
`[Points] Failed to consume points after generation. user=${userId} model=${modelId} cost=${totalCost}`
);
} else if (consumeResult.skipped) {
console.log(
`[Points] Skipped ${totalCost} image points for VIP user=${userId} model=${modelId}`
);
} else {
console.log(
`[Points] Consumed ${totalCost} points (${unitCost} x ${savedImageCount}). balance=${consumeResult.balance}`
);
}
} catch (error) {
console.error("[Points] Failed to consume points after image generation:", error);
}
}
type AgentPresetMessageImagePart = {
type: "file";
url: string;
@ -10570,6 +10337,122 @@ export async function POST(request: Request) {
}
messagesForAlibaba = injectHistoryImagesToLastUserMessage(messagesForAlibaba, smartAutoHistoryImages);
const isFalAsyncImageEdit =
(languageModel as any).provider === "fal-image" &&
isFalGptImage2EditModelId(actualModelId);
if (isFalAsyncImageEdit) {
const falProvider = alibabaModelConfig?.providerId
? await getAiProviderById({ id: alibabaModelConfig.providerId }).catch(() => undefined)
: undefined;
const falApiKey = String(falProvider?.apiKey || "").trim();
if (!falApiKey) {
throw new Error("FAL 图片编辑模型未配置可用的 API Key");
}
const falMessages = await convertMessagesWithImages(messagesForAlibaba, "fal-image");
const queuedTask = await submitFalImageTask({
apiKey: falApiKey,
modelId: actualModelId,
messages: falMessages,
imageParams:
effectiveImageParams || {
aspectRatio: defaultImageQualityParams.aspectRatio,
imageSize: defaultImageQualityParams.imageSize,
imageCount: defaultImageQualityParams.imageCount,
},
});
const placeholderMessageId = generateUUID();
const userPrompt =
lastUserMessage?.parts?.find(
(p): p is { type: "text"; text: string } => p.type === "text",
)?.text || "";
const modelRef = resolveModelRefByRuntimeModelId(
allModels,
queuedTask.modelId,
finalModelId,
);
const placeholderText = `已提交 ${modelDisplayName} 编辑任务,预计 2-4 分钟完成,结果会自动回填到这里。`;
const queuedMetadata: FalImageEditTaskMetadata = {
taskKind: "fal_gpt_image_2_edit",
provider: "fal-image",
providerId: String(alibabaModelConfig?.providerId || ""),
endpointId: queuedTask.endpointId,
requestId: queuedTask.requestId,
modelId: queuedTask.modelId,
modelRef,
modelDisplayName,
prompt: queuedTask.prompt,
optimizedPrompt: smartAutoOptimizedPrompt || userPrompt || queuedTask.prompt,
userPrompt: userPrompt || queuedTask.prompt,
requestedImageCount:
effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
aspectRatio: effectiveImageParams?.aspectRatio,
imageSize: effectiveImageParams?.imageSize,
outputSizeLabel: queuedTask.outputSizeLabel,
sourceImageUrls: requestTargetResolution?.targetImageUrls || queuedTask.sourceImageUrls,
sourceAssetIds: requestTargetResolution?.targetAssetIds || [],
relationType: requestResolvedMediaInput?.hasImage ? "edited_from" : "generated_from",
sourceType: "generated",
projectId: contextPayload?.projectId || null,
contextSource: contextPayload?.source || null,
status: "queued",
startedAt: Date.now(),
updatedAt: Date.now(),
queuePosition: null,
lastLogs: [],
error: null,
};
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_START -->",
"alibaba:image-start",
);
writeImageGenerationStatusChunk(
writer,
{
phase: "queued",
providerType: "fal-image",
modelName: modelDisplayName,
imageCount:
effectiveImageParams?.imageCount || defaultImageQualityParams.imageCount || 1,
},
"alibaba:image-status-queued",
);
writeAssistantTextChunk(
writer,
placeholderText,
"alibaba:image-queued",
placeholderMessageId,
);
await saveMessages({
messages: [
{
id: placeholderMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: placeholderText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: queuedMetadata as any,
},
],
});
console.log("[Chat API] ✅ Queued FAL GPT Image 2 Edit task", {
requestId: queuedTask.requestId,
endpointId: queuedTask.endpointId,
messageId: placeholderMessageId,
});
return;
}
const tryGenerateImage = async (modelIdToUse: string, isRetry: boolean) => {
// 转换消息(图片编辑模型统一使用 URL 输入)
@ -10698,6 +10581,17 @@ export async function POST(request: Request) {
console.log(
`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`
);
if (
candidate.provider.type === "fal-image" &&
isFalGptImage2EditModelId(
String(candidate.model?.modelId || candidate.model?.id || "")
)
) {
console.log(
`[Chat API] Skip async-only failover candidate: ${candidate.model.displayName}`
);
continue;
}
const candidateLanguageModel = await getLanguageModelById(candidate.model.id);
if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") {
@ -11124,6 +11018,17 @@ export async function POST(request: Request) {
console.log(
`[Chat API] 🔁 Volcano cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`
);
if (
candidate.provider.type === "fal-image" &&
isFalGptImage2EditModelId(
String(candidate.model?.modelId || candidate.model?.id || "")
)
) {
console.log(
`[Chat API] Skip async-only failover candidate: ${candidate.model.displayName}`
);
continue;
}
const candidateLanguageModel = await getLanguageModelById(candidate.model.id);
if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") {
@ -12014,6 +11919,17 @@ export async function POST(request: Request) {
console.log(
`[Chat API] 🔁 OpenAI-compatible cross-provider failover: ${candidate.model.displayName} (${candidate.provider.type})`
);
if (
candidate.provider.type === "fal-image" &&
isFalGptImage2EditModelId(
String(candidate.model?.modelId || candidate.model?.id || "")
)
) {
console.log(
`[Chat API] Skip async-only failover candidate: ${candidate.model.displayName}`
);
continue;
}
const candidateLanguageModel = await getLanguageModelById(candidate.model.id);
if (

View File

@ -1,11 +1,195 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { saveMessages, getMessagesByChatId } from "@/lib/db/queries";
import { prependImageModelBadge } from "@/lib/ai/runtime/agent-media-markdown";
import {
isPendingFalImageEditTaskMetadata,
type FalImageEditTaskMetadata,
} from "@/lib/ai/runtime/fal-image-edit-task";
import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
import {
getChatById,
getMessagesByChatId,
getAiProviderById,
saveMessages,
updateMessageById,
} from "@/lib/db/queries";
import type { DBMessage } from "@/lib/db/schema";
import {
getFalImageTaskStatus,
resolveFalImageTaskToMarkdown,
} from "@/lib/ai/providers/fal-image";
export const dynamic = "force-dynamic";
export const revalidate = 0;
const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
const falTaskLastPolledAt = new Map<string, number>();
const falTaskSyncInFlight = new Set<string>();
async function markFalTaskFailed(params: {
message: DBMessage;
metadata: FalImageEditTaskMetadata;
errorText: string;
}) {
await updateMessageById({
messageId: params.message.id,
chatId: params.message.chatId,
parts: [{ type: "text", text: params.errorText }],
videoMetadata: {
...params.metadata,
status: "failed",
updatedAt: Date.now(),
error: params.errorText,
} as any,
});
}
async function syncPendingFalImageTask(params: {
userId: string;
message: DBMessage;
}): Promise<boolean> {
const metadata = params.message.videoMetadata;
if (!isPendingFalImageEditTaskMetadata(metadata)) return false;
const taskKey = `${params.message.id}:${metadata.requestId}`;
const now = Date.now();
const lastPolledAt = falTaskLastPolledAt.get(taskKey) || 0;
if (now - lastPolledAt < FAL_IMAGE_TASK_POLL_INTERVAL_MS) {
return false;
}
if (falTaskSyncInFlight.has(taskKey)) {
return false;
}
falTaskSyncInFlight.add(taskKey);
falTaskLastPolledAt.set(taskKey, now);
try {
const provider = await getAiProviderById({ id: metadata.providerId }).catch(() => undefined);
const apiKey = String(provider?.apiKey || "").trim();
if (!apiKey) {
await markFalTaskFailed({
message: params.message,
metadata,
errorText: "FAL 图片编辑失败:未找到可用的服务商配置",
});
falTaskLastPolledAt.delete(taskKey);
return true;
}
let queueStatus: Awaited<ReturnType<typeof getFalImageTaskStatus>>;
try {
queueStatus = await getFalImageTaskStatus({
apiKey,
endpointId: metadata.endpointId,
requestId: metadata.requestId,
});
} catch (error) {
console.error("[Messages API] Failed to poll FAL task status:", {
messageId: params.message.id,
requestId: metadata.requestId,
error,
});
return false;
}
if (queueStatus.status === "COMPLETED") {
try {
const imageMarkdown = await resolveFalImageTaskToMarkdown({
apiKey,
endpointId: metadata.endpointId,
requestId: metadata.requestId,
imageCount: metadata.requestedImageCount,
outputSizeLabel: metadata.outputSizeLabel,
sourceImageUrls: metadata.sourceImageUrls || [],
});
const finalText = prependImageModelBadge(
imageMarkdown,
metadata.modelDisplayName || metadata.modelId,
);
await updateMessageById({
messageId: params.message.id,
chatId: params.message.chatId,
parts: [{ type: "text", text: finalText }],
videoMetadata: {
...metadata,
status: "completed",
updatedAt: Date.now(),
queuePosition: null,
lastLogs: Array.isArray((queueStatus as any)?.logs) ? (queueStatus as any).logs : [],
error: null,
} as any,
});
await saveGeneratedImages(
finalText,
params.userId,
params.message.chatId,
params.message.id,
metadata.userPrompt || metadata.prompt,
metadata.modelRef || metadata.modelId,
{
sourceAssetIds: metadata.sourceAssetIds || [],
sourceImageUrls: metadata.sourceImageUrls || [],
relationType: metadata.relationType || "edited_from",
sourceType: metadata.sourceType || "generated",
projectId: metadata.projectId || null,
optimizedPrompt: metadata.optimizedPrompt || metadata.prompt,
userPrompt: metadata.userPrompt || metadata.prompt,
requestedImageCount: metadata.requestedImageCount,
aspectRatio: metadata.aspectRatio,
imageSize: metadata.imageSize,
},
);
falTaskLastPolledAt.delete(taskKey);
return true;
} catch (error) {
console.error("[Messages API] Failed to finalize completed FAL task:", {
messageId: params.message.id,
requestId: metadata.requestId,
error,
});
await markFalTaskFailed({
message: params.message,
metadata,
errorText: "FAL 图片编辑结果处理失败,请重新提交任务",
});
falTaskLastPolledAt.delete(taskKey);
return true;
}
}
const nextStatus = queueStatus.status === "IN_QUEUE" ? "queued" : "running";
const nextQueuePosition =
queueStatus.status === "IN_QUEUE" && typeof (queueStatus as any).queue_position === "number"
? (queueStatus as any).queue_position
: null;
const hasMeaningfulChange =
metadata.status !== nextStatus ||
(metadata.queuePosition ?? null) !== nextQueuePosition;
if (hasMeaningfulChange) {
await updateMessageById({
messageId: params.message.id,
chatId: params.message.chatId,
videoMetadata: {
...metadata,
status: nextStatus,
updatedAt: Date.now(),
queuePosition: nextQueuePosition,
lastLogs: Array.isArray((queueStatus as any)?.logs) ? (queueStatus as any).logs : [],
} as any,
});
return true;
}
return false;
} finally {
falTaskSyncInFlight.delete(taskKey);
}
}
/**
* 获取聊天消息
* 用于从数据库重新加载消息
@ -27,8 +211,26 @@ export async function GET(request: NextRequest) {
);
}
// 从数据库获取消息
const messages = await getMessagesByChatId({ id: chatId });
const selectedChat = await getChatById({ id: chatId });
if (!selectedChat || selectedChat.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
let messages = await getMessagesByChatId({ id: chatId });
let mutated = false;
for (const message of messages) {
if (message.role !== "assistant") continue;
if (!isPendingFalImageEditTaskMetadata(message.videoMetadata)) continue;
const changed = await syncPendingFalImageTask({
userId: session.user.id,
message,
});
mutated = mutated || changed;
}
if (mutated) {
messages = await getMessagesByChatId({ id: chatId });
}
// 转换为前端格式
const formattedMessages = messages.map((msg: DBMessage) => {
@ -41,6 +243,7 @@ export async function GET(request: NextRequest) {
createdAt: msg.createdAt,
parts: parts,
attachments: msg.attachments || [],
videoMetadata: msg.videoMetadata || null,
};
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,385 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, RefreshCw, Trash2, Type, X } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { imageGenerationService, type CanvasTextEditPayloadItem } from "@/services/canvas";
interface AgentImageTextEditDialogProps {
open: boolean;
imageUrl?: string | null;
title?: string | null;
onOpenChange: (open: boolean) => void;
onApplySuccess?: (result: { originalUrl: string; thumbnailUrl?: string | null; model?: string | null }) => void;
}
interface AgentImageTextEditItem {
id: string;
originalText: string;
editedText: string;
confidence?: number;
}
interface DeletedTextItem {
id: string;
originalText: string;
}
function isHttpImageUrl(value?: string | null) {
if (!value) return false;
try {
const parsed = new URL(String(value).trim());
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
export function AgentImageTextEditDialog({
open,
imageUrl,
title,
onOpenChange,
onApplySuccess,
}: AgentImageTextEditDialogProps) {
const normalizedImageUrl = useMemo(() => String(imageUrl || "").trim(), [imageUrl]);
const [textEditItems, setTextEditItems] = useState<AgentImageTextEditItem[]>([]);
const [deletedTextItems, setDeletedTextItems] = useState<DeletedTextItem[]>([]);
const [isDetectingText, setIsDetectingText] = useState(false);
const [isApplyingTextEdit, setIsApplyingTextEdit] = useState(false);
const [textEditError, setTextEditError] = useState<string | null>(null);
const [detectedSourceUrl, setDetectedSourceUrl] = useState<string | null>(null);
const detectTextItems = useCallback(
async (forceRefresh = false) => {
if (!normalizedImageUrl) {
setTextEditItems([]);
setDeletedTextItems([]);
setTextEditError("当前图片地址无效");
return;
}
if (!isHttpImageUrl(normalizedImageUrl)) {
setTextEditItems([]);
setDeletedTextItems([]);
setTextEditError("仅支持 URL 图片,不允许 base64 传输");
return;
}
if (isDetectingText) {
return;
}
if (!forceRefresh && detectedSourceUrl === normalizedImageUrl && textEditItems.length > 0) {
setDeletedTextItems([]);
setTextEditError(null);
return;
}
setIsDetectingText(true);
setTextEditError(null);
try {
const result = await imageGenerationService.detectCanvasText(normalizedImageUrl);
if (!result.success) {
throw new Error(result.error || "文字识别失败");
}
const detectedItems: AgentImageTextEditItem[] = [];
(result.data?.items || []).forEach((item, index) => {
const text = String(item.text || "").trim();
if (!text) {
return;
}
detectedItems.push({
id: item.id || String(index + 1),
originalText: text,
editedText: text,
confidence: item.confidence,
});
});
setTextEditItems(detectedItems);
setDeletedTextItems([]);
setDetectedSourceUrl(normalizedImageUrl);
if (detectedItems.length === 0) {
toast.info("未识别到可编辑文字");
} else {
toast.success(`已识别 ${detectedItems.length} 段文字`);
}
} catch (error) {
const message = error instanceof Error ? error.message : "文字识别失败";
setTextEditError(message);
toast.error(message);
} finally {
setIsDetectingText(false);
}
},
[detectedSourceUrl, isDetectingText, normalizedImageUrl, textEditItems.length]
);
useEffect(() => {
if (!open) {
return;
}
setTextEditError(null);
if (detectedSourceUrl === normalizedImageUrl && textEditItems.length > 0) {
setDeletedTextItems([]);
return;
}
setTextEditItems([]);
setDeletedTextItems([]);
void detectTextItems(true);
}, [detectTextItems, detectedSourceUrl, normalizedImageUrl, open, textEditItems.length]);
const handleTextItemChange = useCallback((id: string, value: string) => {
setTextEditItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, editedText: value } : item))
);
}, []);
const handleRemoveTextItem = useCallback((id: string) => {
setTextEditItems((prev) => {
const target = prev.find((item) => item.id === id);
if (!target?.originalText) {
return prev.filter((item) => item.id !== id);
}
setDeletedTextItems((deletedPrev) => {
if (deletedPrev.some((item) => item.id === id)) {
return deletedPrev;
}
return [...deletedPrev, { id, originalText: target.originalText }];
});
return prev.filter((item) => item.id !== id);
});
}, []);
const handleApplyTextEdit = useCallback(async () => {
if (!normalizedImageUrl) {
toast.error("当前图片地址无效");
return;
}
if (!isHttpImageUrl(normalizedImageUrl)) {
toast.error("仅支持 URL 图片,不允许 base64 传输");
return;
}
if (isApplyingTextEdit) {
return;
}
const changedItems: CanvasTextEditPayloadItem[] = textEditItems
.map((item) => {
const editedText = item.editedText.trim();
const action: "replace" | "delete" = editedText.length === 0 ? "delete" : "replace";
return {
id: item.id,
originalText: item.originalText.trim(),
editedText,
action,
};
})
.filter(
(item) =>
item.originalText.length > 0 &&
(item.action === "delete" ||
(item.editedText &&
item.editedText.length > 0 &&
item.originalText !== item.editedText))
);
const removedItems: CanvasTextEditPayloadItem[] = deletedTextItems
.map((item) => ({
id: item.id,
originalText: item.originalText.trim(),
action: "delete" as const,
}))
.filter((item) => item.originalText.length > 0);
const requestItems = [...changedItems, ...removedItems];
if (requestItems.length === 0) {
toast.info("请先修改或删除至少一段文字");
return;
}
setIsApplyingTextEdit(true);
setTextEditError(null);
try {
const result = await imageGenerationService.editCanvasText({
imageUrl: normalizedImageUrl,
items: requestItems,
});
if (!result.success || !result.data?.originalUrl) {
throw new Error(result.error || "文字替换失败");
}
toast.success("文字替换已完成");
setDeletedTextItems([]);
setDetectedSourceUrl(result.data.originalUrl);
onApplySuccess?.({
originalUrl: result.data.originalUrl,
thumbnailUrl: result.data.thumbnailUrl,
model: result.data.model || null,
});
onOpenChange(false);
} catch (error) {
const message = error instanceof Error ? error.message : "文字替换失败";
setTextEditError(message);
toast.error(message);
} finally {
setIsApplyingTextEdit(false);
}
}, [
deletedTextItems,
isApplyingTextEdit,
normalizedImageUrl,
onApplySuccess,
onOpenChange,
textEditItems,
]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] overflow-hidden border-slate-200 bg-white p-0 sm:max-w-[620px] dark:border-zinc-800 dark:bg-zinc-950">
<DialogHeader className="border-b border-slate-200 px-6 py-4 dark:border-zinc-800">
<DialogTitle className="flex items-center gap-2 text-left text-lg">
<Type className="h-5 w-5" />
<span>编辑文字</span>
</DialogTitle>
<DialogDescription className="text-left text-sm leading-6 text-slate-500 dark:text-zinc-400">
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
{title ? ` 当前处理:${title}` : ""}
</DialogDescription>
</DialogHeader>
<div className="agent-dark-scrollbar max-h-[calc(88vh-156px)] space-y-4 overflow-y-auto px-6 py-5">
{normalizedImageUrl ? (
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 dark:border-zinc-800 dark:bg-zinc-900">
<img
src={normalizedImageUrl}
alt={title || "待编辑结果图"}
className="block max-h-[240px] w-full object-contain"
/>
</div>
) : null}
{isDetectingText ? (
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
<Loader2 className="h-4 w-4 animate-spin" />
正在识别图片文字...
</div>
) : null}
{textEditError && !isDetectingText ? (
<div className="rounded-xl bg-red-500/10 px-4 py-3 text-sm text-red-500">
{textEditError}
</div>
) : null}
{!isDetectingText && textEditItems.length === 0 && !textEditError ? (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-4 text-sm leading-6 text-slate-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
未识别到可编辑文字,可点击“重新识别”再试一次。
</div>
) : null}
{textEditItems.map((item, index) => (
<div
key={item.id}
className="space-y-3 rounded-2xl border border-slate-200 p-4 dark:border-zinc-800"
>
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-slate-500 dark:text-zinc-400">
文本 {index + 1}
{typeof item.confidence === "number"
? ` · 置信度 ${(item.confidence * 100).toFixed(0)}%`
: ""}
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 text-slate-500 hover:text-red-500 dark:text-zinc-400"
onClick={() => handleRemoveTextItem(item.id)}
title="删除该条文字"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="text-sm text-slate-500 dark:text-zinc-400">原文:{item.originalText}</div>
<Input
value={item.editedText}
onChange={(event) => handleTextItemChange(item.id, event.target.value)}
placeholder="输入替换后的文字"
/>
</div>
))}
{deletedTextItems.length > 0 ? (
<div className="rounded-xl border border-orange-300 border-dashed bg-orange-50 px-4 py-3 text-sm text-orange-700 dark:border-orange-900 dark:bg-orange-950/40 dark:text-orange-200">
已标记删除 {deletedTextItems.length} 条文字,应用时会明确告诉生图模型移除这些文字并修复背景。
</div>
) : null}
</div>
<div className="flex items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 dark:border-zinc-800">
<Button
type="button"
variant="outline"
disabled={isDetectingText || isApplyingTextEdit}
onClick={() => void detectTextItems(true)}
>
{isDetectingText ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
重新识别
</Button>
<div className="flex items-center gap-3">
<Button
type="button"
variant="ghost"
disabled={isApplyingTextEdit}
onClick={() => onOpenChange(false)}
>
<X className="mr-2 h-4 w-4" />
取消
</Button>
<Button
type="button"
disabled={
isDetectingText ||
isApplyingTextEdit ||
(textEditItems.length === 0 && deletedTextItems.length === 0)
}
onClick={() => void handleApplyTextEdit()}
>
{isApplyingTextEdit ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Type className="mr-2 h-4 w-4" />
)}
确认替换
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -2489,7 +2489,7 @@ export function CanvasChatPanel({
}, []);
// 从 API 加载历史消息
const { data: historyData } = useSWR<{
const { data: historyData, mutate: mutateHistoryData } = useSWR<{
messages: ChatMessage[];
}>(rawChatId ? `/api/messages?chatId=${rawChatId}` : null, fetcher, {
revalidateOnFocus: false,
@ -5171,6 +5171,63 @@ export function CanvasChatPanel({
};
}, [historyData, rawChatId, setMessages, status]);
useEffect(() => {
const streamState = streamImageGenerationStateByChatId[activeChatId];
if (!rawChatId || !streamState) return;
let cancelled = false;
const poll = async () => {
if (cancelled) return;
try {
await mutateHistoryData();
} catch (error) {
console.error("[CanvasChat] Failed to poll message history:", error);
}
};
void poll();
const timer = window.setInterval(poll, 1500);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [activeChatId, rawChatId, streamImageGenerationStateByChatId, mutateHistoryData]);
useEffect(() => {
const streamState = streamImageGenerationStateByChatId[activeChatId];
if (!streamState || messages.length === 0) return;
const lastUserIndex = [...messages]
.map((message) => message.role)
.lastIndexOf("user");
const assistantMessages =
lastUserIndex >= 0
? messages.slice(lastUserIndex + 1).filter((message) => message.role === "assistant")
: messages.filter((message) => message.role === "assistant");
const hasCompletedMedia = assistantMessages.some((message) => {
const parts = (message.parts || []) as any[];
const textContent = parts
.filter((part) => part?.type === "text" && typeof part?.text === "string")
.map((part) => String(part.text))
.join("\n");
return (
extractAssistantImageUrls(textContent).length > 0 ||
extractVideoUrls(textContent).length > 0 ||
/失败|超时|重试|error|failed/i.test(textContent)
);
});
if (hasCompletedMedia) {
clearStreamImageGenerationStateForChat(activeChatId);
}
}, [
activeChatId,
messages,
streamImageGenerationStateByChatId,
clearStreamImageGenerationStateForChat,
]);
// 监听消息变化,提取图片并添加到画布
// 仅处理历史同步后的新消息,避免刷新后把历史图片重复回灌到画布
const processedMessageIds = useRef<Set<string>>(new Set());

View File

@ -1002,7 +1002,7 @@ export function Chat({
if (!pendingGeneration || !isImagePending) return;
let cancelled = false;
const maxDurationMs = 10 * 60 * 1000;
const maxDurationMs = 20 * 60 * 1000;
const mergeMessagesFromDatabase = (dbMessages: ChatMessage[]) => {
setMessages((prev) => {

View File

@ -14,7 +14,7 @@ export interface PendingGeneration {
}
const STORAGE_KEY = "ai-chatbot-pending-generations";
const EXPIRY_TIME = 5 * 60 * 1000; // 5 分钟超时
const EXPIRY_TIME = 20 * 60 * 1000; // 20 分钟超时
/**
* 获取所有未完成的生成任务

View File

@ -181,7 +181,7 @@ export const FAL_IMAGE_MODEL_PRESETS: FalImageModelPreset[] = [
"quality",
"num_images",
"output_format",
"mask_image_url",
"mask_url",
"openai_api_key",
],
supportsMultiImageReference: true,

View File

@ -28,6 +28,16 @@ interface UploadedImage {
original: string;
}
export interface FalSubmittedImageTask {
requestId: string;
endpointId: string;
modelId: string;
imageCount: number;
outputSizeLabel: string;
prompt: string;
sourceImageUrls: string[];
}
const DEFAULT_ASPECT_RATIO = "auto";
const DEFAULT_RESOLUTION = "1K";
const DEFAULT_OUTPUT_FORMAT = "png";
@ -203,7 +213,7 @@ function normalizeSeed(value?: string | number) {
return seed;
}
function isFalGptImage2EditModel(modelId: string) {
export function isFalGptImage2EditModelId(modelId: string) {
return modelId.trim().toLowerCase() === FAL_GPT_IMAGE_2_EDIT_MODEL_ID;
}
@ -443,6 +453,208 @@ function summarizeFalError(error: any) {
};
}
function createFalClientFromApiKey(apiKey: string) {
return import("@fal-ai/client").then(({ createFalClient }) =>
createFalClient({
credentials: normalizeFalCredentials(apiKey),
}),
);
}
function buildFalRequestPayload(params: {
modelId: string;
overrideModelId?: string;
messages: Array<{ role: string; content: any }>;
imageParams?: FalImageParams;
}) {
const { prompt, imageUrls } = extractPromptAndImages(params.messages);
const aspectRatio = normalizeAspectRatio(params.imageParams?.aspectRatio);
const resolution = normalizeResolution(params.imageParams?.imageSize);
const outputFormat = normalizeOutputFormat(params.imageParams?.outputFormat);
const safetyTolerance = normalizeSafetyTolerance(params.imageParams?.safetyTolerance);
const seed = normalizeSeed(params.imageParams?.seed);
const imageCount = Math.max(1, Math.min(6, Number(params.imageParams?.imageCount || 1)));
const activeModelId = params.overrideModelId || params.modelId;
const isGptImage2Edit = isFalGptImage2EditModelId(activeModelId);
const falEndpointId = isGptImage2Edit
? activeModelId
: normalizeFalEndpointId(activeModelId, imageUrls.length > 0);
if (!isGptImage2Edit && falEndpointId.toLowerCase().endsWith("/edit") && imageUrls.length === 0) {
throw new Error("FAL 图片编辑模型需要至少一张可访问的参考图片 URL");
}
const gptImage2EditSize = normalizeGptImage2EditImageSize(
params.imageParams?.imageSize,
params.imageParams?.aspectRatio,
);
const outputSizeLabel = isGptImage2Edit ? getFalImageSizeLabel(gptImage2EditSize) : resolution;
let input: Record<string, any>;
if (isGptImage2Edit) {
if (imageUrls.length === 0) {
throw new Error("FAL GPT Image 2 Edit 需要至少一张可访问的参考图片 URL");
}
input = {
prompt,
image_urls: imageUrls,
image_size: gptImage2EditSize,
quality: normalizeGptImage2EditQuality(params.imageParams?.quality),
num_images: imageCount,
output_format: outputFormat,
};
const maskImageUrl = normalizeOptionalHttpUrl(params.imageParams?.maskImageUrl);
if (maskImageUrl) {
input.mask_url = maskImageUrl;
}
if (seed !== undefined) {
input.seed = seed;
}
const openaiApiKey = String(params.imageParams?.openaiApiKey || "").trim();
if (openaiApiKey) {
input.openai_api_key = openaiApiKey;
}
} else {
input = {
prompt,
num_images: imageCount,
aspect_ratio: aspectRatio,
resolution,
output_format: outputFormat,
safety_tolerance: safetyTolerance,
limit_generations: true,
};
if (imageUrls.length > 0) {
input.image_urls = imageUrls;
}
if (seed !== undefined) {
input.seed = seed;
}
if (typeof params.imageParams?.enableWebSearch === "boolean") {
input.enable_web_search = params.imageParams.enableWebSearch;
}
}
return {
prompt,
imageUrls,
aspectRatio,
resolution,
outputFormat,
safetyTolerance,
seed,
imageCount,
activeModelId,
isGptImage2Edit,
falEndpointId,
gptImage2EditSize,
outputSizeLabel,
input,
};
}
async function normalizeFalResultToMarkdown(params: {
payload: any;
sourceImageUrls: string[];
imageCount: number;
outputSizeLabel: string;
}) {
let outputUrls = filterSourceImages(collectImageUrls(params.payload), params.sourceImageUrls);
outputUrls = await filterSourceImagesByContent(outputUrls, params.sourceImageUrls);
if (outputUrls.length === 0 && params.payload?.data) {
outputUrls = filterSourceImages(collectImageUrls(params.payload.data), params.sourceImageUrls);
outputUrls = await filterSourceImagesByContent(outputUrls, params.sourceImageUrls);
}
if (outputUrls.length === 0) {
throw new Error("FAL 未返回可用图片");
}
const uploadedImages: UploadedImage[] = [];
for (const outputUrl of outputUrls.slice(0, params.imageCount)) {
uploadedImages.push(await downloadAndUploadImage(outputUrl));
}
if (uploadedImages.length === 0) {
throw new Error("FAL 未返回可用图片");
}
return formatMarkdown(uploadedImages, params.outputSizeLabel);
}
export async function submitFalImageTask(params: {
apiKey: string;
modelId: string;
messages: Array<{ role: string; content: any }>;
imageParams?: FalImageParams;
overrideModelId?: string;
}): Promise<FalSubmittedImageTask> {
const request = buildFalRequestPayload(params);
const fal = await createFalClientFromApiKey(params.apiKey);
console.log("[Fal Image] Queue submit", {
model: request.activeModelId,
endpoint: request.falEndpointId,
imageCount: request.imageCount,
hasInputImages: request.imageUrls.length > 0,
resolution: request.outputSizeLabel,
});
const enqueueResult = await fal.queue.submit(request.falEndpointId, {
input: request.input,
});
return {
requestId: String(enqueueResult.request_id),
endpointId: request.falEndpointId,
modelId: request.activeModelId,
imageCount: request.imageCount,
outputSizeLabel: request.outputSizeLabel,
prompt: request.prompt,
sourceImageUrls: request.imageUrls,
};
}
export async function getFalImageTaskStatus(params: {
apiKey: string;
endpointId: string;
requestId: string;
}) {
const fal = await createFalClientFromApiKey(params.apiKey);
return fal.queue.status(params.endpointId, {
requestId: params.requestId,
logs: true,
});
}
export async function resolveFalImageTaskToMarkdown(params: {
apiKey: string;
endpointId: string;
requestId: string;
imageCount: number;
outputSizeLabel: string;
sourceImageUrls: string[];
}) {
const fal = await createFalClientFromApiKey(params.apiKey);
const result = await fal.queue.result<any>(params.endpointId, {
requestId: params.requestId,
});
return normalizeFalResultToMarkdown({
payload: result,
sourceImageUrls: params.sourceImageUrls,
imageCount: params.imageCount,
outputSizeLabel: params.outputSizeLabel,
});
}
export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
return {
provider: "fal-image",
@ -453,111 +665,36 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
imageParams?: FalImageParams;
overrideModelId?: string;
}) => {
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
const aspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
const resolution = normalizeResolution(options.imageParams?.imageSize);
const outputFormat = normalizeOutputFormat(options.imageParams?.outputFormat);
const safetyTolerance = normalizeSafetyTolerance(options.imageParams?.safetyTolerance);
const seed = normalizeSeed(options.imageParams?.seed);
const imageCount = Math.max(1, Math.min(6, Number(options.imageParams?.imageCount || 1)));
const activeModelId = options.overrideModelId || modelId;
const isGptImage2Edit = isFalGptImage2EditModel(activeModelId);
const falEndpointId = isGptImage2Edit
? activeModelId
: normalizeFalEndpointId(activeModelId, imageUrls.length > 0);
if (!isGptImage2Edit && falEndpointId.toLowerCase().endsWith("/edit") && imageUrls.length === 0) {
throw new Error("FAL 图片编辑模型需要至少一张可访问的参考图片 URL");
}
const gptImage2EditSize = normalizeGptImage2EditImageSize(
options.imageParams?.imageSize,
options.imageParams?.aspectRatio
);
const outputSizeLabel = isGptImage2Edit
? getFalImageSizeLabel(gptImage2EditSize)
: resolution;
const request = buildFalRequestPayload({
modelId,
overrideModelId: options.overrideModelId,
messages: options.messages,
imageParams: options.imageParams,
});
console.log("[Fal Image] Submitting request", {
model: activeModelId,
endpoint: falEndpointId,
model: request.activeModelId,
endpoint: request.falEndpointId,
baseURL,
imageCount,
aspectRatio,
resolution: outputSizeLabel,
hasInputImages: imageUrls.length > 0,
imageCount: request.imageCount,
aspectRatio: request.aspectRatio,
resolution: request.outputSizeLabel,
hasInputImages: request.imageUrls.length > 0,
});
const { createFalClient } = await import("@fal-ai/client");
const fal = createFalClient({
credentials: normalizeFalCredentials(apiKey),
});
let input: Record<string, any>;
if (isGptImage2Edit) {
if (imageUrls.length === 0) {
throw new Error("FAL GPT Image 2 Edit 需要至少一张可访问的参考图片 URL");
}
input = {
prompt,
image_urls: imageUrls,
image_size: gptImage2EditSize,
quality: normalizeGptImage2EditQuality(options.imageParams?.quality),
num_images: imageCount,
output_format: outputFormat,
};
const maskImageUrl = normalizeOptionalHttpUrl(options.imageParams?.maskImageUrl);
if (maskImageUrl) {
input.mask_image_url = maskImageUrl;
}
if (typeof options.imageParams?.syncMode === "boolean") {
input.sync_mode = options.imageParams.syncMode;
}
if (seed !== undefined) {
input.seed = seed;
}
const openaiApiKey = String(options.imageParams?.openaiApiKey || "").trim();
if (openaiApiKey) {
input.openai_api_key = openaiApiKey;
}
} else {
input = {
prompt,
num_images: imageCount,
aspect_ratio: aspectRatio,
resolution,
output_format: outputFormat,
safety_tolerance: safetyTolerance,
limit_generations: true,
};
if (imageUrls.length > 0) {
input.image_urls = imageUrls;
}
if (seed !== undefined) {
input.seed = seed;
}
if (typeof options.imageParams?.enableWebSearch === "boolean") {
input.enable_web_search = options.imageParams.enableWebSearch;
}
}
const fal = await createFalClientFromApiKey(apiKey);
let result: any;
try {
result = await fal.subscribe(falEndpointId, {
input,
result = await fal.subscribe(request.falEndpointId, {
input: request.input,
logs: true,
timeout: DEFAULT_TIMEOUT_MS,
onQueueUpdate: (update: any) => {
if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) {
for (const log of update.logs) {
if (log?.message) {
console.log(`[Fal Image][${falEndpointId}]`, log.message);
console.log(`[Fal Image][${request.falEndpointId}]`, log.message);
}
}
}
@ -568,28 +705,12 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
throw error;
}
let outputUrls = filterSourceImages(collectImageUrls(result), imageUrls);
outputUrls = await filterSourceImagesByContent(outputUrls, imageUrls);
if (outputUrls.length === 0 && result?.data) {
outputUrls = filterSourceImages(collectImageUrls(result.data), imageUrls);
outputUrls = await filterSourceImagesByContent(outputUrls, imageUrls);
}
if (outputUrls.length === 0) {
throw new Error("FAL 未返回可用图片");
}
const uploadedImages: UploadedImage[] = [];
for (const outputUrl of outputUrls.slice(0, imageCount)) {
uploadedImages.push(await downloadAndUploadImage(outputUrl));
}
if (uploadedImages.length === 0) {
throw new Error("FAL 未返回可用图片");
}
const text = formatMarkdown(uploadedImages, outputSizeLabel);
const text = await normalizeFalResultToMarkdown({
payload: result,
sourceImageUrls: request.imageUrls,
imageCount: request.imageCount,
outputSizeLabel: request.outputSizeLabel,
});
return {
text,
finishReason: "stop" as const,

View File

@ -0,0 +1,58 @@
export type FalImageEditTaskStatus = "queued" | "running" | "completed" | "failed";
export interface FalImageEditTaskMetadata {
taskKind: "fal_gpt_image_2_edit";
provider: "fal-image";
providerId: string;
endpointId: string;
requestId: string;
modelId: string;
modelRef: string;
modelDisplayName: string;
prompt: string;
optimizedPrompt?: string;
userPrompt?: string;
requestedImageCount: number;
aspectRatio?: string;
imageSize?: string;
outputSizeLabel: string;
sourceImageUrls: string[];
sourceAssetIds?: string[];
relationType?: "generated_from" | "edited_from";
sourceType?: string;
projectId?: string | null;
contextSource?: string | null;
status: FalImageEditTaskStatus;
startedAt: number;
updatedAt: number;
queuePosition?: number | null;
lastLogs?: Array<{
message?: string;
level?: string;
timestamp?: string;
}>;
error?: string | null;
}
export function isFalImageEditTaskMetadata(
value: unknown,
): value is FalImageEditTaskMetadata {
if (!value || typeof value !== "object") return false;
const maybe = value as Record<string, unknown>;
return (
maybe.taskKind === "fal_gpt_image_2_edit" &&
maybe.provider === "fal-image" &&
typeof maybe.providerId === "string" &&
typeof maybe.endpointId === "string" &&
typeof maybe.requestId === "string"
);
}
export function isPendingFalImageEditTaskMetadata(
value: unknown,
): value is FalImageEditTaskMetadata {
return (
isFalImageEditTaskMetadata(value) &&
(value.status === "queued" || value.status === "running")
);
}

View File

@ -0,0 +1,300 @@
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import {
consumeUserPoints,
createMediaAssetRelation,
getAiModelById,
getEnabledAiModelsWithProvider,
getMediaAssetsByUserAndUrls,
getPointsSystemConfig,
getUserById,
upsertMediaAsset,
} from "@/lib/db/queries";
import { persistImageWorkflowTurn } from "@/lib/ai/conversation-state-protocol";
import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config";
const MODEL_UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function parseImageAltMetadata(altText: string, thumbnailUrl: string): {
originalUrl: string;
imageSize: string;
} {
let originalUrl = thumbnailUrl;
let imageSize = "2K";
if (altText.includes("thumb|||")) {
const content = altText.substring(altText.indexOf("thumb|||") + 8);
const parts = content.split("|||");
originalUrl = parts[0] || thumbnailUrl;
imageSize = parts[1] || "2K";
} else if (altText.includes("thumb$$")) {
const content = altText.substring(altText.indexOf("thumb$$") + 7);
const parts = content.split("$$");
originalUrl = parts[0] || thumbnailUrl;
imageSize = parts[1] || "2K";
} else if (altText.includes("thumb|")) {
const content = altText.substring(altText.indexOf("thumb|") + 6);
const parts = content.split("|");
originalUrl = parts[0] || thumbnailUrl;
imageSize = parts[1] || "2K";
}
if (originalUrl === thumbnailUrl) {
if (thumbnailUrl.includes("gemini-thumb-")) {
originalUrl = thumbnailUrl.replace("gemini-thumb-", "gemini-orig-");
} else if (thumbnailUrl.includes("openai-thumb-")) {
originalUrl = thumbnailUrl.replace("openai-thumb-", "openai-orig-");
} else if (thumbnailUrl.includes("volcano-thumb-")) {
originalUrl = thumbnailUrl.replace("volcano-thumb-", "volcano-orig-");
} else if (thumbnailUrl.includes("bfl-image-thumb-")) {
originalUrl = thumbnailUrl.replace("bfl-image-thumb-", "bfl-image-orig-");
} else if (thumbnailUrl.includes("alibaba-image-thumb-")) {
originalUrl = thumbnailUrl.replace("alibaba-image-thumb-", "alibaba-image-orig-");
} else if (thumbnailUrl.includes("ruxa-image-thumb-")) {
originalUrl = thumbnailUrl.replace("ruxa-image-thumb-", "ruxa-image-orig-");
} else if (thumbnailUrl.includes("fal-image-thumb-")) {
originalUrl = thumbnailUrl.replace("fal-image-thumb-", "fal-image-orig-");
}
}
return { originalUrl, imageSize };
}
function getModelCreditCost(
modelConfig: any,
target: "image" | "video",
options?: { duration?: number | null; resolution?: string | null },
): number {
const config = (modelConfig as any)?.imageGenConfig || {};
const sourceValue =
target === "video"
? (() => {
const resolvedVideoCost = getVideoCreditCostFromConfig(
config,
options?.duration,
options?.resolution,
);
if (resolvedVideoCost > 0) return resolvedVideoCost;
return config.creditCostPerVideo ?? config.creditCostPerImage ?? 0;
})()
: config.creditCostPerImage ?? 0;
const normalized = Number(sourceValue);
return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0;
}
async function resolveBillingModelByReference(modelRef: string): Promise<any | undefined> {
if (!modelRef) return undefined;
if (MODEL_UUID_REGEX.test(modelRef)) {
return await getAiModelById({ id: modelRef }).catch(() => undefined);
}
const allModels = await getEnabledAiModelsWithProvider(true, true).catch(() => []);
const matched = allModels.find(
({ model }: any) => model.id === modelRef || model.modelId === modelRef,
);
return matched?.model;
}
export async function saveGeneratedImages(
imageMarkdown: string,
userId: string,
chatId: string,
messageId: string,
prompt: string,
modelId: string,
options?: {
sourceAssetIds?: string[];
sourceImageUrls?: string[];
relationType?: "generated_from" | "edited_from";
sourceType?: string;
projectId?: string | null;
userPrompt?: string;
optimizedPrompt?: string;
requestedImageCount?: number;
aspectRatio?: string;
imageSize?: string;
},
): Promise<void> {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...imageMarkdown.matchAll(imageRegex)];
let savedImageCount = 0;
const savedAssetIds: string[] = [];
const savedOriginalUrls: string[] = [];
let relationSourceAssetIds = Array.isArray(options?.sourceAssetIds)
? options.sourceAssetIds.filter((assetId): assetId is string => Boolean(assetId))
: [];
if (
relationSourceAssetIds.length === 0 &&
Array.isArray(options?.sourceImageUrls) &&
options.sourceImageUrls.length > 0
) {
try {
const sourceAssets = await getMediaAssetsByUserAndUrls({
userId,
urls: options.sourceImageUrls,
});
relationSourceAssetIds = sourceAssets.map((asset) => asset.id);
} catch (sourceLookupError) {
console.error("[Gallery] Failed to resolve source assets by URL:", sourceLookupError);
}
}
for (const match of matches) {
const altText = match[1] || "";
const thumbnailUrl = match[2];
const { originalUrl, imageSize } = parseImageAltMetadata(altText, thumbnailUrl);
try {
const [savedImage] = await db
.insert(generatedImage)
.values({
userId,
chatId,
messageId,
thumbnailUrl,
originalUrl,
imageSize,
prompt,
modelId,
})
.returning();
savedImageCount += 1;
if (!savedOriginalUrls.includes(originalUrl)) {
savedOriginalUrls.push(originalUrl);
}
const media = await upsertMediaAsset({
userId,
chatId,
projectId: options?.projectId ?? null,
mediaType: "image",
sourceType: options?.sourceType || "generated",
status: "ready",
originalUrl,
previewUrl: originalUrl,
thumbnailUrl,
displayName: prompt?.slice(0, 80) || "AI 生成图片",
prompt,
modelId,
sourceMessageId: messageId,
sourceTable: "GeneratedImage",
sourceRowId: savedImage?.id || null,
primaryParentAssetId: relationSourceAssetIds[0] || null,
metadata: {
imageSize,
},
});
if (media?.id && !savedAssetIds.includes(media.id)) {
savedAssetIds.push(media.id);
}
if (media && relationSourceAssetIds.length > 0) {
for (const sourceAssetId of relationSourceAssetIds) {
if (!sourceAssetId) continue;
await createMediaAssetRelation({
userId,
chatId,
projectId: options?.projectId ?? null,
fromAssetId: sourceAssetId,
toAssetId: media.id,
relationType: options?.relationType || "generated_from",
sourceMessageId: messageId,
metadata: {
modelId,
prompt,
sourceImageUrls: options?.sourceImageUrls || [],
},
});
}
}
console.log(`[Gallery] Saved image: ${originalUrl.substring(0, 80)}...`);
} catch (error) {
console.error("[Gallery] Failed to save image record:", error);
}
}
if (savedOriginalUrls.length > 0) {
try {
const resultAssetIds = savedAssetIds.length > 0 ? savedAssetIds : null;
await persistImageWorkflowTurn({
userId,
chatId,
projectId: options?.projectId || null,
currentIntent: options?.relationType === "edited_from" ? "image_edit" : "image_gen",
currentFocusAssetIds: resultAssetIds,
currentFocusImageUrls: savedOriginalUrls,
lastGeneratedAssetIds: resultAssetIds,
lastGeneratedImageUrls: savedOriginalUrls,
lastResolvedTargetAssetIds: resultAssetIds,
lastResolvedTargetImageUrls: savedOriginalUrls,
lastMessageId: messageId,
lastUserPrompt: String(options?.userPrompt || prompt || "").trim() || prompt,
lastOptimizedPrompt:
String(
options?.optimizedPrompt ||
(options?.relationType !== "edited_from" ? prompt : "") ||
"",
).trim() || undefined,
lastRequestedImageCount: options?.requestedImageCount || savedOriginalUrls.length,
lastAspectRatio: options?.aspectRatio,
lastImageSize: options?.imageSize,
lastSourceImageUrls: options?.sourceImageUrls || null,
lastResultImageUrls: savedOriginalUrls,
});
} catch (memoryError) {
console.warn("[Gallery] Failed to update working state with generated images:", memoryError);
}
}
if (savedImageCount <= 0) return;
try {
const pointsConfig = await getPointsSystemConfig();
if (!pointsConfig.enabled) return;
const userData = await getUserById({ id: userId }).catch(() => undefined);
if (userData?.isHighVip) {
console.log(`[Points] Skip image points for VIP user=${userId} model=${modelId}`);
return;
}
const modelConfig = await resolveBillingModelByReference(modelId);
const unitCost = getModelCreditCost(modelConfig, "image");
if (unitCost <= 0) return;
const totalCost = unitCost * savedImageCount;
const consumeResult = await consumeUserPoints({
userId,
amount: totalCost,
reason: "image_generation",
modelId: modelConfig?.id || modelId,
chatId,
messageId,
note: `模型(${modelConfig?.displayName || modelId}) 生图 ${savedImageCount} 张`,
});
if (!consumeResult.ok) {
console.warn(
`[Points] Failed to consume points after generation. user=${userId} model=${modelId} cost=${totalCost}`,
);
} else if (consumeResult.skipped) {
console.log(
`[Points] Skipped ${totalCost} image points for VIP user=${userId} model=${modelId}`,
);
} else {
console.log(
`[Points] Consumed ${totalCost} points (${unitCost} x ${savedImageCount}). balance=${consumeResult.balance}`,
);
}
} catch (error) {
console.error("[Points] Failed to consume points after image generation:", error);
}
}

View File

@ -416,7 +416,8 @@ export async function getPendingVideoMessagesByChatId({
eq(message.chatId, chatId),
eq(message.role, "assistant"),
sql`${message.videoMetadata} is not null`,
sql`coalesce(${message.videoMetadata} ->> 'status', '') in ('queued', 'running')`
sql`coalesce(${message.videoMetadata} ->> 'status', '') in ('queued', 'running')`,
sql`coalesce(${message.videoMetadata} ->> 'taskKind', '') <> 'fal_gpt_image_2_edit'`
)
)
.orderBy(desc(message.createdAt));