feat: unify reference image data structure, add Nano Banana 2 model
Reference Image 统一重构: - 新增 ReferenceImage 类型 (src/types/index.ts),统一承载 base64 和 localPath 两种来源 - 重写 image-ref-store.ts:单一 Map<string, ReferenceImage[]> 替代原来的 imageGenRefStore + lastGeneratedImagePaths 双通路 - sessionStorage 持久化 last-generated 路径,解决页面刷新后数据丢失 - buildReferenceImages() 作为唯一的 merge 入口,消除各组件手写拼接逻辑 - ImageGenConfirmation props 从两个 (referenceImagePaths + referenceImagesData) 合并为单一 referenceImages?: ReferenceImage[] - 预览渲染改为单循环,根据 data/localPath 决定 img src,上传图和结果图同时显示 - API 调用时再拆分回 base64 和路径两个字段,后端无需改动 - StreamingMessage / MessageItem 各自 5 行手动 merge 逻辑替换为一行 buildReferenceImages() 调用 - ChatView 改用 setLastGeneratedImages() 和 transferPendingToMessage() 替代直接操作 Map - MessageInput 改用 setRefImages() / deleteRefImages() 替代直接操作 Map 新增 Gemini 3.1 Flash Image 模型: - 新增 Nano Banana 2 (gemini-3.1-flash-image-preview) 作为默认图片生成模型 - 模型列表: Nano Banana 2 > Nano Banana Pro > Nano Banana - 更新 ProviderManager、image-generator.ts、media/generate route 的默认值 其他已有改动一并提交: - batch image generation 功能文件 - 依赖更新 (package.json / package-lock.json) - i18n、db schema、AppShell、uploads route 等相关改动 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
00d3fef5a4
commit
d5dd9cfe34
37 files changed
+3508
-343
No files matched your search
@@ -247,8 +247,8 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
{
|
||||
key: "gemini-image",
|
||||
name: "Google Gemini (Image)",
|
||||
description: "Nano Banana Pro — AI image generation by Google Gemini",
|
||||
descriptionZh: "Nano Banana Pro — Google Gemini AI 图片生成",
|
||||
description: "Nano Banana 2 — AI image generation by Google Gemini",
|
||||
descriptionZh: "Nano Banana 2 — Google Gemini AI 图片生成",
|
||||
icon: <Google size={18} />,
|
||||
provider_type: "gemini-image",
|
||||
base_url: "https://generativelanguage.googleapis.com/v1beta",
|
||||
@@ -258,6 +258,23 @@ const QUICK_PRESETS: QuickPreset[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const GEMINI_IMAGE_MODELS = [
|
||||
{ value: 'gemini-3.1-flash-image-preview', label: 'Nano Banana 2' },
|
||||
{ value: 'gemini-3-pro-image-preview', label: 'Nano Banana Pro' },
|
||||
{ value: 'gemini-2.5-flash-image', label: 'Nano Banana' },
|
||||
];
|
||||
|
||||
const DEFAULT_GEMINI_IMAGE_MODEL = 'gemini-3.1-flash-image-preview';
|
||||
|
||||
function getGeminiImageModel(provider: ApiProvider): string {
|
||||
try {
|
||||
const env = JSON.parse(provider.extra_env || '{}');
|
||||
return env.GEMINI_IMAGE_MODEL || DEFAULT_GEMINI_IMAGE_MODEL;
|
||||
} catch {
|
||||
return DEFAULT_GEMINI_IMAGE_MODEL;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preset connect dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -553,6 +570,31 @@ export function ProviderManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageModelChange = useCallback(async (provider: ApiProvider, model: string) => {
|
||||
try {
|
||||
const env = JSON.parse(provider.extra_env || '{}');
|
||||
env.GEMINI_IMAGE_MODEL = model;
|
||||
const newExtraEnv = JSON.stringify(env);
|
||||
const res = await fetch(`/api/providers/${provider.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: provider.name,
|
||||
provider_type: provider.provider_type,
|
||||
base_url: provider.base_url,
|
||||
api_key: provider.api_key,
|
||||
extra_env: newExtraEnv,
|
||||
notes: provider.notes,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
setProviders(prev => prev.map(p => p.id === provider.id ? result.provider : p));
|
||||
window.dispatchEvent(new Event('provider-changed'));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const sorted = [...providers].sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
return (
|
||||
@@ -607,37 +649,62 @@ export function ProviderManager() {
|
||||
sorted.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center gap-3 py-2.5 px-1 border-b border-border/30 last:border-b-0"
|
||||
className="py-2.5 px-1 border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<div className="shrink-0 w-[22px] flex justify-center">
|
||||
{getProviderIcon(provider.name, provider.base_url)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{provider.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{provider.api_key ? "API Key" : t('provider.configured')}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="shrink-0 w-[22px] flex justify-center">
|
||||
{getProviderIcon(provider.name, provider.base_url)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{provider.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{provider.api_key ? "API Key" : t('provider.configured')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Edit"
|
||||
onClick={() => handleEdit(provider)}
|
||||
>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
>
|
||||
{t('provider.disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Edit"
|
||||
onClick={() => handleEdit(provider)}
|
||||
>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteTarget(provider)}
|
||||
>
|
||||
{t('provider.disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Gemini Image model selector — capsule buttons */}
|
||||
{provider.provider_type === 'gemini-image' && (
|
||||
<div className="ml-[34px] mt-2 flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground mr-1">{isZh ? '模型' : 'Model'}:</span>
|
||||
{GEMINI_IMAGE_MODELS.map((m) => {
|
||||
const isActive = getGeminiImageModel(provider) === m.value;
|
||||
return (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => handleImageModelChange(provider, m.value)}
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-medium border transition-all ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary border-primary/30'
|
||||
: 'text-muted-foreground border-border/60 hover:text-foreground hover:border-foreground/30 hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user