306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
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; // 生成图片数量
|
||
// 🆕 阿里生图专用参数
|
||
size?: string; // 自定义分辨率,如 "1024*1024"
|
||
negativePrompt?: string; // 反向提示词
|
||
promptExtend?: boolean; // 提示词智能优化
|
||
watermark?: boolean; // 水印
|
||
seed?: number; // 随机种子
|
||
}
|
||
|
||
interface ImageParamsBarProps {
|
||
modelName?: string;
|
||
initialParams?: Partial<ImageGenerationParams>;
|
||
onParamsChange?: (params: ImageGenerationParams) => void;
|
||
inline?: boolean; // 内联模式:只显示参数按钮,不显示外层容器
|
||
providerType?: string; // 模型类型:gemini, volcano-vision, alibaba-image, ruxa-image 等
|
||
hideImageCount?: boolean; // 隐藏图片数量按钮(仅个别页面需要)
|
||
// 🆕 图片生成模型特性配置(从模型配置中读取)
|
||
imageGenConfig?: {
|
||
maxImages?: number;
|
||
supportsSize?: boolean;
|
||
supportsPromptExtend?: boolean;
|
||
supportsNegativePrompt?: boolean;
|
||
supportsWatermark?: boolean;
|
||
supportsSeed?: boolean;
|
||
};
|
||
}
|
||
|
||
// 图片比例选项(根据 Nano Banana 文档)
|
||
const ASPECT_RATIO_OPTIONS = [
|
||
{ value: "auto", label: "自动" },
|
||
{ value: "1:1", label: "1:1" },
|
||
{ value: "16:9", label: "16:9" },
|
||
{ value: "9:16", label: "9:16" },
|
||
{ value: "4:3", label: "4:3" },
|
||
{ value: "3:4", label: "3:4" },
|
||
{ value: "3:2", label: "3:2" },
|
||
{ value: "2:3", label: "2:3" },
|
||
{ value: "5:4", label: "5:4" },
|
||
{ value: "4:5", label: "4:5" },
|
||
{ value: "21:9", label: "21:9" },
|
||
] as const;
|
||
|
||
// 图片大小选项(仅支持 nano-banana-pro)
|
||
const IMAGE_SIZE_OPTIONS = [
|
||
{ value: "1K", label: "1K", disabled: false },
|
||
{ value: "2K", label: "2K ⚡", disabled: false }, // 默认推荐
|
||
{ value: "4K", label: "4K 🐌", disabled: false }, // 4K较慢
|
||
] as const;
|
||
|
||
export function ImageParamsBar({
|
||
modelName = "Gemini 图片生成",
|
||
initialParams,
|
||
onParamsChange,
|
||
inline = false,
|
||
providerType = "gemini", // 默认 gemini
|
||
hideImageCount = false,
|
||
imageGenConfig,
|
||
}: ImageParamsBarProps) {
|
||
const getImageCountOptions = () => {
|
||
// RUXA 当前按单图任务处理(优先级最高)
|
||
if (providerType === "ruxa-image") {
|
||
return [1];
|
||
}
|
||
// 优先使用模型配置中的 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];
|
||
}
|
||
// Gemini:1~2 张
|
||
return [1, 2];
|
||
};
|
||
|
||
const clampParamsByProvider = (raw: ImageGenerationParams): ImageGenerationParams => {
|
||
const options = getImageCountOptions();
|
||
const fallbackCount = options[0] ?? 1;
|
||
const imageCount = options.includes(raw.imageCount || fallbackCount) ? (raw.imageCount || fallbackCount) : fallbackCount;
|
||
return {
|
||
...raw,
|
||
imageCount,
|
||
};
|
||
};
|
||
|
||
const isSameParams = (a: ImageGenerationParams, b: ImageGenerationParams) =>
|
||
a.aspectRatio === b.aspectRatio &&
|
||
a.imageSize === b.imageSize &&
|
||
(a.imageCount || 1) === (b.imageCount || 1) &&
|
||
a.size === b.size &&
|
||
a.negativePrompt === b.negativePrompt &&
|
||
a.promptExtend === b.promptExtend &&
|
||
a.watermark === b.watermark &&
|
||
a.seed === b.seed;
|
||
|
||
const buildBaseParams = (source?: Partial<ImageGenerationParams>) =>
|
||
clampParamsByProvider({
|
||
aspectRatio: "auto",
|
||
imageSize: "2K",
|
||
imageCount: 1,
|
||
promptExtend: true,
|
||
watermark: false,
|
||
...source,
|
||
});
|
||
|
||
const [params, setParams] = useState<ImageGenerationParams>({
|
||
...buildBaseParams(initialParams),
|
||
});
|
||
|
||
const imageCountOptions = getImageCountOptions();
|
||
|
||
// 是否显示阿里生图专用参数
|
||
const isAlibabaImage = providerType === "alibaba-image";
|
||
const showPromptExtend = isAlibabaImage || imageGenConfig?.supportsPromptExtend;
|
||
const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt;
|
||
|
||
// 🆕 监听 initialParams 变化,确保状态同步
|
||
useEffect(() => {
|
||
console.log("[ImageParamsBar] Mount/Update - initialParams:", initialParams);
|
||
const rawParams = {
|
||
aspectRatio: "auto",
|
||
imageSize: "2K",
|
||
imageCount: 1,
|
||
promptExtend: true,
|
||
watermark: false,
|
||
...(initialParams || {}),
|
||
} as ImageGenerationParams;
|
||
const nextParams = buildBaseParams(initialParams);
|
||
setParams((prevParams) => (isSameParams(prevParams, nextParams) ? prevParams : nextParams));
|
||
if (initialParams !== undefined && onParamsChange && !isSameParams(rawParams, nextParams)) {
|
||
onParamsChange(nextParams);
|
||
}
|
||
}, [initialParams, providerType, imageGenConfig?.maxImages]);
|
||
|
||
const updateParam = <K extends keyof ImageGenerationParams>(
|
||
key: K,
|
||
value: ImageGenerationParams[K]
|
||
) => {
|
||
const newParams = clampParamsByProvider({ ...params, [key]: value });
|
||
console.log("[ImageParamsBar] Updating param:", key, "=", value);
|
||
console.log("[ImageParamsBar] New params:", newParams);
|
||
setParams(newParams);
|
||
onParamsChange?.(newParams);
|
||
};
|
||
|
||
// 内联模式:只返回参数按钮,嵌入工具栏
|
||
if (inline) {
|
||
return (
|
||
<div className="flex items-center ml-1 border-l border-border/50 pl-1">
|
||
{/* 图片比例 */}
|
||
<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">
|
||
<Ratio size={14} />
|
||
<span className="font-medium text-xs">{params.aspectRatio}</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="center" className="w-48">
|
||
<div className="p-1.5 grid grid-cols-3 gap-1">
|
||
{ASPECT_RATIO_OPTIONS.map((option) => (
|
||
<Button
|
||
key={option.value}
|
||
variant={params.aspectRatio === option.value ? "secondary" : "ghost"}
|
||
size="sm"
|
||
className="h-7 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0"
|
||
onClick={() => updateParam("aspectRatio", option.value)}
|
||
>
|
||
{option.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</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">
|
||
<Maximize2 size={14} />
|
||
<span className="font-medium text-xs">{params.imageSize}</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="center" className="min-w-[100px]">
|
||
{IMAGE_SIZE_OPTIONS.map((option) => (
|
||
<DropdownMenuItem
|
||
key={option.value}
|
||
onClick={() => updateParam("imageSize", option.value)}
|
||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0"
|
||
>
|
||
<Check className={cn("mr-2 h-4 w-4", params.imageSize === option.value ? "opacity-100" : "opacity-0")} />
|
||
{option.label}
|
||
</DropdownMenuItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
|
||
{!hideImageCount && (
|
||
<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">
|
||
<Images size={14} />
|
||
<span className="font-medium text-xs">{params.imageCount || 1}张</span>
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="center" className="min-w-[80px]">
|
||
{imageCountOptions.map((count) => (
|
||
<DropdownMenuItem
|
||
key={count}
|
||
onClick={() => updateParam("imageCount", count)}
|
||
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0"
|
||
>
|
||
<Check className={cn("mr-2 h-4 w-4", (params.imageCount || 1) === count ? "opacity-100" : "opacity-0")} />
|
||
{count}张
|
||
</DropdownMenuItem>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
// 非内联模式(暂时不实现,因为只需要内联模式)
|
||
return null;
|
||
}
|