Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.node.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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."
},
Expand Down Expand Up @@ -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."
},
Expand Down Expand Up @@ -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."
},
Expand Down
11 changes: 11 additions & 0 deletions src/app/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
"apiErrorDesc": "服务暂时不可用,请稍后重试",
"timeoutError": "请求超时",
"timeoutErrorDesc": "请求时间过长,请重试",
"tooManyRequests": "请求过于频繁",
"tooManyRequestsDesc": "您的请求次数过多,请稍后再试",
"unknownError": "未知错误",
"unknownErrorDesc": "发生了意外错误,请重试",
"goToSettings": "前往设置",
Expand Down Expand Up @@ -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团队最新发布的最适配和最响应的模型。"
},
Expand Down Expand Up @@ -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] 是一个具有极大改进的提示遵循和排版生成的模型,提供卓越的一致性以供编辑,同时不牺牲速度。"
},
Expand Down Expand Up @@ -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 的最先进的图像生成和编辑模型。"
},
Expand Down
8 changes: 8 additions & 0 deletions src/app/routes/chat/-components/chat/GenerationErrorItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
140 changes: 112 additions & 28 deletions src/server/ai/provider/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string[]> => {
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<string[]> => {
const AI = getContext().AI;
const { builtin, apiKey, accountId } = Cloudflare.parseSettings<CloudflareSettings>(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,
Expand All @@ -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 = [
Expand Down Expand Up @@ -102,7 +165,13 @@ const cloudflareSettingsBuiltinSchema = [
// Automatically generate type from schema
export type CloudflareSettings = ProviderSettingsType<typeof cloudflareSettingsBuiltinSchema>;

const Cloudflare: AiProvider = {
type CloudflareAiModel = AiModel & {
inputType?: "JSON" | "FormData";
};

type CloudflareProvider = ReplacePropertyType<AiProvider, "models", CloudflareAiModel[]>;

const Cloudflare: CloudflareProvider = {
id: "cloudflare",
name: "Cloudflare AI",
settings: () => {
Expand All @@ -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",
Expand Down Expand Up @@ -177,6 +255,12 @@ const Cloudflare: AiProvider = {
images: [],
};
}
if (error.message === "TOO_MANY_REQUESTS") {
return {
errorReason: "TOO_MANY_REQUESTS",
images: [],
};
}
throw error;
}
},
Expand Down
2 changes: 1 addition & 1 deletion src/server/ai/provider/fal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/server/ai/provider/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/server/db/schemas/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
3 changes: 3 additions & 0 deletions src/server/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export type StrictOmit<T, K extends keyof T> = Omit<T, K>;
export type ReplacePropertyType<T, K extends keyof T, NewType> = {
[P in keyof T]: P extends K ? NewType : T[P];
};
10 changes: 10 additions & 0 deletions src/server/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down