feat: 集成阿里千问生图模型 (alibaba-image)
- 新增 alibaba-image provider 支持 qwen-image-edit-plus 系列模型 - 支持 1-6 张图片输出、提示词优化、反向提示词等参数 - 扩展 ImageParamsBar 支持阿里模型专用参数 - 创建通用 ImageGenerationLoading 组件(可配置颜色主题) - 管理后台添加图片生成模型特性配置界面 - 数据库新增 imageGenConfig 字段存储生图特性 - 确保生成图片正确保存到相册和生图记录
This commit is contained in:
parent
9393382e6d
commit
eae8ea9436
@ -94,13 +94,25 @@ type ToolConfig = {
|
||||
type AiProvider = {
|
||||
id: 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;
|
||||
apiKey: string;
|
||||
isEnabled: boolean;
|
||||
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 = {
|
||||
id: string;
|
||||
providerId: string;
|
||||
@ -112,6 +124,7 @@ type AiModel = {
|
||||
isEnabled: boolean;
|
||||
isDefault: boolean;
|
||||
sortOrder: number;
|
||||
imageGenConfig?: ImageGenConfig; // 🆕 图片生成特性配置
|
||||
};
|
||||
|
||||
type StorageConfig = {
|
||||
@ -232,6 +245,7 @@ const AVAILABLE_TOOLS = [
|
||||
|
||||
const PROVIDER_TYPES = [
|
||||
{ value: "alibaba", label: "阿里云通义千问" },
|
||||
{ value: "alibaba-image", label: "阿里生图模型" }, // 🆕 千问图片生成/编辑
|
||||
{ value: "openai", label: "OpenAI 兼容" },
|
||||
{ value: "custom", label: "自定义 OpenAI 兼容" },
|
||||
{ value: "local", label: "本地模型" },
|
||||
@ -591,7 +605,7 @@ export function AdminSettingsClient() {
|
||||
const [providerForm, setProviderForm] = useState({
|
||||
id: "",
|
||||
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: "",
|
||||
apiKey: "",
|
||||
isEnabled: true,
|
||||
@ -611,8 +625,19 @@ export function AdminSettingsClient() {
|
||||
isEnabled: true,
|
||||
isDefault: false,
|
||||
sortOrder: 0,
|
||||
fallbackModelId1: "", // 🆕 备用模型 ID 1
|
||||
fallbackModelId2: "", // 🆕 备用模型 ID 2
|
||||
fallbackModelId1: "", // 备用模型 ID 1
|
||||
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 [editingModel, setEditingModel] = useState<string | null>(null);
|
||||
@ -1086,6 +1111,16 @@ export function AdminSettingsClient() {
|
||||
sortOrder: 0,
|
||||
fallbackModelId1: "",
|
||||
fallbackModelId2: "",
|
||||
imageGenConfig: {
|
||||
maxImages: 2,
|
||||
supportsSize: false,
|
||||
supportsPromptExtend: false,
|
||||
supportsNegativePrompt: false,
|
||||
supportsWatermark: false,
|
||||
supportsSeed: false,
|
||||
enableLoadingPlaceholder: true,
|
||||
loadingColor: "purple",
|
||||
},
|
||||
});
|
||||
setEditingModel(null);
|
||||
};
|
||||
@ -2917,6 +2952,116 @@ export function AdminSettingsClient() {
|
||||
<Label>设为默认</Label>
|
||||
</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>
|
||||
@ -2955,8 +3100,18 @@ export function AdminSettingsClient() {
|
||||
isEnabled: model.isEnabled,
|
||||
isDefault: model.isDefault,
|
||||
sortOrder: model.sortOrder || 0,
|
||||
fallbackModelId1: model.fallbackModelId1 || "",
|
||||
fallbackModelId2: model.fallbackModelId2 || "",
|
||||
fallbackModelId1: (model as any).fallbackModelId1 || "",
|
||||
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);
|
||||
setShowModelDialog(true);
|
||||
|
||||
@ -242,6 +242,9 @@ async function saveGeneratedImages(
|
||||
originalUrl = thumbnailUrl.replace("openai-thumb-", "openai-orig-");
|
||||
} else if (thumbnailUrl.includes("volcano-thumb-")) {
|
||||
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 处理结束 ==========
|
||||
|
||||
// ========== 阿里云千问图片生成独立处理 ==========
|
||||
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") {
|
||||
console.log("[Chat API] Using Volcano Vision image generation");
|
||||
|
||||
@ -14,7 +14,7 @@ const filePartSchema = z.object({
|
||||
|
||||
const partSchema = z.union([textPartSchema, filePartSchema]);
|
||||
|
||||
// 🆕 视频参数 schema
|
||||
// 视频参数 schema
|
||||
const videoParamsSchema = z.object({
|
||||
mode: z.enum(["text-to-video", "first-frame", "first-last-frame", "reference-images"]),
|
||||
resolution: z.enum(["480p", "720p", "1080p"]).optional(),
|
||||
@ -28,6 +28,20 @@ const videoParamsSchema = z.object({
|
||||
returnLastFrame: z.boolean().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({
|
||||
id: z.string().uuid(),
|
||||
message: z.object({
|
||||
@ -38,7 +52,8 @@ export const postRequestBodySchema = z.object({
|
||||
// 支持预定义模型名称或数据库中的模型 UUID
|
||||
selectedChatModel: z.string().min(1).max(128),
|
||||
selectedVisibilityType: z.enum(["public", "private"]),
|
||||
videoParams: videoParamsSchema, // 🆕 视频参数
|
||||
videoParams: videoParamsSchema, // 视频参数
|
||||
imageParams: imageParamsSchema, // 🆕 图片生成参数
|
||||
});
|
||||
|
||||
export type PostRequestBody = z.infer<typeof postRequestBodySchema>;
|
||||
|
||||
@ -11,6 +11,18 @@ import {
|
||||
} from "@/lib/db/queries";
|
||||
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({
|
||||
providerId: z.string().uuid(),
|
||||
modelId: z.string().min(1).max(128),
|
||||
@ -21,13 +33,14 @@ const createModelSchema = z.object({
|
||||
isEnabled: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
sortOrder: z.number().optional(),
|
||||
fallbackModelId1: z.string().max(128).optional(), // 🆕 备用模型 ID 1
|
||||
fallbackModelId2: z.string().max(128).optional(), // 🆕 备用模型 ID 2
|
||||
fallbackModelId1: z.string().max(128).optional(),
|
||||
fallbackModelId2: z.string().max(128).optional(),
|
||||
imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置
|
||||
});
|
||||
|
||||
const updateModelSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
providerId: z.string().uuid().optional(), // 允许修改提供商
|
||||
providerId: z.string().uuid().optional(),
|
||||
modelId: z.string().min(1).max(128).optional(),
|
||||
displayName: z.string().min(1).max(128).optional(),
|
||||
description: z.string().optional(),
|
||||
@ -36,8 +49,9 @@ const updateModelSchema = z.object({
|
||||
isEnabled: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
sortOrder: z.number().optional(),
|
||||
fallbackModelId1: z.string().max(128).optional(), // 🆕 备用模型 ID 1
|
||||
fallbackModelId2: z.string().max(128).optional(), // 🆕 备用模型 ID 2
|
||||
fallbackModelId1: z.string().max(128).optional(),
|
||||
fallbackModelId2: z.string().max(128).optional(),
|
||||
imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@ -11,7 +11,7 @@ import { ChatSDKError } from "@/lib/errors";
|
||||
|
||||
const createProviderSchema = z.object({
|
||||
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
|
||||
apiKey: z.string().min(1),
|
||||
isEnabled: z.boolean().optional(),
|
||||
@ -20,7 +20,7 @@ const createProviderSchema = z.object({
|
||||
const updateProviderSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
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
|
||||
apiKey: z.string().min(1).optional(),
|
||||
isEnabled: z.boolean().optional(),
|
||||
|
||||
259
components/image-generation-loading.tsx
Normal file
259
components/image-generation-loading.tsx
Normal 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;
|
||||
|
||||
@ -8,14 +8,28 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} 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 { 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 {
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
imageCount?: number; // 🆕 生成图片数量
|
||||
imageCount?: number; // 生成图片数量
|
||||
// 🆕 阿里生图专用参数
|
||||
size?: string; // 自定义分辨率,如 "1024*1024"
|
||||
negativePrompt?: string; // 反向提示词
|
||||
promptExtend?: boolean; // 提示词智能优化
|
||||
watermark?: boolean; // 水印
|
||||
seed?: number; // 随机种子
|
||||
}
|
||||
|
||||
interface ImageParamsBarProps {
|
||||
@ -23,7 +37,16 @@ interface ImageParamsBarProps {
|
||||
initialParams?: Partial<ImageGenerationParams>;
|
||||
onParamsChange?: (params: ImageGenerationParams) => void;
|
||||
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 文档)
|
||||
@ -54,16 +77,28 @@ export function ImageParamsBar({
|
||||
onParamsChange,
|
||||
inline = false,
|
||||
providerType = "gemini", // 默认 gemini
|
||||
imageGenConfig,
|
||||
}: ImageParamsBarProps) {
|
||||
const [params, setParams] = useState<ImageGenerationParams>({
|
||||
aspectRatio: "auto",
|
||||
imageSize: "2K", // 默认 2K
|
||||
imageCount: 1, // 🆕 默认 1 张
|
||||
imageCount: 1, // 默认 1 张
|
||||
promptExtend: true, // 阿里生图默认开启提示词优化
|
||||
watermark: false,
|
||||
...initialParams,
|
||||
});
|
||||
|
||||
// 🆕 根据模型类型确定可选的图片数量
|
||||
// 根据模型类型和配置确定可选的图片数量
|
||||
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") {
|
||||
// 即梦4.5(豆包):1~3 张
|
||||
return [1, 2, 3];
|
||||
@ -73,6 +108,11 @@ export function ImageParamsBar({
|
||||
};
|
||||
|
||||
const imageCountOptions = getImageCountOptions();
|
||||
|
||||
// 是否显示阿里生图专用参数
|
||||
const isAlibabaImage = providerType === "alibaba-image";
|
||||
const showPromptExtend = isAlibabaImage || imageGenConfig?.supportsPromptExtend;
|
||||
const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt;
|
||||
|
||||
// 🆕 监听 initialParams 变化,确保状态同步
|
||||
useEffect(() => {
|
||||
@ -153,7 +193,7 @@ export function ImageParamsBar({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 🆕 图片数量 */}
|
||||
{/* 图片数量 */}
|
||||
<DropdownMenu>
|
||||
<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">
|
||||
@ -174,6 +214,56 @@ export function ImageParamsBar({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import { useDataStream } from "./data-stream-provider";
|
||||
import { GeminiImageLoading } from "./gemini-image-loading";
|
||||
import { QianwenVideoLoading } from "./qianwen-video-loading";
|
||||
import { VolcanoVisionLoading } from "./volcano-vision-loading";
|
||||
import { ImageGenerationLoading } from "./image-generation-loading";
|
||||
import { Greeting } from "./greeting";
|
||||
import { PreviewMessage, ThinkingMessage } from "./message";
|
||||
|
||||
@ -103,6 +104,11 @@ function PureMessages({
|
||||
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "volcano-vision"
|
||||
) ?? false;
|
||||
|
||||
// 🆕 判断是否是阿里生图模型
|
||||
const isAlibabaImageModel = modelsData?.models?.some(
|
||||
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "alibaba-image"
|
||||
) ?? false;
|
||||
|
||||
// 判断是否是视频生成模型(通过 providerType 或模型名称)
|
||||
const isVideoModel = modelsData?.models?.some(
|
||||
(m: DynamicModel) =>
|
||||
@ -292,11 +298,14 @@ function PureMessages({
|
||||
const modelType = pendingGeneration?.modelType;
|
||||
const showGemini = modelType === "gemini" || (!modelType && isGeminiModel);
|
||||
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));
|
||||
|
||||
if (showVideo) return <QianwenVideoLoading />;
|
||||
if (showGemini) return <GeminiImageLoading imageCount={imageCount} />;
|
||||
if (showVolcano) return <VolcanoVisionLoading imageCount={imageCount} />;
|
||||
// 🆕 阿里生图模型使用通用组件,橙色主题,最多 6 张
|
||||
if (showAlibabaImage) return <ImageGenerationLoading imageCount={imageCount} maxImages={6} providerType="alibaba-image" />;
|
||||
return <ThinkingMessage />;
|
||||
})()}
|
||||
|
||||
|
||||
34
components/ui/popover.tsx
Normal file
34
components/ui/popover.tsx
Normal 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 }
|
||||
|
||||
356
docs/千问qwen-image-edit-plus.md
Normal file
356
docs/千问qwen-image-edit-plus.md
Normal 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:模型的调用信息存在小时级延迟,在模型调用完一小时后,请在模型观测(北京或新加坡)页面,查看调用量、调用次数、成功率等指标。详情请参见如何查看模型调用记录。
|
||||
@ -8,7 +8,7 @@ import { useEffect, useState, useCallback } from "react";
|
||||
export interface PendingGeneration {
|
||||
chatId: 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;
|
||||
userMessageId?: string;
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import { createLocalQwen } from "./providers/local-qwen";
|
||||
import { createGeminiImage } from "./providers/gemini-image";
|
||||
import { createQianwenVideo } from "./providers/qianwen-video";
|
||||
import { createVolcanoVision } from "./providers/volcano-vision";
|
||||
import { createAlibabaImage } from "./providers/alibaba-image";
|
||||
|
||||
// 缓存已创建的 provider 实例
|
||||
const providerCache = new Map<string, ReturnType<typeof createOpenAI>>();
|
||||
@ -108,6 +109,17 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
||||
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":
|
||||
default: {
|
||||
// OpenAI 兼容的第三方 API(如 GPT-5 Nano、Claude via OpenAI proxy 等)
|
||||
|
||||
429
lib/ai/providers/alibaba-image.ts
Normal file
429
lib/ai/providers/alibaba-image.ts
Normal 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 ``;
|
||||
})
|
||||
.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;
|
||||
|
||||
@ -941,7 +941,7 @@ export async function createAiProvider({
|
||||
updatedBy,
|
||||
}: {
|
||||
name: string;
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini";
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
isEnabled?: boolean;
|
||||
@ -979,7 +979,7 @@ export async function updateAiProvider({
|
||||
}: {
|
||||
id: 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;
|
||||
apiKey?: string;
|
||||
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({
|
||||
providerId,
|
||||
modelId,
|
||||
@ -1113,6 +1125,7 @@ export async function createAiModel({
|
||||
sortOrder,
|
||||
fallbackModelId1,
|
||||
fallbackModelId2,
|
||||
imageGenConfig,
|
||||
}: {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
@ -1125,6 +1138,7 @@ export async function createAiModel({
|
||||
sortOrder?: number;
|
||||
fallbackModelId1?: string;
|
||||
fallbackModelId2?: string;
|
||||
imageGenConfig?: ImageGenConfig;
|
||||
}): Promise<AiModel[]> {
|
||||
try {
|
||||
// 如果设置为默认,先取消其他默认
|
||||
@ -1149,6 +1163,7 @@ export async function createAiModel({
|
||||
sortOrder: sortOrder ?? 0,
|
||||
fallbackModelId1: fallbackModelId1 || null,
|
||||
fallbackModelId2: fallbackModelId2 || null,
|
||||
imageGenConfig: imageGenConfig || null,
|
||||
})
|
||||
.returning();
|
||||
} catch (_error) {
|
||||
@ -1172,6 +1187,7 @@ export async function updateAiModel({
|
||||
sortOrder,
|
||||
fallbackModelId1,
|
||||
fallbackModelId2,
|
||||
imageGenConfig,
|
||||
}: {
|
||||
id: string;
|
||||
providerId?: string;
|
||||
@ -1185,6 +1201,7 @@ export async function updateAiModel({
|
||||
sortOrder?: number;
|
||||
fallbackModelId1?: string;
|
||||
fallbackModelId2?: string;
|
||||
imageGenConfig?: ImageGenConfig;
|
||||
}): Promise<AiModel[]> {
|
||||
try {
|
||||
// 如果设置为默认,先取消其他默认
|
||||
@ -1196,7 +1213,7 @@ export async function updateAiModel({
|
||||
}
|
||||
|
||||
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 (displayName !== undefined) updateData.displayName = displayName;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
@ -1206,9 +1223,11 @@ export async function updateAiModel({
|
||||
if (isEnabled !== undefined) updateData.isEnabled = isEnabled;
|
||||
if (isDefault !== undefined) updateData.isDefault = isDefault;
|
||||
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
||||
// 🆕 备用模型 ID(空字符串转为 null)
|
||||
// 备用模型 ID(空字符串转为 null)
|
||||
if (fallbackModelId1 !== undefined) updateData.fallbackModelId1 = fallbackModelId1 || null;
|
||||
if (fallbackModelId2 !== undefined) updateData.fallbackModelId2 = fallbackModelId2 || null;
|
||||
// 🆕 图片生成配置
|
||||
if (imageGenConfig !== undefined) updateData.imageGenConfig = imageGenConfig || null;
|
||||
|
||||
return await db
|
||||
.update(aiModel)
|
||||
|
||||
@ -215,7 +215,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
|
||||
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", "qianwen-video", "volcano-vision", "volcano-video"] })
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "qianwen-video", "volcano-vision", "volcano-video", "alibaba-image"] })
|
||||
.notNull()
|
||||
.default("custom"), // 提供商类型
|
||||
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
||||
@ -250,6 +250,8 @@ export const aiModel = pgTable("AiModel", {
|
||||
// 🆕 备用模型 ID(当主模型请求失败时自动切换)
|
||||
fallbackModelId1: varchar("fallbackModelId1", { length: 128 }), // 备用模型 ID 1
|
||||
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(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user