ai-chatbot/components/image-params-bar.tsx
Your Name d104efd7e9
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add openai compatible image provider
2026-03-07 10:51:56 +08:00

465 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Link2, Scan, SlidersHorizontal, Sparkles } 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";
// 图片生成参数接口
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; // 隐藏图片数量按钮(仅个别页面需要)
hideAdvancedOptions?: boolean; // 隐藏高级设置按钮
// 🆕 图片生成模型特性配置(从模型配置中读取)
imageGenConfig?: {
maxImages?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
supports4K?: 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 },
] as const;
const IMAGE_SIZE_PIXEL_MAP: Record<string, number> = {
"1K": 1024,
"2K": 2048,
"4K": 4096,
};
export function ImageParamsBar({
modelName = "Gemini 图片生成",
initialParams,
onParamsChange,
inline = false,
providerType = "gemini", // 默认 gemini
hideImageCount = false,
hideAdvancedOptions = false,
imageGenConfig,
}: ImageParamsBarProps) {
const getImageCountOptions = () => {
// RUXA 当前按单图任务处理(优先级最高)
if (providerType === "ruxa-image" || providerType === "openai-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;
// 根据模型配置校正分辨率:如果当前选择的档位被禁用,则降级到可用的档位
let imageSize = raw.imageSize || "2K";
if (imageGenConfig) {
const sizeAllowed = (s: string) => {
if (s === "1K") return imageGenConfig.supports1K !== false;
if (s === "2K") return imageGenConfig.supports2K !== false;
if (s === "4K") return imageGenConfig.supports4K !== false;
return true;
};
if (!sizeAllowed(imageSize)) {
imageSize = ["2K", "1K", "4K"].find(sizeAllowed) || "2K";
}
}
return {
...raw,
imageSize,
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 showImageCountSelector = !hideImageCount && imageCountOptions.length > 1;
// 是否显示阿里生图专用参数
const isAlibabaImage = providerType === "alibaba-image";
const showPromptExtend = isAlibabaImage || imageGenConfig?.supportsPromptExtend;
const showNegativePrompt = isAlibabaImage || imageGenConfig?.supportsNegativePrompt;
// 根据模型配置过滤可用的分辨率档位
const availableSizeOptions = IMAGE_SIZE_OPTIONS.filter((option) => {
if (option.disabled) return false;
if (!imageGenConfig) return true; // 无配置时全部显示
if (option.value === "1K" && imageGenConfig.supports1K === false) return false;
if (option.value === "2K" && imageGenConfig.supports2K === false) return false;
if (option.value === "4K" && imageGenConfig.supports4K === false) return false;
return true;
});
const getComputedOutputSize = (aspectRatio: string, imageSize: string) => {
const size = IMAGE_SIZE_PIXEL_MAP[imageSize] || IMAGE_SIZE_PIXEL_MAP["2K"];
if (aspectRatio === "auto" || aspectRatio === "1:1") {
return { width: size, height: size };
}
const [wRaw, hRaw] = aspectRatio.split(":").map((item) => Number(item));
if (!wRaw || !hRaw) {
return { width: size, height: size };
}
if (wRaw >= hRaw) {
return {
width: size,
height: Math.max(1, Math.round((size * hRaw) / wRaw)),
};
}
return {
width: Math.max(1, Math.round((size * wRaw) / hRaw)),
height: size,
};
};
const renderRatioGlyph = (ratioValue: string) => {
if (ratioValue === "auto") {
return <Scan className="h-4 w-4" />;
}
const [wRaw, hRaw] = ratioValue.split(":").map((item) => Number(item));
if (!wRaw || !hRaw) {
return <Scan className="h-4 w-4" />;
}
const maxSide = 14;
let width = maxSide;
let height = maxSide;
if (wRaw >= hRaw) {
width = maxSide;
height = Math.max(6, Math.round((maxSide * hRaw) / wRaw));
} else {
height = maxSide;
width = Math.max(6, Math.round((maxSide * wRaw) / hRaw));
}
return (
<span
className="inline-block rounded-[6px] border-2 border-current"
style={{ width, height }}
/>
);
};
// 🆕 监听 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, imageGenConfig?.supports1K, imageGenConfig?.supports2K, imageGenConfig?.supports4K]);
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) {
const ratioLabel = params.aspectRatio === "auto" ? "智能比例" : params.aspectRatio;
const sizeLabel = params.imageSize;
const outputSize = getComputedOutputSize(params.aspectRatio, params.imageSize);
return (
<div className="ml-1 flex items-center gap-1 border-l border-border/50 pl-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="h-8 max-w-[168px] shrink rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-2 text-[11px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90"
title={`比例:${params.aspectRatio},分辨率:${params.imageSize}`}
>
<span className="mr-1 flex h-3.5 w-3.5 items-center justify-center text-[#20262f]">
{renderRatioGlyph(params.aspectRatio)}
</span>
<span className="truncate">{ratioLabel}</span>
<span className="mx-1.5 h-3 w-px bg-[#cfd5de]" />
<span className="inline-flex min-w-0 items-center gap-0.5 truncate">
{sizeLabel}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
align="center"
side="top"
sideOffset={10}
className="w-[312px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/90 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
>
<div className="space-y-2.5">
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">选择比例</p>
<div className="mt-1.5 grid grid-cols-4 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5 md:grid-cols-5">
{ASPECT_RATIO_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"flex h-[46px] flex-col items-center justify-center rounded-[10px] border text-[#5f6b7a] transition",
params.aspectRatio === option.value
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent hover:bg-white/70"
)}
onClick={() => updateParam("aspectRatio", option.value)}
>
<span className="mb-0.5 flex h-4 items-center justify-center">
{renderRatioGlyph(option.value)}
</span>
<span className="text-[12px] font-semibold leading-none">
{option.value === "auto" ? "智能" : option.label}
</span>
</button>
))}
</div>
</div>
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">选择分辨率</p>
<div className="mt-1.5 grid grid-cols-2 gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1">
{availableSizeOptions.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"h-8 rounded-[9px] text-[12px] font-semibold transition",
params.imageSize === option.value
? "bg-white text-[#111827] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "bg-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => updateParam("imageSize", option.value)}
>
<span className="inline-flex items-center gap-1">
{option.label}
</span>
</button>
))}
</div>
</div>
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">尺寸</p>
<div className="mt-1.5 grid grid-cols-[1fr_auto_1fr_auto] items-center gap-1.5">
<div className="h-7 rounded-[9px] border border-[#dce1e8] bg-[#eef1f4]/90 px-2 text-[#b0b8c4]">
<div className="flex h-full items-center justify-between gap-1.5">
<span className="text-[10px] font-semibold">W</span>
<span className="text-[12px] font-semibold">{outputSize.width}</span>
</div>
</div>
<Link2 className="h-3 w-3 text-[#adb6c3]" />
<div className="h-7 rounded-[9px] border border-[#dce1e8] bg-[#eef1f4]/90 px-2 text-[#b0b8c4]">
<div className="flex h-full items-center justify-between gap-1.5">
<span className="text-[10px] font-semibold">H</span>
<span className="text-[12px] font-semibold">{outputSize.height}</span>
</div>
</div>
<span className="text-[11px] font-semibold text-[#8a94a3]">PX</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
{showImageCountSelector && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="h-8 min-w-[56px] rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 px-2.5 text-[12px] font-semibold text-[#2f3b48] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0"
title={`数量:${params.imageCount || 1}张`}
>
<Sparkles className="mr-1 h-3.5 w-3.5 text-[#6b7280]" />
{params.imageCount || 1}张
</Button>
</PopoverTrigger>
<PopoverContent
align="center"
side="top"
sideOffset={10}
className="w-auto min-w-[120px] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
>
<div>
<p className="text-[11px] font-medium text-[#8a94a3]">生成数量</p>
<div className={cn(
"mt-1.5 grid gap-1.5 rounded-[12px] bg-[#eceff2]/90 p-1.5",
imageCountOptions.length <= 3 ? "grid-cols-3" : "grid-cols-3"
)}>
{imageCountOptions.map((count) => (
<button
key={count}
type="button"
className={cn(
"flex h-8 min-w-[36px] items-center justify-center rounded-[9px] border text-[12px] font-semibold transition",
(params.imageCount || 1) === count
? "border-white bg-white text-[#1f2937] shadow-[0_1px_5px_rgba(15,23,42,0.08)]"
: "border-transparent text-[#5f6b7a] hover:bg-white/70"
)}
onClick={() => updateParam("imageCount", count)}
>
{count}张
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)}
{/* 🆕 阿里生图专用:提示词优化 & 反向提示词 */}
{!hideAdvancedOptions && (showPromptExtend || showNegativePrompt) && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
className={cn(
"h-8 w-8 shrink-0 rounded-[12px] border border-[#d6dbe2] bg-[#f3f4f6]/90 p-0 text-[#5f6b7a] hover:bg-[#edeff2]/90 focus-visible:ring-0 focus-visible:ring-offset-0",
(params.promptExtend || params.negativePrompt) && "text-primary"
)}
title="高级设置"
>
<SlidersHorizontal size={14} />
<span className="sr-only">高级设置</span>
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
side="top"
sideOffset={10}
className="w-[280px] max-w-[calc(100vw-24px)] rounded-[14px] border border-[#d8dde4] bg-[#f3f4f6]/95 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.14)]"
>
<div className="space-y-2.5">
{/* 提示词智能优化 */}
{showPromptExtend && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="text-[12px] font-semibold text-[#2f3b48]">提示词优化</span>
<p className="text-[11px] text-[#8a94a3]">AI 自动优化您的描述</p>
</div>
<Switch
checked={params.promptExtend ?? true}
onCheckedChange={(checked) => updateParam("promptExtend", checked)}
/>
</div>
)}
{/* 反向提示词 */}
{showNegativePrompt && (
<div className="space-y-1">
<span className="text-[12px] font-semibold text-[#2f3b48]">反向提示词</span>
<Input
placeholder="不希望出现的内容,如:低质量、模糊..."
value={params.negativePrompt || ""}
onChange={(e) => updateParam("negativePrompt", e.target.value)}
className="h-7 rounded-[8px] border-[#dce1e8] bg-[#eef1f4]/90 text-[12px] text-[#2f3b48] placeholder:text-[#b0b8c4]"
/>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}
// 非内联模式(暂时不实现,因为只需要内联模式)
return null;
}