diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index d8a3c621e..fb40e28f1 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -491,6 +491,7 @@ type AiProvider = {
| "ruxa-image"
| "fal-image"
| "openai-image"
+ | "stepfun-image"
| "midjourney"
| "fal-video"
| "zhipu"
@@ -1274,6 +1275,7 @@ const PROVIDER_TYPES = [
{ value: "ruxa-image", label: "RUXA生图" },
{ value: "fal-image", label: "FAL 图片生成/编辑" },
{ value: "openai-image", label: "OpenAI 图片兼容" },
+ { value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
{ value: "bltcy-video", label: "柏拉图统一视频" },
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
{ value: "grsai-video", label: "Grsai 视频模型" },
@@ -1306,6 +1308,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record = {
"ruxa-image": "https://api.ruxa.ai",
"fal-image": "https://fal.run",
"openai-image": "https://api.openai.com/v1",
+ "stepfun-image": "https://api.stepfun.com",
"bltcy-video": "https://api.bltcy.ai",
"grsai-gpt-image": "https://grsai.dakka.com.cn",
"grsai-video": "https://grsai.dakka.com.cn",
@@ -3232,6 +3235,7 @@ export function AdminSettingsClient() {
| "ruxa-image"
| "fal-image"
| "openai-image"
+ | "stepfun-image"
| "midjourney"
| "fal-video"
| "zhipu"
@@ -8464,6 +8468,7 @@ export function AdminSettingsClient() {
"ruxa-image": "bg-emerald-500",
"fal-image": "bg-sky-500",
"openai-image": "bg-green-600",
+ "stepfun-image": "bg-indigo-600",
"grsai-gpt-image": "bg-emerald-600",
"grsai-video": "bg-orange-600",
midjourney: "bg-fuchsia-600",
@@ -13667,6 +13672,13 @@ export function AdminSettingsClient() {
Token 认证。保存后会自动添加所有同步图片编辑预设模型。
)}
+ {providerForm.type === "stepfun-image" && (
+
+ 阶跃星辰 Step Image Edit 使用 Bearer Token 认证,默认地址
+ https://api.stepfun.com。模型 ID 可填写 step-image-edit-2,
+ 支持文生图和图片编辑。
+
+ )}
{providerForm.type === "bfl-image" &&
isBltcyBflBaseUrl(providerForm.baseUrl) && (
diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts
index 1357f67ed..a6c2cb540 100644
--- a/app/(chat)/api/chat/route.ts
+++ b/app/(chat)/api/chat/route.ts
@@ -474,6 +474,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
normalized === "ruxa-image" ||
normalized === "fal-image" ||
normalized === "openai-image" ||
+ normalized === "stepfun-image" ||
normalized === "midjourney" ||
normalized === "runway" ||
normalized === "krea" ||
@@ -1302,6 +1303,7 @@ const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"ruxa-image",
"fal-image",
"openai-image",
+ "stepfun-image",
"runway",
"krea",
"volcano-vision",
@@ -1372,6 +1374,7 @@ function getImageFailoverConvertProvider(providerType?: string | null) {
case "ruxa-image":
case "fal-image":
case "openai-image":
+ case "stepfun-image":
case "midjourney":
case "runway":
case "krea":
@@ -2274,6 +2277,7 @@ async function convertMessagesWithImages(
normalizedProviderType === "ruxa-image" ||
normalizedProviderType === "fal-image" ||
normalizedProviderType === "openai-image" ||
+ normalizedProviderType === "stepfun-image" ||
normalizedProviderType === "midjourney" ||
normalizedProviderType === "runway" ||
normalizedProviderType === "krea"
@@ -10240,6 +10244,7 @@ export async function POST(request: Request) {
"ruxa-image",
"fal-image",
"openai-image",
+ "stepfun-image",
"midjourney",
"runway",
"krea",
@@ -10465,6 +10470,8 @@ export async function POST(request: Request) {
? "fal-image"
: (languageModel as any).provider === "openai-image"
? "openai-image"
+ : (languageModel as any).provider === "stepfun-image"
+ ? "stepfun-image"
: (languageModel as any).provider === "midjourney"
? "midjourney"
: (languageModel as any).provider === "runway"
@@ -10565,10 +10572,11 @@ export async function POST(request: Request) {
"ruxa-image": 3,
"fal-image": 4,
"openai-image": 5,
- runway: 6,
- krea: 7,
- gemini: 7,
- "huanke-gemini": 8,
+ "stepfun-image": 6,
+ runway: 7,
+ krea: 8,
+ gemini: 8,
+ "huanke-gemini": 9,
};
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
@@ -10608,6 +10616,8 @@ export async function POST(request: Request) {
? "fal-image"
: candidate.provider.type === "openai-image"
? "openai-image"
+ : candidate.provider.type === "stepfun-image"
+ ? "stepfun-image"
: candidate.provider.type === "midjourney"
? "midjourney"
: candidate.provider.type === "runway"
@@ -11001,11 +11011,12 @@ export async function POST(request: Request) {
"ruxa-image": 2,
"fal-image": 3,
"openai-image": 4,
- runway: 5,
- krea: 6,
- gemini: 6,
- "huanke-gemini": 7,
- "volcano-vision": 8,
+ "stepfun-image": 5,
+ runway: 6,
+ krea: 7,
+ gemini: 7,
+ "huanke-gemini": 8,
+ "volcano-vision": 9,
};
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
@@ -11045,6 +11056,8 @@ export async function POST(request: Request) {
? "fal-image"
: candidate.provider.type === "openai-image"
? "openai-image"
+ : candidate.provider.type === "stepfun-image"
+ ? "stepfun-image"
: candidate.provider.type === "midjourney"
? "midjourney"
: candidate.provider.type === "runway"
@@ -11903,10 +11916,11 @@ export async function POST(request: Request) {
"ruxa-image": 3,
"fal-image": 4,
"openai-image": 5,
- runway: 6,
- krea: 7,
- gemini: 7,
- "huanke-gemini": 8,
+ "stepfun-image": 6,
+ runway: 7,
+ krea: 8,
+ gemini: 8,
+ "huanke-gemini": 9,
};
const crossProviderCandidates = getImageCrossProviderCandidates(allModels, {
currentModelId: finalModelId,
@@ -11951,6 +11965,8 @@ export async function POST(request: Request) {
? "fal-image"
: candidate.provider.type === "openai-image"
? "openai-image"
+ : candidate.provider.type === "stepfun-image"
+ ? "stepfun-image"
: candidate.provider.type === "midjourney"
? "midjourney"
: candidate.provider.type === "runway"
diff --git a/app/api/admin/ai-providers/route.ts b/app/api/admin/ai-providers/route.ts
index ef825e5af..59d94ddbb 100644
--- a/app/api/admin/ai-providers/route.ts
+++ b/app/api/admin/ai-providers/route.ts
@@ -30,6 +30,7 @@ const providerTypes = [
"ruxa-image",
"fal-image",
"openai-image",
+ "stepfun-image",
"midjourney",
"fal-video",
"zhipu",
@@ -69,6 +70,7 @@ const DOUBAO_VIDEO_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai";
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
+const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
@@ -230,6 +232,8 @@ export async function POST(request: Request) {
? data.baseUrl || RUXA_IMAGE_BASE_URL
: data.type === "openai-image"
? data.baseUrl || OPENAI_IMAGE_BASE_URL
+ : data.type === "stepfun-image"
+ ? data.baseUrl || STEPFUN_IMAGE_BASE_URL
: data.type === "midjourney"
? data.baseUrl || MIDJOURNEY_BASE_URL
: data.type === "tripo-3d"
diff --git a/app/api/canvas/text-edit/route.ts b/app/api/canvas/text-edit/route.ts
index 489538eaa..f72b99d34 100644
--- a/app/api/canvas/text-edit/route.ts
+++ b/app/api/canvas/text-edit/route.ts
@@ -9,6 +9,7 @@ import { createBflImage } from "@/lib/ai/providers/bfl-image";
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
import { createFalImage } from "@/lib/ai/providers/fal-image";
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
+import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
import { createMidjourney } from "@/lib/ai/providers/midjourney";
import { createRunway } from "@/lib/ai/providers/runway";
import { createKrea } from "@/lib/ai/providers/krea";
@@ -39,6 +40,7 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
"ruxa-image",
"fal-image",
"openai-image",
+ "stepfun-image",
"midjourney",
"runway",
"krea",
@@ -98,6 +100,8 @@ function createTextEditProvider(
return createFalImage({ baseURL, apiKey });
case "openai-image":
return createOpenAIImage({ baseURL, apiKey });
+ case "stepfun-image":
+ return createStepfunImage({ baseURL, apiKey });
case "midjourney":
return createMidjourney({ baseURL, apiKey });
case "runway":
diff --git a/components/chat.tsx b/components/chat.tsx
index 0776a0cbb..559e64546 100644
--- a/components/chat.tsx
+++ b/components/chat.tsx
@@ -56,7 +56,12 @@ function normalizeImageParamsForProvider(
const normalized = { ...params };
const currentCount = normalized.imageCount || 1;
- if (providerType === "ruxa-image" || providerType === "openai-image" || providerType === "midjourney") {
+ if (
+ providerType === "ruxa-image" ||
+ providerType === "openai-image" ||
+ providerType === "stepfun-image" ||
+ providerType === "midjourney"
+ ) {
normalized.imageCount = 1;
return normalized;
}
diff --git a/components/image-generation-loading.tsx b/components/image-generation-loading.tsx
index b434e8087..c873c6e3d 100644
--- a/components/image-generation-loading.tsx
+++ b/components/image-generation-loading.tsx
@@ -21,6 +21,7 @@ const PROVIDER_MODEL_LABEL: Record = {
"ruxa-image": "RUXA 图片",
"fal-image": "FAL 图片",
"openai-image": "OpenAI 图片",
+ "stepfun-image": "StepFun 图片",
midjourney: "Midjourney",
"volcano-vision": "即梦 4.5",
gemini: "Gemini 图片",
diff --git a/components/image-params-bar.tsx b/components/image-params-bar.tsx
index a4be2224c..479c0d0e5 100644
--- a/components/image-params-bar.tsx
+++ b/components/image-params-bar.tsx
@@ -113,7 +113,12 @@ export function ImageParamsBar({
const getImageCountOptions = () => {
// RUXA 当前按单图任务处理(优先级最高)
- if (providerType === "ruxa-image" || providerType === "openai-image" || providerType === "midjourney") {
+ if (
+ providerType === "ruxa-image" ||
+ providerType === "openai-image" ||
+ providerType === "stepfun-image" ||
+ providerType === "midjourney"
+ ) {
return [1];
}
// 优先使用模型配置中的 maxImages
diff --git a/components/messages.tsx b/components/messages.tsx
index dc2b02669..f9be29342 100644
--- a/components/messages.tsx
+++ b/components/messages.tsx
@@ -697,6 +697,7 @@ function PureMessages({
imageCount={imageCount}
maxImages={
currentModelProviderType === "openai-image" ||
+ currentModelProviderType === "stepfun-image" ||
currentModelProviderType === "midjourney"
? 1
: 6
@@ -708,6 +709,8 @@ function PureMessages({
? "fal-image"
: currentModelProviderType === "openai-image"
? "openai-image"
+ : currentModelProviderType === "stepfun-image"
+ ? "stepfun-image"
: currentModelProviderType === "midjourney"
? "midjourney"
: "alibaba-image"
diff --git a/lib/ai/dynamic-provider.ts b/lib/ai/dynamic-provider.ts
index 8cc183333..828c8e3c1 100644
--- a/lib/ai/dynamic-provider.ts
+++ b/lib/ai/dynamic-provider.ts
@@ -22,6 +22,7 @@ import { createBflImage } from "./providers/bfl-image";
import { createRuxaImage } from "./providers/ruxa-image";
import { createFalImage } from "./providers/fal-image";
import { createOpenAIImage } from "./providers/openai-image";
+import { createStepfunImage } from "./providers/stepfun-image";
import { createMidjourney } from "./providers/midjourney";
import { createFalVideo } from "./providers/fal-video";
import { createRuxaVideo } from "./providers/ruxa-video";
@@ -260,6 +261,15 @@ function getOrCreateProvider(provider: Pick = {
// 图片编辑:优先 alibaba-image(灵魂4.5,支持提示词扩展)
image_edit: {
- providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "krea", "gemini", "huanke-gemini"],
+ providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"],
requireVision: true,
requireImageGen: true,
description: "图片编辑",
@@ -170,7 +171,7 @@ const INTENT_MODEL_MAP: Record<
// 图片生成:优先灵魂4.5
image_gen: {
- providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
+ providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
requireImageGen: true,
description: "图片生成",
},
@@ -198,25 +199,25 @@ const SUB_INTENT_OVERRIDES: Record<
> = {
// 抠图:alibaba-image 效果最好
remove_background: {
- providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "krea", "gemini"],
+ providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea", "gemini"],
reason: "抠图首选灵魂4.5",
},
// 换背景:alibaba-image 支持场景生成
replace_background: {
- providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "krea", "gemini"],
+ providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea", "gemini"],
reason: "换背景首选灵魂4.5",
},
// 图片增强
enhance: {
- providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "krea", "gemini", "volcano-vision"],
+ providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"],
reason: "增强首选灵魂4.5",
},
// 风格迁移:gemini 可能更有创意
style_transfer: {
- providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "runway", "krea"],
+ providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "runway", "krea"],
reason: "风格迁移优选Gemini",
},
};
diff --git a/lib/ai/provider-defaults.ts b/lib/ai/provider-defaults.ts
index f09aab83c..82c5b9fd3 100644
--- a/lib/ai/provider-defaults.ts
+++ b/lib/ai/provider-defaults.ts
@@ -16,6 +16,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial> = {
"ruxa-image": "https://api.ruxa.ai",
"fal-image": "https://fal.run",
"openai-image": "https://api.openai.com/v1",
+ "stepfun-image": "https://api.stepfun.com",
"bfl-image": "https://api.bfl.ai",
midjourney: "https://your-midjourney-api.example.com",
"fal-video": "https://fal.run",
diff --git a/lib/ai/providers/stepfun-image.ts b/lib/ai/providers/stepfun-image.ts
new file mode 100644
index 000000000..0c94cdc51
--- /dev/null
+++ b/lib/ai/providers/stepfun-image.ts
@@ -0,0 +1,436 @@
+import "server-only";
+
+import sharp from "sharp";
+import { uploadFile } from "@/lib/storage/service";
+
+interface StepfunImageConfig {
+ apiKey: string;
+ baseURL: string;
+}
+
+export interface StepfunImageParams {
+ aspectRatio?: string;
+ imageSize?: string;
+ imageCount?: number;
+ size?: string;
+ seed?: number;
+ negativePrompt?: string;
+}
+
+interface UploadedImage {
+ thumbnail: string;
+ original: string;
+}
+
+const DEFAULT_BASE_URL = "https://api.stepfun.com";
+const DEFAULT_TIMEOUT_MS = 300000;
+const DEFAULT_CFG_SCALE = 1.0;
+const DEFAULT_STEPS = 8;
+const DEFAULT_TEXT_MODE = true;
+const MAX_PROMPT_LENGTH = 512;
+
+function normalizeBaseURL(baseURL: string) {
+ const normalized = String(baseURL || "").trim().replace(/\/+$/, "");
+ const base = normalized || DEFAULT_BASE_URL;
+ if (/\/v1$/i.test(base)) return base;
+ return `${base}/v1`;
+}
+
+function joinEndpoint(baseURL: string, path: string) {
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
+ return `${normalizeBaseURL(baseURL)}${normalizedPath}`;
+}
+
+function normalizePrompt(prompt: string) {
+ const normalized = String(prompt || "").trim();
+ if (normalized.length <= MAX_PROMPT_LENGTH) return normalized;
+ console.warn("[StepFun Image] Prompt exceeds 512 characters, truncating for step-image-edit-2");
+ return normalized.slice(0, MAX_PROMPT_LENGTH);
+}
+
+function parseAspectRatioValue(ratio?: string | null) {
+ const normalized = String(ratio || "").trim();
+ const [width, height] = normalized.split(":").map((item) => Number(item));
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
+ return 1;
+ }
+ return width / height;
+}
+
+function resolveStepfunSize(aspectRatio?: string, explicitSize?: string) {
+ const normalizedExplicit = String(explicitSize || "").trim();
+ if (/^\d{3,4}x\d{3,4}$/i.test(normalizedExplicit)) {
+ return normalizedExplicit;
+ }
+
+ const ratio = parseAspectRatioValue(aspectRatio);
+ const candidates = [
+ { size: "1024x1024", ratio: 1 },
+ { size: "768x1360", ratio: 1360 / 768 },
+ { size: "896x1184", ratio: 1184 / 896 },
+ { size: "1360x768", ratio: 768 / 1360 },
+ { size: "1184x896", ratio: 896 / 1184 },
+ ];
+
+ let best = candidates[0];
+ let minDiff = Number.POSITIVE_INFINITY;
+ for (const candidate of candidates) {
+ const diff = Math.abs(candidate.ratio - ratio);
+ if (diff < minDiff) {
+ best = candidate;
+ minDiff = diff;
+ }
+ }
+ return best.size;
+}
+
+function detectImageExtension(imageUrl: string, contentType?: string | null) {
+ const type = String(contentType || "").toLowerCase();
+ if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
+ if (type.includes("webp")) return "webp";
+ if (type.includes("png")) return "png";
+
+ const cleanUrl = imageUrl.split("?")[0]?.toLowerCase() || "";
+ if (cleanUrl.endsWith(".jpg") || cleanUrl.endsWith(".jpeg")) return "jpg";
+ if (cleanUrl.endsWith(".webp")) return "webp";
+ if (cleanUrl.endsWith(".png")) return "png";
+ return "png";
+}
+
+async function createThumbnail(buffer: Buffer) {
+ try {
+ return await sharp(buffer)
+ .resize(400, 400, { fit: "inside", withoutEnlargement: true })
+ .jpeg({ quality: 80 })
+ .toBuffer();
+ } catch (error) {
+ console.error("[StepFun Image] Failed to create thumbnail:", error);
+ return buffer;
+ }
+}
+
+function generateFilename(prefix: string, extension: string) {
+ const timestamp = Date.now();
+ const random = Math.random().toString(36).slice(2, 8);
+ return `${prefix}-${timestamp}-${random}.${extension}`;
+}
+
+async function uploadGeneratedBuffer(
+ buffer: Buffer,
+ extension: string,
+ mimeType: string
+): Promise {
+ const thumbnailBuffer = await createThumbnail(buffer);
+
+ const originalFilename = generateFilename("stepfun-image-orig", extension);
+ const originalArrayBuffer = buffer.buffer.slice(
+ buffer.byteOffset,
+ buffer.byteOffset + buffer.byteLength
+ ) as ArrayBuffer;
+ const originalUploaded = await uploadFile(originalFilename, originalArrayBuffer);
+
+ const thumbFilename = generateFilename("stepfun-image-thumb", "jpg");
+ const thumbArrayBuffer = thumbnailBuffer.buffer.slice(
+ thumbnailBuffer.byteOffset,
+ thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
+ ) as ArrayBuffer;
+ const thumbUploaded = await uploadFile(thumbFilename, thumbArrayBuffer);
+
+ console.log("[StepFun Image] Uploaded generated image:", {
+ original: originalUploaded.url,
+ thumbnail: thumbUploaded.url,
+ mimeType,
+ });
+
+ return {
+ thumbnail: thumbUploaded.url,
+ original: originalUploaded.url,
+ };
+}
+
+async function downloadAndUploadImage(imageUrl: string): Promise {
+ const response = await fetch(imageUrl, { cache: "no-store" });
+ if (!response.ok) {
+ throw new Error(`Failed to download StepFun generated image: ${response.status}`);
+ }
+ const buffer = Buffer.from(await response.arrayBuffer());
+ const extension = detectImageExtension(imageUrl, response.headers.get("content-type"));
+ const mimeType = response.headers.get("content-type") || `image/${extension === "jpg" ? "jpeg" : extension}`;
+ return uploadGeneratedBuffer(buffer, extension, mimeType);
+}
+
+async function uploadBase64ToS3(base64Data: string, mimeType: string) {
+ const buffer = Buffer.from(base64Data, "base64");
+ const extension = detectImageExtension("", mimeType);
+ return uploadGeneratedBuffer(
+ buffer,
+ extension,
+ mimeType || `image/${extension === "jpg" ? "jpeg" : extension}`
+ );
+}
+
+function extractPromptAndImages(messages: Array<{ role: string; content: any }>) {
+ const lastUserMessage = messages.filter((message) => message.role === "user").pop();
+ if (!lastUserMessage) {
+ throw new Error("No user message found");
+ }
+
+ let prompt = "";
+ const imageUrls: string[] = [];
+
+ const pushImageUrl = (value?: string) => {
+ if (!value) return;
+ const normalized = String(value).trim();
+ if (!normalized || imageUrls.includes(normalized)) return;
+ imageUrls.push(normalized);
+ };
+
+ if (typeof lastUserMessage.content === "string") {
+ prompt = lastUserMessage.content.trim();
+ } else if (Array.isArray(lastUserMessage.content)) {
+ for (const part of lastUserMessage.content) {
+ if (part?.type === "text" && typeof part.text === "string") {
+ prompt = `${prompt} ${part.text}`.trim();
+ } else if (part?.type === "image_url" && part.image_url?.url) {
+ pushImageUrl(part.image_url.url);
+ } else if (part?.type === "file") {
+ pushImageUrl(part.url || part.data);
+ } else if (part?.type === "image") {
+ pushImageUrl(part.image || part.url);
+ }
+ }
+ }
+
+ if (!prompt) {
+ throw new Error("No prompt found in user message");
+ }
+
+ return { prompt: normalizePrompt(prompt), imageUrls };
+}
+
+async function loadInputImage(source: string) {
+ const normalized = String(source || "").trim();
+ if (!normalized) {
+ throw new Error("输入图片地址为空");
+ }
+
+ if (normalized.startsWith("data:")) {
+ const match = normalized.match(/^data:([A-Za-z0-9.+/-]+);base64,(.+)$/);
+ if (!match) {
+ throw new Error("不支持的 Base64 图片格式");
+ }
+ const mimeType = match[1] || "image/png";
+ const buffer = Buffer.from(match[2], "base64");
+ const extension = detectImageExtension("", mimeType);
+ return {
+ buffer,
+ mimeType,
+ filename: generateFilename("stepfun-image-edit-input", extension),
+ };
+ }
+
+ const response = await fetch(normalized, { cache: "no-store" });
+ if (!response.ok) {
+ throw new Error(`Failed to fetch StepFun input image: ${response.status}`);
+ }
+ const buffer = Buffer.from(await response.arrayBuffer());
+ const mimeType = response.headers.get("content-type") || "image/png";
+ const extension = detectImageExtension(normalized, mimeType);
+ return {
+ buffer,
+ mimeType,
+ filename: generateFilename("stepfun-image-edit-input", extension),
+ };
+}
+
+function normalizeSeed(seed?: number) {
+ if (typeof seed !== "number" || !Number.isFinite(seed)) return undefined;
+ return Math.max(0, Math.floor(seed));
+}
+
+function extractErrorMessage(payload: any) {
+ if (!payload) return "StepFun Images 请求失败";
+ if (typeof payload === "string") return payload;
+ if (payload.error?.message) return String(payload.error.message);
+ if (payload.message) return String(payload.message);
+ try {
+ return JSON.stringify(payload);
+ } catch {
+ return "StepFun Images 请求失败";
+ }
+}
+
+async function parseJsonResponse(response: Response) {
+ const rawText = await response.text();
+ try {
+ return JSON.parse(rawText);
+ } catch {
+ return rawText;
+ }
+}
+
+async function requestStepfunImage(params: {
+ baseURL: string;
+ apiKey: string;
+ model: string;
+ prompt: string;
+ size?: string;
+ imageUrls: string[];
+ seed?: number;
+}) {
+ const { baseURL, apiKey, model, prompt, size, imageUrls, seed } = params;
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
+
+ try {
+ if (imageUrls.length > 0) {
+ const endpoint = joinEndpoint(baseURL, "/images/edits");
+ const formData = new FormData();
+ formData.append("model", model);
+ formData.append("prompt", prompt);
+ formData.append("response_format", "b64_json");
+ formData.append("cfg_scale", String(DEFAULT_CFG_SCALE));
+ formData.append("steps", String(DEFAULT_STEPS));
+ formData.append("text_mode", String(DEFAULT_TEXT_MODE));
+ const normalizedSeed = normalizeSeed(seed);
+ if (normalizedSeed !== undefined) formData.append("seed", String(normalizedSeed));
+
+ for (const source of imageUrls.slice(0, 1)) {
+ const image = await loadInputImage(source);
+ formData.append("image", new Blob([image.buffer], { type: image.mimeType }), image.filename);
+ }
+
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: formData,
+ signal: controller.signal,
+ });
+ const payload = await parseJsonResponse(response);
+ if (!response.ok) {
+ throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
+ }
+ return payload;
+ }
+
+ const endpoint = joinEndpoint(baseURL, "/images/generations");
+ const body: Record = {
+ model,
+ prompt,
+ response_format: "b64_json",
+ cfg_scale: DEFAULT_CFG_SCALE,
+ steps: DEFAULT_STEPS,
+ text_mode: DEFAULT_TEXT_MODE,
+ ...(size ? { size } : {}),
+ };
+ const normalizedSeed = normalizeSeed(seed);
+ if (normalizedSeed !== undefined) body.seed = normalizedSeed;
+
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ signal: controller.signal,
+ });
+ const payload = await parseJsonResponse(response);
+ if (!response.ok) {
+ throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
+ }
+ return payload;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+async function extractUploadedImages(payload: any): Promise {
+ const data = Array.isArray(payload?.data) ? payload.data : [];
+ const uploadedImages: UploadedImage[] = [];
+
+ for (const item of data) {
+ if (item?.url && typeof item.url === "string") {
+ uploadedImages.push(await downloadAndUploadImage(item.url));
+ continue;
+ }
+
+ if (item?.b64_json && typeof item.b64_json === "string") {
+ uploadedImages.push(await uploadBase64ToS3(item.b64_json, "image/png"));
+ }
+ }
+
+ return uploadedImages;
+}
+
+function formatMarkdown(images: UploadedImage[], imageSize: string) {
+ return images
+ .map((image) => ``)
+ .join("\n\n");
+}
+
+export function createStepfunImage({ apiKey, baseURL }: StepfunImageConfig) {
+ return {
+ provider: "stepfun-image",
+ chat: (modelId: string) => ({
+ provider: "stepfun-image",
+ doGenerate: async (options: {
+ messages: Array<{ role: string; content: any }>;
+ imageParams?: StepfunImageParams;
+ overrideModelId?: string;
+ }) => {
+ const { prompt, imageUrls } = extractPromptAndImages(options.messages);
+ const activeModelId = options.overrideModelId || modelId;
+ const size =
+ imageUrls.length > 0
+ ? undefined
+ : resolveStepfunSize(options.imageParams?.aspectRatio, options.imageParams?.size);
+
+ console.log("[StepFun Image] Submitting request", {
+ model: activeModelId,
+ size,
+ imageCount: imageUrls.length,
+ baseURL: normalizeBaseURL(baseURL),
+ });
+
+ const payload = await requestStepfunImage({
+ baseURL,
+ apiKey,
+ model: activeModelId,
+ prompt,
+ size,
+ imageUrls,
+ seed: options.imageParams?.seed,
+ });
+
+ const uploadedImages = await extractUploadedImages(payload);
+ if (uploadedImages.length === 0) {
+ throw new Error("StepFun Images 未返回可用图片");
+ }
+
+ const text = formatMarkdown(uploadedImages, options.imageParams?.imageSize || "1K");
+ return {
+ text,
+ finishReason: "stop" as const,
+ usage: {
+ promptTokens: 0,
+ completionTokens: text.length,
+ },
+ };
+ },
+ doStream: async function* (options: {
+ messages: Array<{ role: string; content: any }>;
+ imageParams?: StepfunImageParams;
+ overrideModelId?: string;
+ }) {
+ const result = await this.doGenerate(options);
+ yield {
+ type: "text-delta",
+ textDelta: result.text,
+ };
+ },
+ }),
+ };
+}
diff --git a/lib/ai/runtime/generated-image-persistence.ts b/lib/ai/runtime/generated-image-persistence.ts
index b943a84d7..bc1350a93 100644
--- a/lib/ai/runtime/generated-image-persistence.ts
+++ b/lib/ai/runtime/generated-image-persistence.ts
@@ -53,6 +53,8 @@ export function parseImageAltMetadata(altText: string, thumbnailUrl: string): {
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("stepfun-image-thumb-")) {
+ originalUrl = thumbnailUrl.replace("stepfun-image-thumb-", "stepfun-image-orig-");
} else if (thumbnailUrl.includes("fal-image-thumb-")) {
originalUrl = thumbnailUrl.replace("fal-image-thumb-", "fal-image-orig-");
}
diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts
index d79d1c7c2..339a70267 100644
--- a/lib/canvas/generate-image.ts
+++ b/lib/canvas/generate-image.ts
@@ -7,6 +7,7 @@ import { createBflImage } from "@/lib/ai/providers/bfl-image";
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
import { createFalImage } from "@/lib/ai/providers/fal-image";
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
+import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
import { createMidjourney } from "@/lib/ai/providers/midjourney";
import { createRunway } from "@/lib/ai/providers/runway";
import { createKrea } from "@/lib/ai/providers/krea";
@@ -47,6 +48,7 @@ const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
"ruxa-image",
"fal-image",
"openai-image",
+ "stepfun-image",
"midjourney",
"runway",
"krea",
@@ -103,6 +105,7 @@ function getProviderBatchLimit(providerType: string) {
case "ruxa-image":
case "bfl-image":
case "openai-image":
+ case "stepfun-image":
case "midjourney":
case "runway":
return 1;
@@ -191,7 +194,7 @@ export async function generateCanvasImage(
}
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) {
throw new Error(
- "当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/阿里/BFL/RUXA/FAL/OpenAI/Runway/Krea 图片模型。"
+ "当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/阿里/BFL/RUXA/FAL/OpenAI/StepFun/Runway/Krea 图片模型。"
);
}
if (fallbackModel.model.id !== selectedModel.model.id) {
@@ -374,6 +377,19 @@ export async function generateCanvasImage(
});
return result.text || "";
};
+ } else if (provider.type === "stepfun-image") {
+ const stepfunProvider = createStepfunImage({
+ apiKey: provider.apiKey,
+ baseURL: provider.baseUrl,
+ });
+ const modelInstance = stepfunProvider.chat(selectedModel.model.modelId);
+ executeGenerateBatch = async (_batchImageCount) => {
+ const result = await modelInstance.doGenerate({
+ messages,
+ imageParams,
+ });
+ return result.text || "";
+ };
} else if (provider.type === "midjourney") {
const midjourneyProvider = createMidjourney({
apiKey: provider.apiKey,
@@ -421,7 +437,7 @@ export async function generateCanvasImage(
};
} else {
throw new Error(
- `不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/阿里/BFL/RUXA/FAL/OpenAI/Midjourney/Runway/Krea 图片模型`
+ `不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/阿里/BFL/RUXA/FAL/OpenAI/StepFun/Midjourney/Runway/Krea 图片模型`
);
}
diff --git a/lib/db/migrate.ts b/lib/db/migrate.ts
index 3aa4d011b..deb3307a2 100644
--- a/lib/db/migrate.ts
+++ b/lib/db/migrate.ts
@@ -32,6 +32,7 @@ const providerBaseUrlDefaults = [
{ type: "ruxa-image", baseUrl: "https://api.ruxa.ai" },
{ type: "fal-image", baseUrl: "https://fal.run" },
{ type: "openai-image", baseUrl: "https://api.openai.com/v1" },
+ { type: "stepfun-image", baseUrl: "https://api.stepfun.com" },
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
{ type: "fal-video", baseUrl: "https://fal.run" },
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
@@ -98,7 +99,7 @@ const runMigrate = async () => {
ADD CONSTRAINT "AiProvider_type_check"
CHECK (type IN (
'alibaba', 'openai', 'custom', 'local', 'gemini',
- 'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
+ 'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
));
`);
await db.execute(sql`
diff --git a/lib/db/migrations/0077_add_stepfun_image_provider_type.sql b/lib/db/migrations/0077_add_stepfun_image_provider_type.sql
new file mode 100644
index 000000000..6096d1d0d
--- /dev/null
+++ b/lib/db/migrations/0077_add_stepfun_image_provider_type.sql
@@ -0,0 +1,36 @@
+ALTER TABLE "AiProvider"
+ DROP CONSTRAINT IF EXISTS "AiProvider_type_check";
+
+ALTER TABLE "AiProvider"
+ ADD CONSTRAINT "AiProvider_type_check"
+ CHECK (
+ "type" IN (
+ 'alibaba',
+ 'openai',
+ 'custom',
+ 'local',
+ 'gemini',
+ 'grsai-gpt-image',
+ 'grsai-video',
+ 'huanke-gemini',
+ 'qianwen-video',
+ 'doubao-video',
+ 'ruxa-video',
+ 'volcano-vision',
+ 'volcano-video',
+ 'alibaba-image',
+ 'bfl-image',
+ 'ruxa-image',
+ 'fal-image',
+ 'openai-image',
+ 'stepfun-image',
+ 'midjourney',
+ 'fal-video',
+ 'zhipu',
+ 'tripo-3d',
+ 'worldlabs-3d',
+ 'kittycad-cad',
+ 'runway',
+ 'krea'
+ )
+ );
diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json
index cc74ac8df..1d58b4f63 100644
--- a/lib/db/migrations/meta/_journal.json
+++ b/lib/db/migrations/meta/_journal.json
@@ -442,6 +442,13 @@
"when": 1777305600000,
"tag": "0076_add_ai_model_text_edit_default",
"breakpoints": true
+ },
+ {
+ "idx": 63,
+ "version": "7",
+ "when": 1777392000000,
+ "tag": "0077_add_stepfun_image_provider_type",
+ "breakpoints": true
}
]
}
diff --git a/lib/db/queries.ts b/lib/db/queries.ts
index bdb4ab1ca..c937de70f 100644
--- a/lib/db/queries.ts
+++ b/lib/db/queries.ts
@@ -3751,7 +3751,7 @@ export async function createAiProvider({
updatedBy,
}: {
name: string;
- type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
+ type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
baseUrl: string;
apiKey: string;
isEnabled?: boolean;
@@ -3789,7 +3789,7 @@ export async function updateAiProvider({
}: {
id: string;
name?: string;
- type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
+ type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
baseUrl?: string;
apiKey?: string;
isEnabled?: boolean;
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 06ff7fc51..496de7f51 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -276,7 +276,7 @@ export type ToolConfig = InferSelectModel;
export const aiProvider = pgTable("AiProvider", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
- type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
+ type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
.notNull()
.default("custom"), // 提供商类型
baseUrl: text("baseUrl").notNull(), // API 基础 URL
diff --git a/lib/image-url.ts b/lib/image-url.ts
index c18e208a9..8de2cd926 100644
--- a/lib/image-url.ts
+++ b/lib/image-url.ts
@@ -16,6 +16,7 @@ export function isLikelyThumbnailUrl(url: string): boolean {
lowerUrl.includes("volcano-thumb-") ||
lowerUrl.includes("alibaba-image-thumb-") ||
lowerUrl.includes("ruxa-image-thumb-") ||
+ lowerUrl.includes("stepfun-image-thumb-") ||
lowerUrl.includes("fal-image-thumb-")
) {
return true;
@@ -42,6 +43,9 @@ export function inferOriginalImageUrl(url: string): string {
if (url.includes("ruxa-image-thumb-")) {
return url.replace("ruxa-image-thumb-", "ruxa-image-orig-");
}
+ if (url.includes("stepfun-image-thumb-")) {
+ return url.replace("stepfun-image-thumb-", "stepfun-image-orig-");
+ }
if (url.includes("fal-image-thumb-")) {
return url.replace("fal-image-thumb-", "fal-image-orig-");
}