diff --git a/.env.node.example b/.env.node.example index c8e7269..fb986f1 100644 --- a/.env.node.example +++ b/.env.node.example @@ -4,7 +4,7 @@ DATABASE_URL="file:./db.sqlite" # File Storage -FILE_STORAGE="db" # Options: "db"(default), "local", "r2" +FILE_STORAGE="base64" # Options: "base64"(default), "local", "r2" # Local File Storage Configuration FILE_STORAGE_LOCAL_PATH=".files" # Path to store files locally, default is .files directory diff --git a/src/app/i18n/locales/en.json b/src/app/i18n/locales/en.json index 6bdfcab..d383bf4 100644 --- a/src/app/i18n/locales/en.json +++ b/src/app/i18n/locales/en.json @@ -67,6 +67,8 @@ "apiErrorDesc": "The service is temporarily unavailable, please try again later", "timeoutError": "Request timeout", "timeoutErrorDesc": "The request took too long, please try again", + "tooManyRequests": "Too many requests", + "tooManyRequestsDesc": "You have made too many requests, please try again later", "unknownError": "Unknown error", "unknownErrorDesc": "An unexpected error occurred, please try again", "goToSettings": "Go to Settings", @@ -206,6 +208,9 @@ } }, "models": { + "@cf/black-forest-labs/flux-2-dev": { + "description": "FLUX.2 [dev] is an image model from Black Forest Labs where you can generate highly realistic and detailed images, with multi-reference support." + }, "@cf/leonardo/lucid-origin": { "description": "Lucid Origin from Leonardo.AI is their most adaptable and prompt-responsive model to date." }, @@ -264,6 +269,9 @@ } }, "models": { + "fal-ai/nano-banana-pro": { + "description": "Nano Banana Pro is Google's latest image generation and editing model with significantly improved consistency and prompt adherence." + }, "fal-ai/flux-pro/kontext/max": { "description": "FLUX.1 Kontext [max] is a model with greatly improved prompt adherence and typography generation meet premium consistency for editing without compromise on speed." }, @@ -315,6 +323,9 @@ } }, "models": { + "gemini-3-pro-image-preview": { + "description": "Nano Banana Pro is Google's latest image generation and editing model with advanced multimodal capabilities." + }, "gemini-2.5-flash-image-preview": { "description": "Nano Banana[Gemini 2.5 Flash Image] is Google's state-of-the-art image generation and editing model." }, diff --git a/src/app/i18n/locales/zh.json b/src/app/i18n/locales/zh.json index ae3c126..6cbe645 100644 --- a/src/app/i18n/locales/zh.json +++ b/src/app/i18n/locales/zh.json @@ -67,6 +67,8 @@ "apiErrorDesc": "服务暂时不可用,请稍后重试", "timeoutError": "请求超时", "timeoutErrorDesc": "请求时间过长,请重试", + "tooManyRequests": "请求过于频繁", + "tooManyRequestsDesc": "您的请求次数过多,请稍后再试", "unknownError": "未知错误", "unknownErrorDesc": "发生了意外错误,请重试", "goToSettings": "前往设置", @@ -206,6 +208,9 @@ } }, "models": { + "@cf/black-forest-labs/flux-2-dev": { + "description": "FLUX.2 [dev] 是 Black Forest Labs 的图像模型,您可以生成高度逼真且细节丰富的图像,并支持多参考图像。" + }, "@cf/leonardo/lucid-origin": { "description": "Lucid Origin from Leonardo.AI 是Leonardo.AI团队最新发布的最适配和最响应的模型。" }, @@ -264,6 +269,9 @@ } }, "models": { + "fal-ai/nano-banana-pro": { + "description": "Nano Banana Pro 是 Google 最新的图像生成和编辑模型,一致性和提示遵循能力显著提升。" + }, "fal-ai/flux-pro/kontext/max": { "description": "FLUX.1 Kontext [max] 是一个具有极大改进的提示遵循和排版生成的模型,提供卓越的一致性以供编辑,同时不牺牲速度。" }, @@ -315,6 +323,9 @@ } }, "models": { + "gemini-3-pro-image-preview": { + "description": "Nano Banana Pro 是 Google 最新的图像生成和编辑模型,一致性和提示遵循能力显著提升。" + }, "gemini-2.5-flash-image-preview": { "description": "Nano Banana[Gemini 2.5 Flash Image] 是 Google 的最先进的图像生成和编辑模型。" }, diff --git a/src/app/routes/chat/-components/chat/GenerationErrorItem.tsx b/src/app/routes/chat/-components/chat/GenerationErrorItem.tsx index 1f07c3d..26e1e77 100644 --- a/src/app/routes/chat/-components/chat/GenerationErrorItem.tsx +++ b/src/app/routes/chat/-components/chat/GenerationErrorItem.tsx @@ -45,6 +45,14 @@ export function GenerationErrorItem({ errorReason, provider, onRetry, className buttonIcon: RefreshCw, buttonAction: "retry" as const, }; + case "TOO_MANY_REQUESTS": + return { + title: t("chat.generation.tooManyRequests"), + description: t("chat.generation.tooManyRequestsDesc"), + buttonText: t("chat.generation.retry"), + buttonIcon: RefreshCw, + buttonAction: "retry" as const, + }; default: return { title: t("chat.generation.unknownError"), diff --git a/src/server/ai/provider/cloudflare.ts b/src/server/ai/provider/cloudflare.ts index 10038bf..47fd073 100644 --- a/src/server/ai/provider/cloudflare.ts +++ b/src/server/ai/provider/cloudflare.ts @@ -1,7 +1,9 @@ import { inCfWorker } from "@/server/lib/env"; -import { base64ToDataURI, dataURItoBase64, readableStreamToDataURI } from "@/server/lib/util"; +import type { ReplacePropertyType } from "@/server/lib/types"; +import { base64ToBlob, base64ToDataURI, dataURItoBase64, readableStreamToDataURI } from "@/server/lib/util"; import { getContext } from "@/server/service/context"; import { type TypixGenerateRequest, commonAspectRatioSizes } from "../types/api"; +import type { AiModel } from "../types/model"; import type { AiProvider, ApiProviderSettings, ApiProviderSettingsItem } from "../types/provider"; import { type ProviderSettingsType, @@ -11,13 +13,59 @@ import { getProviderSettingsSchema, } from "../types/provider"; +// Helper function to create FormData from params +const createFormData = (params: any, model: CloudflareAiModel, request: TypixGenerateRequest): FormData => { + const form = new FormData(); + form.append("prompt", params.prompt); + if (params.width) form.append("width", String(params.width)); + if (params.height) form.append("height", String(params.height)); + + // Handle image editing (i2i) with multiple input images + if (request.images) { + const maxInputImages = model.maxInputImages || 1; + const images = request.images; + + // Append images with numbered parameter names + for (let i = 0; i < Math.min(images.length, maxInputImages); i++) { + const imageBlob = base64ToBlob(images[i]!); + form.append(`input_image_${i}`, imageBlob); + } + } + + return form; +}; + +// Helper function to handle API response +const handleApiResponse = async (resp: Response): Promise => { + if (!resp.ok) { + if (resp.status === 401 || resp.status === 404) { + throw new Error("CONFIG_ERROR"); + } + if (resp.status === 429) { + throw new Error("TOO_MANY_REQUESTS"); + } + const errorText = await resp.text(); + throw new Error(`Cloudflare API error: ${resp.status} ${resp.statusText} - ${errorText}`); + } + + const contentType = resp.headers.get("Content-Type"); + if (contentType?.includes("image/png") === true) { + const imageBuffer = await resp.arrayBuffer(); + return [base64ToDataURI(Buffer.from(imageBuffer).toString("base64"))]; + } + + const result = (await resp.json()) as unknown as any; + return [base64ToDataURI(result.result.image)]; +}; + // Single image generation helper function const generateSingle = async (request: TypixGenerateRequest, settings: ApiProviderSettings): Promise => { const AI = getContext().AI; const { builtin, apiKey, accountId } = Cloudflare.parseSettings(settings); - const model = findModel(Cloudflare, request.modelId); + const model = findModel(Cloudflare, request.modelId) as CloudflareAiModel; const genType = chooseAblility(request, model.ability); + const inputType = model.inputType || "JSON"; const params = { prompt: request.prompt, @@ -28,44 +76,59 @@ const generateSingle = async (request: TypixGenerateRequest, settings: ApiProvid params.height = size?.height; } if (genType === "i2i") { - params.image_b64 = dataURItoBase64(request.images![0]!); + params.image_b64 = request.images![0]!; } + // Built-in Cloudflare Worker AI if (inCfWorker && AI && builtin === true) { - const resp = await AI.run(request.modelId as unknown as any, params); + if (inputType === "FormData") { + const form = createFormData(params, model, request); + const formRequest = new Request("http://dummy", { + method: "POST", + body: form, + }); + + const resp = await AI.run(request.modelId as unknown as any, { + multipart: { + body: formRequest.body, + contentType: formRequest.headers.get("content-type") || "multipart/form-data", + }, + }); + + if (resp instanceof ReadableStream) { + return [await readableStreamToDataURI(resp)]; + } + return [base64ToDataURI(resp.image)]; + } + // Default JSON format + const resp = await AI.run(request.modelId as unknown as any, params); if (resp instanceof ReadableStream) { return [await readableStreamToDataURI(resp)]; } - return [base64ToDataURI(resp.image)]; } - const resp = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${request.modelId}`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify(params), - }); - - if (!resp.ok) { - if (resp.status === 401 || resp.status === 404) { - throw new Error("CONFIG_ERROR"); - } + // External API call + const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${request.modelId}`; + const headers = { Authorization: `Bearer ${apiKey}` }; - const errorText = await resp.text(); - throw new Error(`Cloudflare API error: ${resp.status} ${resp.statusText} - ${errorText}`); - } - - const contentType = resp.headers.get("Content-Type"); - if (contentType?.includes("image/png") === true) { - const imageBuffer = await resp.arrayBuffer(); - return [base64ToDataURI(Buffer.from(imageBuffer).toString("base64"))]; + if (inputType === "FormData") { + const resp = await fetch(url, { + method: "POST", + headers, + body: createFormData(params, model, request), + }); + return handleApiResponse(resp); } - const result = (await resp.json()) as unknown as any; - return [base64ToDataURI(result.result.image)]; + // Default JSON format + const resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(params), + }); + return handleApiResponse(resp); }; const cloudflareSettingsNotBuiltInSchema = [ @@ -102,7 +165,13 @@ const cloudflareSettingsBuiltinSchema = [ // Automatically generate type from schema export type CloudflareSettings = ProviderSettingsType; -const Cloudflare: AiProvider = { +type CloudflareAiModel = AiModel & { + inputType?: "JSON" | "FormData"; +}; + +type CloudflareProvider = ReplacePropertyType; + +const Cloudflare: CloudflareProvider = { id: "cloudflare", name: "Cloudflare AI", settings: () => { @@ -112,6 +181,15 @@ const Cloudflare: AiProvider = { }, enabledByDefault: true, models: [ + { + id: "@cf/black-forest-labs/flux-2-dev", + name: "FLUX.2-dev", + ability: "i2i", + maxInputImages: 4, + enabledByDefault: true, + supportedAspectRatios: ["1:1", "16:9", "9:16", "4:3", "3:4"], + inputType: "FormData", + }, { id: "@cf/leonardo/lucid-origin", name: "Lucid Origin", @@ -177,6 +255,12 @@ const Cloudflare: AiProvider = { images: [], }; } + if (error.message === "TOO_MANY_REQUESTS") { + return { + errorReason: "TOO_MANY_REQUESTS", + images: [], + }; + } throw error; } }, diff --git a/src/server/ai/provider/fal.ts b/src/server/ai/provider/fal.ts index 485c0c3..26daee8 100644 --- a/src/server/ai/provider/fal.ts +++ b/src/server/ai/provider/fal.ts @@ -32,7 +32,7 @@ const Fal: AiProvider = { models: [ { id: "fal-ai/nano-banana-pro", - name: "Nano Banana 2", + name: "Nano Banana Pro", ability: "i2i", maxInputImages: 4, enabledByDefault: true, diff --git a/src/server/ai/provider/google.ts b/src/server/ai/provider/google.ts index 441fe7c..bcceb73 100644 --- a/src/server/ai/provider/google.ts +++ b/src/server/ai/provider/google.ts @@ -91,7 +91,7 @@ const Google: AiProvider = { models: [ { id: "gemini-3-pro-image-preview", - name: "Nano Banana 2", + name: "Nano Banana Pro", ability: "i2i", maxInputImages: 4, enabledByDefault: true, diff --git a/src/server/db/schemas/chat.ts b/src/server/db/schemas/chat.ts index 363f1da..5de3dad 100644 --- a/src/server/db/schemas/chat.ts +++ b/src/server/db/schemas/chat.ts @@ -49,7 +49,7 @@ export const messageAttachments = sqliteTable("message_attachments", { ...metaFields, }); -const errorReason = ["CONFIG_INVALID", "CONFIG_ERROR", "API_ERROR", "TIMEOUT", "UNKNOWN"] as const; +const errorReason = ["CONFIG_INVALID", "CONFIG_ERROR", "API_ERROR", "TOO_MANY_REQUESTS", "TIMEOUT", "UNKNOWN"] as const; export type ErrorReason = (typeof errorReason)[number]; // Generations table - stores AI generation requests and results (images, videos, etc.) diff --git a/src/server/lib/types.ts b/src/server/lib/types.ts index 57853f7..36f1fbb 100644 --- a/src/server/lib/types.ts +++ b/src/server/lib/types.ts @@ -1 +1,4 @@ export type StrictOmit = Omit; +export type ReplacePropertyType = { + [P in keyof T]: P extends K ? NewType : T[P]; +}; diff --git a/src/server/lib/util.ts b/src/server/lib/util.ts index 1ffa77b..7a67f9f 100644 --- a/src/server/lib/util.ts +++ b/src/server/lib/util.ts @@ -2,6 +2,16 @@ export function base64ToDataURI(base64: string, fmt = "png") { return `data:image/${fmt};base64,${base64}`; } +export function base64ToBlob(base64: string, mimeType = "image/png") { + const byteString = atob(base64); + const arrayBuffer = new ArrayBuffer(byteString.length); + const uint8Array = new Uint8Array(arrayBuffer); + for (let i = 0; i < byteString.length; i++) { + uint8Array[i] = byteString.charCodeAt(i); + } + return new Blob([arrayBuffer], { type: mimeType }); +} + export function dataURItoBase64(dataURI: string) { return dataURI.split(",")[1]; }