feat: 集成阿里千问生图模型 (alibaba-image)

- 新增 alibaba-image provider 支持 qwen-image-edit-plus 系列模型
- 支持 1-6 张图片输出、提示词优化、反向提示词等参数
- 扩展 ImageParamsBar 支持阿里模型专用参数
- 创建通用 ImageGenerationLoading 组件(可配置颜色主题)
- 管理后台添加图片生成模型特性配置界面
- 数据库新增 imageGenConfig 字段存储生图特性
- 确保生成图片正确保存到相册和生图记录
This commit is contained in:
Your Name 2026-01-12 12:02:44 +08:00
parent 9393382e6d
commit eae8ea9436
16 changed files with 1543 additions and 27 deletions

View File

@ -94,13 +94,25 @@ type ToolConfig = {
type AiProvider = { type AiProvider = {
id: string; id: string;
name: string; name: string;
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "qianwen-video" | "volcano-vision" | "volcano-video"; type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image";
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
isEnabled: boolean; isEnabled: boolean;
createdAt: string; createdAt: string;
}; };
// 🆕 图片生成模型特性配置
type ImageGenConfig = {
maxImages?: number; // 最大输出图片数,如阿里 1-6,Gemini 1-2
supportsSize?: boolean; // 是否支持自定义分辨率
supportsPromptExtend?: boolean; // 是否支持提示词智能优化
supportsNegativePrompt?: boolean; // 是否支持反向提示词
supportsWatermark?: boolean; // 是否支持水印开关
supportsSeed?: boolean; // 是否支持随机种子
enableLoadingPlaceholder?: boolean; // 是否启用加载占位图
loadingColor?: string; // 占位图主题色,如 "purple", "blue", "green"
};
type AiModel = { type AiModel = {
id: string; id: string;
providerId: string; providerId: string;
@ -112,6 +124,7 @@ type AiModel = {
isEnabled: boolean; isEnabled: boolean;
isDefault: boolean; isDefault: boolean;
sortOrder: number; sortOrder: number;
imageGenConfig?: ImageGenConfig; // 🆕 图片生成特性配置
}; };
type StorageConfig = { type StorageConfig = {
@ -232,6 +245,7 @@ const AVAILABLE_TOOLS = [
const PROVIDER_TYPES = [ const PROVIDER_TYPES = [
{ value: "alibaba", label: "阿里云通义千问" }, { value: "alibaba", label: "阿里云通义千问" },
{ value: "alibaba-image", label: "阿里生图模型" }, // 🆕 千问图片生成/编辑
{ value: "openai", label: "OpenAI 兼容" }, { value: "openai", label: "OpenAI 兼容" },
{ value: "custom", label: "自定义 OpenAI 兼容" }, { value: "custom", label: "自定义 OpenAI 兼容" },
{ value: "local", label: "本地模型" }, { value: "local", label: "本地模型" },
@ -591,7 +605,7 @@ export function AdminSettingsClient() {
const [providerForm, setProviderForm] = useState({ const [providerForm, setProviderForm] = useState({
id: "", id: "",
name: "", name: "",
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "qianwen-video" | "volcano-vision" | "volcano-video", type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image",
baseUrl: "", baseUrl: "",
apiKey: "", apiKey: "",
isEnabled: true, isEnabled: true,
@ -611,8 +625,19 @@ export function AdminSettingsClient() {
isEnabled: true, isEnabled: true,
isDefault: false, isDefault: false,
sortOrder: 0, sortOrder: 0,
fallbackModelId1: "", // 🆕 备用模型 ID 1 fallbackModelId1: "", // 备用模型 ID 1
fallbackModelId2: "", // 🆕 备用模型 ID 2 fallbackModelId2: "", // 备用模型 ID 2
// 🆕 图片生成模型特性配置
imageGenConfig: {
maxImages: 2,
supportsSize: false,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
enableLoadingPlaceholder: true,
loadingColor: "purple",
} as ImageGenConfig,
}); });
const [showModelDialog, setShowModelDialog] = useState(false); const [showModelDialog, setShowModelDialog] = useState(false);
const [editingModel, setEditingModel] = useState<string | null>(null); const [editingModel, setEditingModel] = useState<string | null>(null);
@ -1086,6 +1111,16 @@ export function AdminSettingsClient() {
sortOrder: 0, sortOrder: 0,
fallbackModelId1: "", fallbackModelId1: "",
fallbackModelId2: "", fallbackModelId2: "",
imageGenConfig: {
maxImages: 2,
supportsSize: false,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
enableLoadingPlaceholder: true,
loadingColor: "purple",
},
}); });
setEditingModel(null); setEditingModel(null);
}; };
@ -2917,6 +2952,116 @@ export function AdminSettingsClient() {
<Label>设为默认</Label> <Label>设为默认</Label>
</div> </div>
</div> </div>
{/* 🆕 图片生成模型特性配置 */}
{modelForm.supportsImageGen && (
<div className="border rounded-lg p-4 space-y-4 bg-muted/30">
<div className="font-medium text-sm">图片生成配置</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>最大输出图片数</Label>
<Input
type="number"
min={1}
max={10}
value={modelForm.imageGenConfig?.maxImages || 2}
onChange={(e) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
maxImages: Number.parseInt(e.target.value) || 2,
},
})}
/>
<p className="text-xs text-muted-foreground">Gemini: 1-2, 阿里: 1-6</p>
</div>
<div className="space-y-2">
<Label>占位图颜色主题</Label>
<Select
value={modelForm.imageGenConfig?.loadingColor || "purple"}
onValueChange={(value) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
loadingColor: value,
},
})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="purple">紫色 (Gemini)</SelectItem>
<SelectItem value="orange">橙色 (阿里)</SelectItem>
<SelectItem value="blue">蓝色 (火山)</SelectItem>
<SelectItem value="green">绿色</SelectItem>
<SelectItem value="red">红色</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supportsPromptExtend || false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supportsPromptExtend: checked,
},
})}
/>
<Label className="text-sm">支持提示词优化</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supportsNegativePrompt || false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supportsNegativePrompt: checked,
},
})}
/>
<Label className="text-sm">支持反向提示词</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.supportsSize || false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
supportsSize: checked,
},
})}
/>
<Label className="text-sm">支持自定义分辨率</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={modelForm.imageGenConfig?.enableLoadingPlaceholder !== false}
onCheckedChange={(checked) => setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
enableLoadingPlaceholder: checked,
},
})}
/>
<Label className="text-sm">启用加载占位图</Label>
</div>
</div>
</div>
)}
</> </>
)} )}
</div> </div>
@ -2955,8 +3100,18 @@ export function AdminSettingsClient() {
isEnabled: model.isEnabled, isEnabled: model.isEnabled,
isDefault: model.isDefault, isDefault: model.isDefault,
sortOrder: model.sortOrder || 0, sortOrder: model.sortOrder || 0,
fallbackModelId1: model.fallbackModelId1 || "", fallbackModelId1: (model as any).fallbackModelId1 || "",
fallbackModelId2: model.fallbackModelId2 || "", fallbackModelId2: (model as any).fallbackModelId2 || "",
imageGenConfig: (model as any).imageGenConfig || {
maxImages: 2,
supportsSize: false,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
enableLoadingPlaceholder: true,
loadingColor: "purple",
},
}); });
setEditingModel(model.id); setEditingModel(model.id);
setShowModelDialog(true); setShowModelDialog(true);

View File

@ -242,6 +242,9 @@ async function saveGeneratedImages(
originalUrl = thumbnailUrl.replace("openai-thumb-", "openai-orig-"); originalUrl = thumbnailUrl.replace("openai-thumb-", "openai-orig-");
} else if (thumbnailUrl.includes("volcano-thumb-")) { } else if (thumbnailUrl.includes("volcano-thumb-")) {
originalUrl = thumbnailUrl.replace("volcano-thumb-", "volcano-orig-"); originalUrl = thumbnailUrl.replace("volcano-thumb-", "volcano-orig-");
} else if (thumbnailUrl.includes("alibaba-image-thumb-")) {
// 🆕 阿里生图模型
originalUrl = thumbnailUrl.replace("alibaba-image-thumb-", "alibaba-image-orig-");
} }
} }
@ -889,6 +892,124 @@ export async function POST(request: Request) {
} }
// ========== Gemini 处理结束 ========== // ========== Gemini 处理结束 ==========
// ========== 阿里云千问图片生成独立处理 ==========
if ((languageModel as any).provider === "alibaba-image" && typeof (languageModel as any).doGenerate === "function") {
console.log("[Chat API] Using Alibaba Image generation");
console.log("[Chat API] Image params:", requestBody.imageParams);
const alibabaStream = createUIMessageStream({
execute: async ({ writer }) => {
try {
// 只传递最后一条用户消息
const lastUserMessage = uiMessages.filter((m) => m.role === "user").pop();
const messagesForAlibaba = lastUserMessage ? [lastUserMessage] : uiMessages;
console.log("[Chat API] Alibaba: Using only last user message");
// 转换消息(阿里生图支持 URL,会自动上传 base64)
const modelMessages = await convertMessagesWithImages(messagesForAlibaba, "alibaba-image");
// 调用 doGenerate 获取图片
const result = await (languageModel as any).doGenerate({
messages: modelMessages,
imageParams: requestBody.imageParams || { aspectRatio: "auto", imageSize: "2K", imageCount: 1 },
});
const imageMarkdown = result.text || "图片生成失败";
console.log("[Chat API] Alibaba generated, length:", imageMarkdown.length);
// 生成消息 ID
const messageId = generateUUID();
// 写入消息到流
writer.write({
type: "data-appendMessage",
data: JSON.stringify({
id: messageId,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
}),
});
// 保存消息到数据库
try {
await saveMessages({
messages: [
{
id: messageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: imageMarkdown }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
console.log("[Chat API] ✅ Alibaba message saved successfully!");
// 提取用户 prompt
const userPrompt = lastUserMessage?.parts?.find((p): p is { type: "text"; text: string } => p.type === "text")?.text || "";
// 保存图片记录到 GeneratedImage 表
await saveGeneratedImages(
imageMarkdown,
session.user.id,
id,
messageId,
userPrompt,
actualModelId
);
} catch (saveError) {
console.error("[Chat API] ❌ Failed to save Alibaba message:", saveError);
}
} catch (error) {
console.error("[Chat API] Alibaba generation error:", error);
// 返回错误消息
const errorMessageId = generateUUID();
const errorText = error instanceof Error ? error.message : "图片生成失败,请稍后重试";
writer.write({
type: "data-appendMessage",
data: JSON.stringify({
id: errorMessageId,
role: "assistant",
parts: [{ type: "text", text: errorText }],
}),
});
try {
await saveMessages({
messages: [
{
id: errorMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: errorText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
} catch (saveError) {
console.error("[Chat API] Failed to save error message:", saveError);
}
}
},
});
return new Response(alibabaStream.pipeThrough(new JsonToSseTransformStream()));
}
// ========== 阿里云图片生成处理结束 ==========
// ========== 火山引擎视觉图片生成独立处理 ========== // ========== 火山引擎视觉图片生成独立处理 ==========
if ((languageModel as any).provider === "volcano-vision" && typeof (languageModel as any).doGenerate === "function") { if ((languageModel as any).provider === "volcano-vision" && typeof (languageModel as any).doGenerate === "function") {
console.log("[Chat API] Using Volcano Vision image generation"); console.log("[Chat API] Using Volcano Vision image generation");

View File

@ -14,7 +14,7 @@ const filePartSchema = z.object({
const partSchema = z.union([textPartSchema, filePartSchema]); const partSchema = z.union([textPartSchema, filePartSchema]);
// 🆕 视频参数 schema // 视频参数 schema
const videoParamsSchema = z.object({ const videoParamsSchema = z.object({
mode: z.enum(["text-to-video", "first-frame", "first-last-frame", "reference-images"]), mode: z.enum(["text-to-video", "first-frame", "first-last-frame", "reference-images"]),
resolution: z.enum(["480p", "720p", "1080p"]).optional(), resolution: z.enum(["480p", "720p", "1080p"]).optional(),
@ -28,6 +28,20 @@ const videoParamsSchema = z.object({
returnLastFrame: z.boolean().optional(), returnLastFrame: z.boolean().optional(),
}).optional(); }).optional();
// 🆕 图片生成参数 schema
const imageParamsSchema = z.object({
aspectRatio: z.string().optional(),
imageSize: z.string().optional(),
imageCount: z.number().min(1).max(10).optional(),
// 阿里生图专用参数
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
negativePrompt: z.string().optional(), // 反向提示词
promptExtend: z.boolean().optional(), // 提示词智能优化
watermark: z.boolean().optional(), // 水印
seed: z.number().optional(), // 随机种子
n: z.number().min(1).max(10).optional(), // 兼容 n 参数
}).optional();
export const postRequestBodySchema = z.object({ export const postRequestBodySchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
message: z.object({ message: z.object({
@ -38,7 +52,8 @@ export const postRequestBodySchema = z.object({
// 支持预定义模型名称或数据库中的模型 UUID // 支持预定义模型名称或数据库中的模型 UUID
selectedChatModel: z.string().min(1).max(128), selectedChatModel: z.string().min(1).max(128),
selectedVisibilityType: z.enum(["public", "private"]), selectedVisibilityType: z.enum(["public", "private"]),
videoParams: videoParamsSchema, // 🆕 视频参数 videoParams: videoParamsSchema, // 视频参数
imageParams: imageParamsSchema, // 🆕 图片生成参数
}); });
export type PostRequestBody = z.infer<typeof postRequestBodySchema>; export type PostRequestBody = z.infer<typeof postRequestBodySchema>;

View File

@ -11,6 +11,18 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
// 🆕 图片生成模型特性配置 Schema
const imageGenConfigSchema = z.object({
maxImages: z.number().min(1).max(10).optional(),
supportsSize: z.boolean().optional(),
supportsPromptExtend: z.boolean().optional(),
supportsNegativePrompt: z.boolean().optional(),
supportsWatermark: z.boolean().optional(),
supportsSeed: z.boolean().optional(),
enableLoadingPlaceholder: z.boolean().optional(),
loadingColor: z.string().optional(),
}).optional();
const createModelSchema = z.object({ const createModelSchema = z.object({
providerId: z.string().uuid(), providerId: z.string().uuid(),
modelId: z.string().min(1).max(128), modelId: z.string().min(1).max(128),
@ -21,13 +33,14 @@ const createModelSchema = z.object({
isEnabled: z.boolean().optional(), isEnabled: z.boolean().optional(),
isDefault: z.boolean().optional(), isDefault: z.boolean().optional(),
sortOrder: z.number().optional(), sortOrder: z.number().optional(),
fallbackModelId1: z.string().max(128).optional(), // 🆕 备用模型 ID 1 fallbackModelId1: z.string().max(128).optional(),
fallbackModelId2: z.string().max(128).optional(), // 🆕 备用模型 ID 2 fallbackModelId2: z.string().max(128).optional(),
imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置
}); });
const updateModelSchema = z.object({ const updateModelSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
providerId: z.string().uuid().optional(), // 允许修改提供商 providerId: z.string().uuid().optional(),
modelId: z.string().min(1).max(128).optional(), modelId: z.string().min(1).max(128).optional(),
displayName: z.string().min(1).max(128).optional(), displayName: z.string().min(1).max(128).optional(),
description: z.string().optional(), description: z.string().optional(),
@ -36,8 +49,9 @@ const updateModelSchema = z.object({
isEnabled: z.boolean().optional(), isEnabled: z.boolean().optional(),
isDefault: z.boolean().optional(), isDefault: z.boolean().optional(),
sortOrder: z.number().optional(), sortOrder: z.number().optional(),
fallbackModelId1: z.string().max(128).optional(), // 🆕 备用模型 ID 1 fallbackModelId1: z.string().max(128).optional(),
fallbackModelId2: z.string().max(128).optional(), // 🆕 备用模型 ID 2 fallbackModelId2: z.string().max(128).optional(),
imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置
}); });
export async function GET(request: Request) { export async function GET(request: Request) {

View File

@ -11,7 +11,7 @@ import { ChatSDKError } from "@/lib/errors";
const createProviderSchema = z.object({ const createProviderSchema = z.object({
name: z.string().min(1).max(128), name: z.string().min(1).max(128),
type: z.enum(["alibaba", "openai", "custom", "local", "gemini", "qianwen-video", "volcano-vision", "volcano-video"]), type: z.enum(["alibaba", "openai", "custom", "local", "gemini", "qianwen-video", "volcano-vision", "volcano-video", "alibaba-image"]),
baseUrl: z.string().min(1), // 改为简单的字符串验证,支持 localhost baseUrl: z.string().min(1), // 改为简单的字符串验证,支持 localhost
apiKey: z.string().min(1), apiKey: z.string().min(1),
isEnabled: z.boolean().optional(), isEnabled: z.boolean().optional(),
@ -20,7 +20,7 @@ const createProviderSchema = z.object({
const updateProviderSchema = z.object({ const updateProviderSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
name: z.string().min(1).max(128).optional(), name: z.string().min(1).max(128).optional(),
type: z.enum(["alibaba", "openai", "custom", "local", "gemini", "qianwen-video", "volcano-vision", "volcano-video"]).optional(), type: z.enum(["alibaba", "openai", "custom", "local", "gemini", "qianwen-video", "volcano-vision", "volcano-video", "alibaba-image"]).optional(),
baseUrl: z.string().min(1).optional(), // 改为简单的字符串验证,支持 localhost baseUrl: z.string().min(1).optional(), // 改为简单的字符串验证,支持 localhost
apiKey: z.string().min(1).optional(), apiKey: z.string().min(1).optional(),
isEnabled: z.boolean().optional(), isEnabled: z.boolean().optional(),

View File

@ -0,0 +1,259 @@
"use client";
import { memo } from "react";
import { SparklesIcon } from "./icons";
// 预定义的颜色主题
const COLOR_THEMES = {
purple: {
ping: "bg-purple-500/20",
pulse: "bg-purple-500/10",
ring: "ring-purple-500/30",
text: "text-purple-500",
dot: "bg-purple-500",
gradientFrom: "from-purple-400",
gradientVia: "via-pink-400",
gradientTo: "to-blue-400",
overlay1From: "from-purple-500/40",
overlay1To: "to-pink-500/40",
overlay2From: "from-blue-500/30",
overlay2To: "to-purple-500/30",
},
blue: {
ping: "bg-blue-500/20",
pulse: "bg-blue-500/10",
ring: "ring-blue-500/30",
text: "text-blue-500",
dot: "bg-blue-500",
gradientFrom: "from-blue-400",
gradientVia: "via-cyan-400",
gradientTo: "to-teal-400",
overlay1From: "from-blue-500/40",
overlay1To: "to-cyan-500/40",
overlay2From: "from-teal-500/30",
overlay2To: "to-blue-500/30",
},
green: {
ping: "bg-green-500/20",
pulse: "bg-green-500/10",
ring: "ring-green-500/30",
text: "text-green-500",
dot: "bg-green-500",
gradientFrom: "from-green-400",
gradientVia: "via-emerald-400",
gradientTo: "to-teal-400",
overlay1From: "from-green-500/40",
overlay1To: "to-emerald-500/40",
overlay2From: "from-teal-500/30",
overlay2To: "to-green-500/30",
},
orange: {
ping: "bg-orange-500/20",
pulse: "bg-orange-500/10",
ring: "ring-orange-500/30",
text: "text-orange-500",
dot: "bg-orange-500",
gradientFrom: "from-orange-400",
gradientVia: "via-amber-400",
gradientTo: "to-yellow-400",
overlay1From: "from-orange-500/40",
overlay1To: "to-amber-500/40",
overlay2From: "from-yellow-500/30",
overlay2To: "to-orange-500/30",
},
red: {
ping: "bg-red-500/20",
pulse: "bg-red-500/10",
ring: "ring-red-500/30",
text: "text-red-500",
dot: "bg-red-500",
gradientFrom: "from-red-400",
gradientVia: "via-rose-400",
gradientTo: "to-pink-400",
overlay1From: "from-red-500/40",
overlay1To: "to-rose-500/40",
overlay2From: "from-pink-500/30",
overlay2To: "to-red-500/30",
},
} as const;
type ColorTheme = keyof typeof COLOR_THEMES;
interface ImageGenerationLoadingProps {
imageCount?: number; // 生成图片数量,默认 1
maxImages?: number; // 最大图片数限制,默认 6
colorTheme?: ColorTheme; // 颜色主题,默认 purple
modelName?: string; // 模型名称,用于显示
providerType?: string; // 提供商类型,用于自动选择颜色
}
/**
* 通用图片生成加载占位符组件
* 支持配置颜色主题和图片数量
*/
export const ImageGenerationLoading = memo(({
imageCount = 1,
maxImages = 6,
colorTheme,
modelName,
providerType,
}: ImageGenerationLoadingProps) => {
// 根据 providerType 自动选择颜色主题
const getAutoColorTheme = (): ColorTheme => {
if (colorTheme) return colorTheme;
switch (providerType) {
case "gemini":
return "purple";
case "alibaba-image":
return "orange";
case "volcano-vision":
return "blue";
default:
return "purple";
}
};
const theme = COLOR_THEMES[getAutoColorTheme()];
// 确保数量在有效范围内
const count = Math.min(Math.max(imageCount, 1), maxImages);
// 获取显示名称
const displayName = modelName || (providerType === "alibaba-image" ? "千问图片编辑" :
providerType === "volcano-vision" ? "即梦4.5" : "AI");
return (
<div
className="group/message fade-in w-full animate-in duration-300"
data-role="assistant"
data-testid="message-assistant-image-loading"
>
<div className="flex items-start justify-start gap-3">
{/* 头像 */}
<div className="relative -mt-1 flex size-8 shrink-0 items-center justify-center">
<div className={`absolute inset-0 rounded-full ${theme.ping} animate-ping`} />
<div className={`absolute inset-0 rounded-full ${theme.pulse} animate-pulse`} />
<div className={`relative flex size-8 items-center justify-center rounded-full bg-background ring-2 ${theme.ring}`}>
<div className={`animate-spin ${theme.text}`}>
<SparklesIcon size={14} />
</div>
</div>
</div>
<div className="flex flex-col gap-3">
{/* 文本提示 */}
<div className="flex items-center gap-1.5">
<span className="text-base font-medium text-foreground">
正在生成 {count} 张图片
</span>
<span className="flex gap-1">
<span
className={`h-2 w-2 rounded-full ${theme.dot} animate-bounce`}
style={{ animationDelay: "0ms" }}
/>
<span
className={`h-2 w-2 rounded-full ${theme.dot} animate-bounce`}
style={{ animationDelay: "150ms" }}
/>
<span
className={`h-2 w-2 rounded-full ${theme.dot} animate-bounce`}
style={{ animationDelay: "300ms" }}
/>
</span>
</div>
{/* 图片占位符 - 横向排列,每个 170x200px */}
<div className="flex gap-3 flex-wrap">
{Array.from({ length: count }, (_, index) => index).map((index) => (
<div
key={index}
className="relative overflow-hidden rounded-xl"
style={{ width: "170px", height: "200px" }}
>
{/* 底层渐变背景 */}
<div className={`absolute inset-0 bg-gradient-to-br ${theme.gradientFrom} ${theme.gradientVia} ${theme.gradientTo} opacity-60`} />
{/* 动态模糊叠加层 1 */}
<div
className={`absolute inset-0 bg-gradient-to-tr ${theme.overlay1From} via-transparent ${theme.overlay1To} animate-pulse`}
style={{ animationDuration: "2s", animationDelay: `${index * 0.3}s` }}
/>
{/* 动态模糊叠加层 2 */}
<div
className={`absolute inset-0 bg-gradient-to-bl ${theme.overlay2From} via-transparent ${theme.overlay2To} animate-pulse`}
style={{ animationDuration: "2.5s", animationDelay: `${0.5 + index * 0.3}s` }}
/>
{/* 噪点纹理效果 */}
<div
className="absolute inset-0 opacity-30"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
}}
/>
{/* 模糊叠加 */}
<div className="absolute inset-0 backdrop-blur-sm" />
{/* 中心图标 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative">
{/* 外圈光晕 */}
<div className="absolute -inset-4 rounded-full bg-white/20 animate-ping" style={{ animationDuration: "1.5s", animationDelay: `${index * 0.2}s` }} />
{/* 图片图标 */}
<svg
className="h-10 w-10 text-white/80 animate-pulse"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
style={{ animationDuration: "1.5s", animationDelay: `${index * 0.2}s` }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
</div>
{/* 流光效果 */}
<div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full animate-shimmer"
style={{
animation: "shimmer 2s infinite",
animationDelay: `${index * 0.4}s`,
}}
/>
</div>
))}
</div>
{/* 提示文字 */}
<span className="text-xs text-muted-foreground">
{displayName} 正在创作 {count} 张图片,请稍候...
</span>
</div>
</div>
{/* 流光动画样式 */}
<style jsx>{`
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
`}</style>
</div>
);
});
ImageGenerationLoading.displayName = "ImageGenerationLoading";
export default ImageGenerationLoading;

View File

@ -8,14 +8,28 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Check, Image, Ratio, Maximize2, Images } from "lucide-react"; import { Check, Image, Ratio, Maximize2, Images, Sparkles, Ban } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
// 图片生成参数接口 // 图片生成参数接口
export interface ImageGenerationParams { export interface ImageGenerationParams {
aspectRatio: string; aspectRatio: string;
imageSize: string; imageSize: string;
imageCount?: number; // 🆕 生成图片数量 imageCount?: number; // 生成图片数量
// 🆕 阿里生图专用参数
size?: string; // 自定义分辨率,如 "1024*1024"
negativePrompt?: string; // 反向提示词
promptExtend?: boolean; // 提示词智能优化
watermark?: boolean; // 水印
seed?: number; // 随机种子
} }
interface ImageParamsBarProps { interface ImageParamsBarProps {
@ -23,7 +37,16 @@ interface ImageParamsBarProps {
initialParams?: Partial<ImageGenerationParams>; initialParams?: Partial<ImageGenerationParams>;
onParamsChange?: (params: ImageGenerationParams) => void; onParamsChange?: (params: ImageGenerationParams) => void;
inline?: boolean; // 内联模式:只显示参数按钮,不显示外层容器 inline?: boolean; // 内联模式:只显示参数按钮,不显示外层容器
providerType?: string; // 🆕 模型类型:gemini, volcano-vision 等 providerType?: string; // 模型类型:gemini, volcano-vision, alibaba-image 等
// 🆕 图片生成模型特性配置(从模型配置中读取)
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
};
} }
// 图片比例选项(根据 Nano Banana 文档) // 图片比例选项(根据 Nano Banana 文档)
@ -54,16 +77,28 @@ export function ImageParamsBar({
onParamsChange, onParamsChange,
inline = false, inline = false,
providerType = "gemini", // 默认 gemini providerType = "gemini", // 默认 gemini
imageGenConfig,
}: ImageParamsBarProps) { }: ImageParamsBarProps) {
const [params, setParams] = useState<ImageGenerationParams>({ const [params, setParams] = useState<ImageGenerationParams>({
aspectRatio: "auto", aspectRatio: "auto",
imageSize: "2K", // 默认 2K imageSize: "2K", // 默认 2K
imageCount: 1, // 🆕 默认 1 张 imageCount: 1, // 默认 1 张
promptExtend: true, // 阿里生图默认开启提示词优化
watermark: false,
...initialParams, ...initialParams,
}); });
// 🆕 根据模型类型确定可选的图片数量 // 根据模型类型和配置确定可选的图片数量
const getImageCountOptions = () => { const getImageCountOptions = () => {
// 优先使用模型配置中的 maxImages
if (imageGenConfig?.maxImages) {
return Array.from({ length: imageGenConfig.maxImages }, (_, i) => i + 1);
}
// 否则根据 providerType 判断
if (providerType === "alibaba-image") {
// 阿里生图:1~6 张(qwen-image-edit-plus)
return [1, 2, 3, 4, 5, 6];
}
if (providerType === "volcano-vision") { if (providerType === "volcano-vision") {
// 即梦4.5(豆包):1~3 张 // 即梦4.5(豆包):1~3 张
return [1, 2, 3]; return [1, 2, 3];
@ -73,6 +108,11 @@ export function ImageParamsBar({
}; };
const imageCountOptions = getImageCountOptions(); const imageCountOptions = getImageCountOptions();
// 是否显示阿里生图专用参数
const isAlibabaImage = providerType === "alibaba-image";
const showPromptExtend = isAlibabaImage || imageGenConfig?.supportsPromptExtend;
const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt;
// 🆕 监听 initialParams 变化,确保状态同步 // 🆕 监听 initialParams 变化,确保状态同步
useEffect(() => { useEffect(() => {
@ -153,7 +193,7 @@ export function ImageParamsBar({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* 🆕 图片数量 */} {/* 图片数量 */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 px-2 gap-1 focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0"> <Button variant="ghost" className="h-8 px-2 gap-1 focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0">
@ -174,6 +214,56 @@ export function ImageParamsBar({
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* 🆕 阿里生图专用:提示词优化 & 反向提示词 */}
{(showPromptExtend || showNegativePrompt) && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className={cn(
"h-8 px-2 gap-1 focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0",
(params.promptExtend || params.negativePrompt) && "text-primary"
)}
>
<Sparkles size={14} />
<span className="font-medium text-xs">高级</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72">
<div className="space-y-4">
<div className="font-medium text-sm">高级选项</div>
{/* 提示词智能优化 */}
{showPromptExtend && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm">提示词优化</Label>
<p className="text-xs text-muted-foreground">AI 自动优化您的描述</p>
</div>
<Switch
checked={params.promptExtend ?? true}
onCheckedChange={(checked) => updateParam("promptExtend", checked)}
/>
</div>
)}
{/* 反向提示词 */}
{showNegativePrompt && (
<div className="space-y-2">
<Label className="text-sm">反向提示词</Label>
<Input
placeholder="不希望出现的内容,如:低质量、模糊..."
value={params.negativePrompt || ""}
onChange={(e) => updateParam("negativePrompt", e.target.value)}
className="h-8 text-xs"
/>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}
</div> </div>
); );
} }

View File

@ -11,6 +11,7 @@ import { useDataStream } from "./data-stream-provider";
import { GeminiImageLoading } from "./gemini-image-loading"; import { GeminiImageLoading } from "./gemini-image-loading";
import { QianwenVideoLoading } from "./qianwen-video-loading"; import { QianwenVideoLoading } from "./qianwen-video-loading";
import { VolcanoVisionLoading } from "./volcano-vision-loading"; import { VolcanoVisionLoading } from "./volcano-vision-loading";
import { ImageGenerationLoading } from "./image-generation-loading";
import { Greeting } from "./greeting"; import { Greeting } from "./greeting";
import { PreviewMessage, ThinkingMessage } from "./message"; import { PreviewMessage, ThinkingMessage } from "./message";
@ -103,6 +104,11 @@ function PureMessages({
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "volcano-vision" (m: DynamicModel) => m.id === selectedModelId && m.providerType === "volcano-vision"
) ?? false; ) ?? false;
// 🆕 判断是否是阿里生图模型
const isAlibabaImageModel = modelsData?.models?.some(
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "alibaba-image"
) ?? false;
// 判断是否是视频生成模型(通过 providerType 或模型名称) // 判断是否是视频生成模型(通过 providerType 或模型名称)
const isVideoModel = modelsData?.models?.some( const isVideoModel = modelsData?.models?.some(
(m: DynamicModel) => (m: DynamicModel) =>
@ -292,11 +298,14 @@ function PureMessages({
const modelType = pendingGeneration?.modelType; const modelType = pendingGeneration?.modelType;
const showGemini = modelType === "gemini" || (!modelType && isGeminiModel); const showGemini = modelType === "gemini" || (!modelType && isGeminiModel);
const showVolcano = modelType === "volcano-vision" || (!modelType && isVolcanoVisionModel); const showVolcano = modelType === "volcano-vision" || (!modelType && isVolcanoVisionModel);
const showAlibabaImage = modelType === "alibaba-image" || (!modelType && isAlibabaImageModel);
const showVideo = modelType === "volcano-video" || modelType === "qianwen-video" || (!modelType && (isQianwenVideoModel || isVideoModel)); const showVideo = modelType === "volcano-video" || modelType === "qianwen-video" || (!modelType && (isQianwenVideoModel || isVideoModel));
if (showVideo) return <QianwenVideoLoading />; if (showVideo) return <QianwenVideoLoading />;
if (showGemini) return <GeminiImageLoading imageCount={imageCount} />; if (showGemini) return <GeminiImageLoading imageCount={imageCount} />;
if (showVolcano) return <VolcanoVisionLoading imageCount={imageCount} />; if (showVolcano) return <VolcanoVisionLoading imageCount={imageCount} />;
// 🆕 阿里生图模型使用通用组件,橙色主题,最多 6 张
if (showAlibabaImage) return <ImageGenerationLoading imageCount={imageCount} maxImages={6} providerType="alibaba-image" />;
return <ThinkingMessage />; return <ThinkingMessage />;
})()} })()}

34
components/ui/popover.tsx Normal file
View File

@ -0,0 +1,34 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,356 @@
qwen-image-edit-plus推荐
当前与qwen-image-edit-plus-2025-10-30能力相同
qwen-image-edit-plus系列模型,支持单图编辑和多图融合。
可输出 1-6 张图片。
支持自定义分辨率。
支持提示词智能优化。
格式:PNG
分辨率:
可指定:通过 parameters.size 参数指定输出图像的宽*高(单位:像素)。
默认(不指定时):总像素接近 1024*1024,宽高比与输入图(多图输入时为最后一张)一致。
qwen-image-edit-plus-2025-12-15 推荐
qwen-image-edit-plus-2025-10-30 推荐
qwen-image-edit
支持单图编辑和多图融合。
仅支持输出 1 张图片。
不支持自定义分辨率。
格式:PNG
分辨率:不可指定。生成规则同上方的默认规则。
HTTP调用
在调用前,您需要获取与配置 API Key,再配置API Key到环境变量。
如需通过SDK进行调用,请安装DashScope SDK。目前,该SDK已支持Python和Java。
重要
北京和新加坡地域拥有独立的 API Key 与请求地址,不可混用,跨地域调用将导致鉴权失败或服务报错。
北京地域:POST https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
请求参数
单图编辑
curl --location 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $DASHSCOPE_API_KEY" \
--data '{
"model": "qwen-image-edit-plus",
"input": {
"messages": [
{
"role": "user",
"content": [
{
"image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/fpakfo/image36.webp"
},
{
"text": "生成一张符合深度图的图像,遵循以下描述:一辆红色的破旧的自行车停在一条泥泞的小路上,背景是茂密的原始森林"
}
]
}
]
},
"parameters": {
"n": 2,
"negative_prompt": "低质量",
"prompt_extend": true,
"watermark": false
}
}'
多图融合
curl --location 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $DASHSCOPE_API_KEY" \
--data '{
"model": "qwen-image-edit-plus",
"input": {
"messages": [
{
"role": "user",
"content": [
{
"image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/thtclx/input1.png"
},
{
"image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/iclsnx/input2.png"
},
{
"image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/gborgw/input3.png"
},
{
"text": "图1中的女生穿着图2中的黑色裙子按图3的姿势坐下"
}
]
}
]
},
"parameters": {
"n": 2,
"negative_prompt": "低质量",
"prompt_extend": true,
"watermark": false
}
}'
请求头(Headers)
Content-Type string (必选)
请求内容类型。此参数必须设置为application/json。
Authorization string(必选)
请求身份认证。接口使用阿里云百炼API-Key进行身份认证。示例值:Bearer sk-xxxx。
请求体(Request Body)
model string (必选)
模型名称,可选以下模型:
qwen-image-edit-plus系列模型:包括 qwen-image-edit-plus、qwen-image-edit-plus-2025-12-15、qwen-image-edit-plus-2025-10-30,支持输出1-6张图片。
qwen-image-edit:仅支持输出1张图片。
input object (必选)
输入参数对象,包含以下字段:
属性
messages array (必选)
请求内容数组。当前仅支持单轮对话,因此数组内有且只有一个对象,该对象包含role和content两个属性。
属性
role string (必选)
消息发送者角色,必须设置为user。
content array (必选)
消息内容,包含1-3张图像,格式为 {"image": "..."};以及单个编辑指令,格式为 {"text": "..."}。
属性
image string (必选)
输入图像的 URL 或 Base64 编码数据。支持传入1-3张图像。
多图输入时,按照数组顺序定义图像顺序,输出图像的比例以最后一张为准。
图像要求:
图像格式:JPG、JPEG、PNG、BMP、TIFF、WEBP和GIF。
输出图像为PNG格式,对于GIF动图,仅处理其第一帧。
图像分辨率:为获得最佳效果,建议图像的宽和高均在384像素至3072像素之间。分辨率过低可能导致生成效果模糊,过高则会增加处理时长。
图像大小:不超过10MB。
支持的输入格式:
公网可访问的URL
支持 HTTP 或 HTTPS 协议。本地文件请参见上传文件获取临时 URL。
示例值:https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/fpakfo/image36.webp
传入 Base64 编码图像后的字符串
示例值:data:image/jpeg;base64,GDU7MtCZz...(示例已截断,仅做演示)
Base64 编码规范请参见通过Base64编码传入图片。
text string (必选)
图像编辑指令,即正向提示词,用来描述生成图像中期望包含的元素和视觉特点。
进行多图像编辑时,编辑指令中需要使用“图1”、“图2”、“图3”等描述来指代相应的图片,否则会出现不符合预期的编辑结果。
支持中英文,长度上限800个字符,每个汉字/字母占一个字符,超过部分会自动截断。
示例值:图1中的女生穿着图2中的黑色裙子按图3的姿势坐下,保持其服装、发型和表情不变,动作自然流畅。
parameters object (可选)
控制图像生成的附加参数。
属性
n integer (可选)
输出图像的数量,默认值为1。
对于qwen-image-edit-plus系列模型,可选择输出1-6张图片。
对于qwen-image-edit,仅支持输出1张图片。
negative_prompt string (可选)
反向提示词,用来描述不希望在画面中看到的内容,可以对画面进行限制。
支持中英文,长度上限500个字符,每个汉字/字母占一个字符,超过部分会自动截断。
示例值:低分辨率、错误、最差质量、低质量、残缺、多余的手指、比例不良等。
size string (可选)
设置输出图像的分辨率,格式为宽*高,例如"1024*2048"。宽和高的取值范围均为[512, 2048]像素。
默认行为:若不设置,输出图像将保持与输入图像(多图输入时为最后一张)相似的长宽比,接近1024*1024分辨率。
支持模型:仅qwen-image-edit-plus系列模型支持。
prompt_extend bool (可选)
是否开启prompt智能改写。开启后,将使用大模型优化正向提示词,对描述性不足、较为简单的prompt提升效果较明显。
true:默认值,开启智能改写。
false:不开启智能改写。
支持模型:仅qwen-image-edit-plus系列模型支持。
watermark bool (可选)
是否在图像右下角添加 "Qwen-Image" 水印。默认值为 false。水印样式如下:
1
seed integer (可选)
随机数种子,取值范围[0,2147483647]。
使用相同的seed参数值可使生成内容保持相对稳定。若不提供,算法将自动使用随机数种子。
注意:模型生成过程具有概率性,即使使用相同的seed,也不能保证每次生成结果完全一致。
响应参数
任务执行成功
{
"output": {
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"image": "https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/xxx.png?Expires=xxx"
},
{
"image": "https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/xxx.png?Expires=xxx"
}
]
}
}
]
},
"usage": {
"width": 1248,
"image_count": 2,
"height": 832
},
"request_id": "bf37ca26-0abe-98e4-8065-xxxxxx"
}
任务执行异常
{
"request_id": "31f808fd-8eef-9004-xxxxx",
"code": "InvalidApiKey",
"message": "Invalid API-key provided."
}
output object
包含模型生成结果。
属性
choices array
结果选项列表。
属性
finish_reason string
任务停止原因,自然停止时为stop。
message object
模型返回的消息。
属性
role string
消息的角色,固定为assistant。
content array
消息内容,包含生成的图像信息。
属性
image string
生成图像的 URL,格式为PNG。链接有效期为24小时,请及时下载并保存图像。
usage object
本次调用的资源使用情况,仅调用成功时返回。
属性
image_count integer
生成的图像数量,等于选择输出图片的数量。
width integer
生成图像的宽度(像素)。
height integer
生成图像的高度(像素)。
request_id string
请求唯一标识。可用于请求明细溯源和问题排查。
code string
请求失败的错误码。请求成功时不会返回此参数,详情请参见错误信息。
message string
请求失败的详细信息。请求成功时不会返回此参数,详情请参见错误信息。
常见问题
Q:qwen-image-edit 支持多轮对话式编辑吗?
A:不支持。模型仅支持单轮执行。每次调用均为独立、无状态的任务。如需连续编辑,须将生成的图片作为新输入再次调用。
Q:qwen-image-edit 和 qwen-image-edit-plus 系列模型支持哪些语言?
A:目前正式支持简体中文和英文;其他语言可自行尝试,但效果未经充分验证,可能存在不确定性。
Q:上传多张不同比例的参考图时,输出图像的比例以哪张为准?
A:输出图像会以最后一张上传的参考图的比例为准。
Q:如何查看模型调用量?
A:模型的调用信息存在小时级延迟,在模型调用完一小时后,请在模型观测(北京或新加坡)页面,查看调用量、调用次数、成功率等指标。详情请参见如何查看模型调用记录。

View File

@ -8,7 +8,7 @@ import { useEffect, useState, useCallback } from "react";
export interface PendingGeneration { export interface PendingGeneration {
chatId: string; chatId: string;
modelId: string; modelId: string;
modelType: "gemini" | "volcano-vision" | "volcano-video" | "qianwen-video" | "other"; modelType: "gemini" | "volcano-vision" | "volcano-video" | "qianwen-video" | "alibaba-image" | "other";
timestamp: number; timestamp: number;
userMessageId?: string; userMessageId?: string;
} }

View File

@ -16,6 +16,7 @@ import { createLocalQwen } from "./providers/local-qwen";
import { createGeminiImage } from "./providers/gemini-image"; import { createGeminiImage } from "./providers/gemini-image";
import { createQianwenVideo } from "./providers/qianwen-video"; import { createQianwenVideo } from "./providers/qianwen-video";
import { createVolcanoVision } from "./providers/volcano-vision"; import { createVolcanoVision } from "./providers/volcano-vision";
import { createAlibabaImage } from "./providers/alibaba-image";
// 缓存已创建的 provider 实例 // 缓存已创建的 provider 实例
const providerCache = new Map<string, ReturnType<typeof createOpenAI>>(); const providerCache = new Map<string, ReturnType<typeof createOpenAI>>();
@ -108,6 +109,17 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
return volcanoVisionProvider as any; return volcanoVisionProvider as any;
} }
case "alibaba-image": {
// 阿里云千问图片生成/编辑服务
// 支持 qwen-image-edit-plus(1-6张输出)和 qwen-image-edit(1张输出)
const alibabaImageProvider = createAlibabaImage({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
console.log(`[Provider] Created Alibaba Image generation provider: ${provider.baseUrl}`);
return alibabaImageProvider as any;
}
case "custom": case "custom":
default: { default: {
// OpenAI 兼容的第三方 API(如 GPT-5 Nano、Claude via OpenAI proxy 等) // OpenAI 兼容的第三方 API(如 GPT-5 Nano、Claude via OpenAI proxy 等)

View File

@ -0,0 +1,429 @@
/**
* 阿里云千问图片生成/编辑 Provider
*
* 支持模型:
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
* - qwen-image-edit:仅支持 1 张输出
*
* API 文档:https://help.aliyun.com/zh/model-studio/developer-reference/qwen-vl-plus
*/
import sharp from "sharp";
import { uploadFile } from "@/lib/storage/service";
interface AlibabaImageConfig {
apiKey: string;
baseURL: string;
}
// 阿里生图请求参数
export interface AlibabaImageParams {
n?: number; // 输出图片数量,1-6(qwen-image-edit-plus)或 1(qwen-image-edit)
size?: string; // 输出分辨率,如 "1024*1024",宽高 512-2048
negativePrompt?: string; // 反向提示词
promptExtend?: boolean; // 是否开启提示词智能优化
watermark?: boolean; // 是否添加水印
seed?: number; // 随机种子
}
// 阿里生图 API 响应
interface AlibabaImageResponse {
output?: {
choices?: Array<{
finish_reason: string;
message: {
role: string;
content: Array<{ image?: string }>;
};
}>;
};
usage?: {
width: number;
height: number;
image_count: number;
};
request_id?: string;
code?: string;
message?: string;
}
/**
* 创建缩略图
*/
async function createThumbnail(buffer: Buffer): Promise<Buffer> {
try {
const thumbnailBuffer = await sharp(buffer)
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
console.log(`[Alibaba Image] Thumbnail created, size: ${thumbnailBuffer.length} bytes`);
return thumbnailBuffer;
} catch (error) {
console.error("[Alibaba Image] Failed to create thumbnail:", error);
return buffer;
}
}
/**
* 生成唯一文件名
*/
function generateFilename(prefix: string, extension: string): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `${prefix}-${timestamp}-${random}.${extension}`;
}
/**
* 下载图片并上传到 S3
*/
async function downloadAndUploadToS3(
imageUrl: string,
prefix: string = "alibaba-image"
): Promise<{ thumbnail: string; original: string }> {
try {
console.log("[Alibaba Image] Downloading image from:", imageUrl.substring(0, 100) + "...");
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
console.log(`[Alibaba Image] Downloaded image, size: ${buffer.length} bytes`);
// 创建缩略图
const thumbnailBuffer = await createThumbnail(buffer);
// 上传原图(Buffer 转 ArrayBuffer)
const originalFilename = generateFilename(`${prefix}-orig`, "png");
const originalResult = await uploadFile(originalFilename, buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
console.log(`[Alibaba Image] Uploaded original to: ${originalResult.url}`);
// 上传缩略图
const thumbnailFilename = generateFilename(`${prefix}-thumb`, "jpg");
const thumbnailArrayBuffer = new Uint8Array(thumbnailBuffer).buffer;
const thumbnailResult = await uploadFile(thumbnailFilename, thumbnailArrayBuffer);
console.log(`[Alibaba Image] Uploaded thumbnail to: ${thumbnailResult.url}`);
return {
original: originalResult.url,
thumbnail: thumbnailResult.url,
};
} catch (error) {
console.error("[Alibaba Image] Failed to download/upload image:", error);
throw error;
}
}
/**
* 将 Base64 图片上传到 S3 获取 URL
*/
async function uploadBase64ToS3(base64Data: string): Promise<string> {
try {
// 移除 data URL 前缀
const base64Clean = base64Data.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Clean, "base64");
const filename = generateFilename("alibaba-ref", "png");
const result = await uploadFile(filename, buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
console.log(`[Alibaba Image] Uploaded reference image to: ${result.url}`);
return result.url;
} catch (error) {
console.error("[Alibaba Image] Failed to upload reference image:", error);
throw error;
}
}
/**
* 发送阿里生图 API 请求
*/
async function sendAlibabaImageRequest(
baseURL: string,
apiKey: string,
modelId: string,
prompt: string,
imageUrls: string[],
params: AlibabaImageParams
): Promise<AlibabaImageResponse> {
// 构建消息内容
const content: Array<{ image?: string; text?: string }> = [];
// 添加输入图片(最多 3 张)
for (const url of imageUrls.slice(0, 3)) {
content.push({ image: url });
}
// 添加编辑指令
content.push({ text: prompt });
const requestBody = {
model: modelId,
input: {
messages: [
{
role: "user",
content,
},
],
},
parameters: {
n: params.n || 1,
...(params.negativePrompt && { negative_prompt: params.negativePrompt }),
...(params.size && { size: params.size }),
prompt_extend: params.promptExtend ?? true,
watermark: params.watermark ?? false,
...(params.seed !== undefined && { seed: params.seed }),
},
};
console.log("[Alibaba Image] Sending request to:", baseURL);
console.log("[Alibaba Image] Model:", modelId);
console.log("[Alibaba Image] Parameters:", JSON.stringify(requestBody.parameters));
const response = await fetch(baseURL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
});
const data = await response.json();
if (!response.ok || data.code) {
console.error("[Alibaba Image] API Error:", data);
throw new Error(data.message || `API request failed: ${response.status}`);
}
console.log("[Alibaba Image] Response received, request_id:", data.request_id);
return data;
}
/**
* 处理 API 响应,提取并上传图片
*/
async function processResponse(
response: AlibabaImageResponse
): Promise<Array<{ thumbnail: string; original: string }>> {
const images: Array<{ thumbnail: string; original: string }> = [];
const choices = response.output?.choices;
if (!choices || choices.length === 0) {
throw new Error("No images in response");
}
for (const choice of choices) {
const content = choice.message?.content;
if (!content) continue;
for (const item of content) {
if (item.image) {
try {
const uploaded = await downloadAndUploadToS3(item.image);
images.push(uploaded);
} catch (error) {
console.error("[Alibaba Image] Failed to process image:", error);
}
}
}
}
if (images.length === 0) {
throw new Error("Failed to process any images");
}
console.log(`[Alibaba Image] Processed ${images.length} images`);
return images;
}
/**
* 格式化图片响应为 Markdown
* 使用与其他生图模型一致的格式:thumb|||originalUrl|||size
* 这样 saveGeneratedImages 函数可以正确解析并保存到数据库
*/
function formatImageResponse(
images: Array<{ thumbnail: string; original: string }>,
size?: string
): string {
if (images.length === 0) {
return "图片生成失败,请重试。";
}
// 将分辨率转换为简短格式(如 "1024*1024" -> "1K")
const sizeLabel = size ? getSizeLabel(size) : "2K";
return images
.map((img) => {
// 使用与 Gemini/火山引擎一致的格式:thumb|||originalUrl|||size
const altText = `thumb|||${img.original}|||${sizeLabel}`;
return `![${altText}](${img.thumbnail})`;
})
.join("\n\n");
}
/**
* 将分辨率字符串转换为简短标签
*/
function getSizeLabel(size: string): string {
const sizeMap: Record<string, string> = {
"1024*1024": "1K",
"1280*1280": "1K",
"1536*1536": "2K",
"2048*2048": "4K",
};
return sizeMap[size] || "2K";
}
/**
* 创建阿里云千问图片生成 Provider
*/
export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
return {
provider: "alibaba-image",
chat: (modelId: string) => ({
doGenerate: async (options: {
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>;
imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number };
}) => {
console.log("[Alibaba Image] doGenerate called");
console.log("[Alibaba Image] Model:", modelId);
console.log("[Alibaba Image] Image params:", options.imageParams);
try {
// 提取最后一条用户消息
const lastUserMessage = options.messages.filter((m) => m.role === "user").pop();
if (!lastUserMessage) {
throw new Error("No user message found");
}
// 解析消息内容
let prompt = "";
const imageUrls: string[] = [];
if (typeof lastUserMessage.content === "string") {
prompt = lastUserMessage.content;
} else if (Array.isArray(lastUserMessage.content)) {
for (const part of lastUserMessage.content) {
if (part.type === "text" && part.text) {
prompt = part.text;
}
// 支持多种图片格式
else if (part.type === "image" && part.image) {
// 格式1: { type: "image", image: string }
if (part.image.startsWith("data:")) {
const url = await uploadBase64ToS3(part.image);
imageUrls.push(url);
} else {
imageUrls.push(part.image);
}
} else if (part.type === "image_url" && (part as any).image_url?.url) {
// 格式2: { type: "image_url", image_url: { url: string } }
const imageUrl = (part as any).image_url.url;
if (imageUrl.startsWith("data:")) {
const url = await uploadBase64ToS3(imageUrl);
imageUrls.push(url);
} else {
imageUrls.push(imageUrl);
}
} else if (part.type === "file" && (part as any).url) {
// 格式3: { type: "file", url: string } - 来自 ChatMessage 的原始格式
const fileUrl = (part as any).url;
if (fileUrl.startsWith("data:")) {
const url = await uploadBase64ToS3(fileUrl);
imageUrls.push(url);
} else {
imageUrls.push(fileUrl);
}
}
}
}
if (!prompt) {
throw new Error("No prompt found in message");
}
// 构建参数
const params: AlibabaImageParams = {
n: options.imageParams?.imageCount || options.imageParams?.n || 1,
negativePrompt: options.imageParams?.negativePrompt,
promptExtend: options.imageParams?.promptExtend,
watermark: options.imageParams?.watermark,
seed: options.imageParams?.seed,
};
// 处理分辨率
if (options.imageParams?.size) {
params.size = options.imageParams.size;
} else if (options.imageParams?.imageSize) {
// 从 imageSize (如 "2K") 转换为具体分辨率
const sizeMap: Record<string, string> = {
"1K": "1024*1024",
"2K": "1536*1536",
"4K": "2048*2048",
};
params.size = sizeMap[options.imageParams.imageSize] || "1024*1024";
}
console.log("[Alibaba Image] Prompt:", prompt.substring(0, 100) + "...");
console.log("[Alibaba Image] Input images:", imageUrls.length);
console.log("[Alibaba Image] Final params:", params);
// 发送请求
const response = await sendAlibabaImageRequest(
baseURL,
apiKey,
modelId,
prompt,
imageUrls,
params
);
// 处理响应
const images = await processResponse(response);
const markdownText = formatImageResponse(images, params.size);
console.log(`[Alibaba Image] Generated ${images.length} images successfully`);
return {
text: markdownText,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: markdownText.length,
},
};
} catch (error) {
console.error("[Alibaba Image] Generation failed:", error);
throw error;
}
},
doStream: async function* (options: {
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>;
imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number };
}) {
console.log("[Alibaba Image] doStream called (using doGenerate internally)");
// 阿里生图 API 不支持流式,直接调用 doGenerate
const result = await this.doGenerate(options);
// 模拟流式输出
yield {
type: "text-delta" as const,
textDelta: result.text,
};
yield {
type: "finish" as const,
finishReason: "stop" as const,
usage: result.usage,
};
},
}),
};
}
export default createAlibabaImage;

View File

@ -941,7 +941,7 @@ export async function createAiProvider({
updatedBy, updatedBy,
}: { }: {
name: string; name: string;
type: "alibaba" | "openai" | "custom" | "local" | "gemini"; type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image";
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
isEnabled?: boolean; isEnabled?: boolean;
@ -979,7 +979,7 @@ export async function updateAiProvider({
}: { }: {
id: string; id: string;
name?: string; name?: string;
type?: "alibaba" | "openai" | "custom" | "local" | "gemini"; type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image";
baseUrl?: string; baseUrl?: string;
apiKey?: string; apiKey?: string;
isEnabled?: boolean; isEnabled?: boolean;
@ -1101,6 +1101,18 @@ export async function getDefaultAiModel(): Promise<AiModel | undefined> {
} }
} }
// 图片生成模型特性配置类型
interface ImageGenConfig {
maxImages?: number;
supportsSize?: boolean;
supportsPromptExtend?: boolean;
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
enableLoadingPlaceholder?: boolean;
loadingColor?: string;
}
export async function createAiModel({ export async function createAiModel({
providerId, providerId,
modelId, modelId,
@ -1113,6 +1125,7 @@ export async function createAiModel({
sortOrder, sortOrder,
fallbackModelId1, fallbackModelId1,
fallbackModelId2, fallbackModelId2,
imageGenConfig,
}: { }: {
providerId: string; providerId: string;
modelId: string; modelId: string;
@ -1125,6 +1138,7 @@ export async function createAiModel({
sortOrder?: number; sortOrder?: number;
fallbackModelId1?: string; fallbackModelId1?: string;
fallbackModelId2?: string; fallbackModelId2?: string;
imageGenConfig?: ImageGenConfig;
}): Promise<AiModel[]> { }): Promise<AiModel[]> {
try { try {
// 如果设置为默认,先取消其他默认 // 如果设置为默认,先取消其他默认
@ -1149,6 +1163,7 @@ export async function createAiModel({
sortOrder: sortOrder ?? 0, sortOrder: sortOrder ?? 0,
fallbackModelId1: fallbackModelId1 || null, fallbackModelId1: fallbackModelId1 || null,
fallbackModelId2: fallbackModelId2 || null, fallbackModelId2: fallbackModelId2 || null,
imageGenConfig: imageGenConfig || null,
}) })
.returning(); .returning();
} catch (_error) { } catch (_error) {
@ -1172,6 +1187,7 @@ export async function updateAiModel({
sortOrder, sortOrder,
fallbackModelId1, fallbackModelId1,
fallbackModelId2, fallbackModelId2,
imageGenConfig,
}: { }: {
id: string; id: string;
providerId?: string; providerId?: string;
@ -1185,6 +1201,7 @@ export async function updateAiModel({
sortOrder?: number; sortOrder?: number;
fallbackModelId1?: string; fallbackModelId1?: string;
fallbackModelId2?: string; fallbackModelId2?: string;
imageGenConfig?: ImageGenConfig;
}): Promise<AiModel[]> { }): Promise<AiModel[]> {
try { try {
// 如果设置为默认,先取消其他默认 // 如果设置为默认,先取消其他默认
@ -1196,7 +1213,7 @@ export async function updateAiModel({
} }
const updateData: any = { updatedAt: new Date() }; const updateData: any = { updatedAt: new Date() };
if (providerId !== undefined) updateData.providerId = providerId; // 允许更新提供商 if (providerId !== undefined) updateData.providerId = providerId;
if (modelId !== undefined) updateData.modelId = modelId; if (modelId !== undefined) updateData.modelId = modelId;
if (displayName !== undefined) updateData.displayName = displayName; if (displayName !== undefined) updateData.displayName = displayName;
if (description !== undefined) updateData.description = description; if (description !== undefined) updateData.description = description;
@ -1206,9 +1223,11 @@ export async function updateAiModel({
if (isEnabled !== undefined) updateData.isEnabled = isEnabled; if (isEnabled !== undefined) updateData.isEnabled = isEnabled;
if (isDefault !== undefined) updateData.isDefault = isDefault; if (isDefault !== undefined) updateData.isDefault = isDefault;
if (sortOrder !== undefined) updateData.sortOrder = sortOrder; if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
// 🆕 备用模型 ID(空字符串转为 null) // 备用模型 ID(空字符串转为 null)
if (fallbackModelId1 !== undefined) updateData.fallbackModelId1 = fallbackModelId1 || null; if (fallbackModelId1 !== undefined) updateData.fallbackModelId1 = fallbackModelId1 || null;
if (fallbackModelId2 !== undefined) updateData.fallbackModelId2 = fallbackModelId2 || null; if (fallbackModelId2 !== undefined) updateData.fallbackModelId2 = fallbackModelId2 || null;
// 🆕 图片生成配置
if (imageGenConfig !== undefined) updateData.imageGenConfig = imageGenConfig || null;
return await db return await db
.update(aiModel) .update(aiModel)

View File

@ -215,7 +215,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
export const aiProvider = pgTable("AiProvider", { export const aiProvider = pgTable("AiProvider", {
id: uuid("id").primaryKey().notNull().defaultRandom(), id: uuid("id").primaryKey().notNull().defaultRandom(),
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问" name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "qianwen-video", "volcano-vision", "volcano-video"] }) type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "qianwen-video", "volcano-vision", "volcano-video", "alibaba-image"] })
.notNull() .notNull()
.default("custom"), // 提供商类型 .default("custom"), // 提供商类型
baseUrl: text("baseUrl").notNull(), // API 基础 URL baseUrl: text("baseUrl").notNull(), // API 基础 URL
@ -250,6 +250,8 @@ export const aiModel = pgTable("AiModel", {
// 🆕 备用模型 ID(当主模型请求失败时自动切换) // 🆕 备用模型 ID(当主模型请求失败时自动切换)
fallbackModelId1: varchar("fallbackModelId1", { length: 128 }), // 备用模型 ID 1 fallbackModelId1: varchar("fallbackModelId1", { length: 128 }), // 备用模型 ID 1
fallbackModelId2: varchar("fallbackModelId2", { length: 128 }), // 备用模型 ID 2 fallbackModelId2: varchar("fallbackModelId2", { length: 128 }), // 备用模型 ID 2
// 🆕 图片生成模型特性配置(用于阿里生图、Gemini等)
imageGenConfig: jsonb("imageGenConfig"), // { maxImages: 6, supportsSize: true, supportsPromptExtend: true, supportsNegativePrompt: true, enableLoadingPlaceholder: true }
createdAt: timestamp("createdAt").notNull().defaultNow(), createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(), updatedAt: timestamp("updatedAt").notNull().defaultNow(),
}); });

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long