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:
7418andClaude Opus 4.6 committed 2026-02-27 00:55:24 +08:00
1 parent 00d3fef5a4
commit d5dd9cfe34
37 files changed
+3508 -343

No files matched your search

+40 -98
View File
@@ -6,13 +6,8 @@ import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { usePanel } from '@/hooks/usePanel';
import { consumeSSEStream } from '@/hooks/useSSEStream';
/**
* Module-level store for reference image base64 data.
* Keyed by message ID — ImageGenConfirmation reads from here to pass
* reference images to /api/media/generate without storing base64 in DB.
*/
export const imageGenRefStore = new Map<string, Array<{ mimeType: string; data: string }>>();
import { BatchExecutionDashboard, BatchContextSync } from './batch-image-gen';
import { setLastGeneratedImages, transferPendingToMessage } from '@/lib/image-ref-store';
interface ToolUseInfo {
id: string;
@@ -115,6 +110,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const toolResultsRef = useRef<ToolResultInfo[]>([]);
// Ref for sendMessage to allow self-referencing in timeout auto-retry without circular deps
const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise<void>>(undefined);
// Pending image generation notices — flushed into the next user message so the LLM knows about generated images
const pendingImageNoticesRef = useRef<string[]>([]);
// Re-sync streaming content when the window regains visibility (Electron/browser tab switch)
useEffect(() => {
@@ -275,13 +272,22 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
}, 10_000);
const markActive = () => { lastEventTime = Date.now(); };
// Flush any pending image generation notices into the prompt so
// the LLM (especially in SDK resume mode) knows about previously generated images.
let effectiveContent = content;
if (pendingImageNoticesRef.current.length > 0) {
const notices = pendingImageNoticesRef.current.join('\n\n');
pendingImageNoticesRef.current = [];
effectiveContent = `${notices}\n\n---\n\n${content}`;
}
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
content,
content: effectiveContent,
mode,
model: currentModel,
provider_id: currentProviderId,
@@ -412,6 +418,11 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
created_at: new Date().toISOString(),
token_usage: result.tokenUsage ? JSON.stringify(result.tokenUsage) : null,
};
// Transfer pending reference images to this message ID so MessageItem can
// retrieve them. StreamingMessage uses __pending__ directly, but once the
// message transitions to MessageItem, it's keyed by message.id.
transferPendingToMessage(assistantMessage.id);
setMessages((prev) => [...prev, assistantMessage]);
}
} catch (error) {
@@ -598,7 +609,9 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
}
}, [sessionId, sendMessage]);
// Listen for image generation completion — persist notice to DB only (no model call)
// Listen for image generation completion — persist notice to DB and queue for next user message.
// The notice is NOT sent as a separate LLM turn (avoids permission popups).
// Instead it's flushed into the next user message via pendingImageNoticesRef.
// MessageItem hides messages matching this prefix so the user doesn't see them.
useEffect(() => {
const handler = (e: Event) => {
@@ -607,103 +620,29 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
const paths = (detail.images || [])
.map((img: { localPath?: string }) => img.localPath)
.filter(Boolean);
const pathInfo = paths.length > 0 ? `, file path: ${paths.join(', ')}` : '';
const notice = `[__IMAGE_GEN_NOTICE__ prompt: "${detail.prompt}", aspect ratio: ${detail.aspectRatio}, resolution: ${detail.resolution}${pathInfo}. You can use your Read tool to view the generated image if you need to analyze or discuss it.]`;
// Persist to DB only — does NOT trigger model
const pathInfo = paths.length > 0 ? `\nGenerated image file paths:\n${paths.map((p: string) => `- ${p}`).join('\n')}` : '';
const notice = `[Image generation completed]\n- Prompt: "${detail.prompt}"\n- Aspect ratio: ${detail.aspectRatio}\n- Resolution: ${detail.resolution}${pathInfo}`;
// Store generated image paths so subsequent edits can use them as reference
if (paths.length > 0) {
setLastGeneratedImages(paths);
}
// Queue for next user message so the LLM gets the context
pendingImageNoticesRef.current.push(notice);
// Also persist to DB for history reload
const dbNotice = `[__IMAGE_GEN_NOTICE__ prompt: "${detail.prompt}", aspect ratio: ${detail.aspectRatio}, resolution: ${detail.resolution}${paths.length > 0 ? `, file path: ${paths.join(', ')}` : ''}]`;
fetch('/api/chat/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, role: 'user', content: notice }),
body: JSON.stringify({ session_id: sessionId, role: 'user', content: dbNotice }),
}).catch(() => {});
};
window.addEventListener('image-gen-completed', handler);
return () => window.removeEventListener('image-gen-completed', handler);
}, [sessionId]);
// Direct image generation — bypasses /api/chat, shows ImageGenConfirmation for parameter selection
const handleImageGenerate = useCallback(async (prompt: string, files?: FileAttachment[]) => {
if (!prompt.trim() && (!files || files.length === 0)) return;
const displayPrompt = prompt || 'Generate an image';
// Build user display content — only store file metadata (no base64) for DB
let userDbContent = displayPrompt;
if (files && files.length > 0) {
const metaOnly = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size }));
userDbContent = `<!--files:${JSON.stringify(metaOnly)}-->${displayPrompt}`;
}
// Build user display content for UI — include base64 for immediate rendering
let userUiContent = displayPrompt;
if (files && files.length > 0) {
const metaWithData = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size, data: f.data }));
userUiContent = `<!--files:${JSON.stringify(metaWithData)}-->${displayPrompt}`;
}
// Add user message to UI
setMessages(prev => [...prev, {
id: 'temp-' + Date.now(),
session_id: sessionId,
role: 'user',
content: userUiContent,
created_at: new Date().toISOString(),
token_usage: null,
}]);
// Persist user message (lightweight, no base64)
fetch('/api/chat/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, role: 'user', content: userDbContent }),
}).catch(() => {});
// Auto-generate title from first message if needed
if (messages.length === 0) {
const title = displayPrompt.slice(0, 50) + (displayPrompt.length > 50 ? '...' : '');
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}).then(() => {
window.dispatchEvent(new CustomEvent('session-updated'));
}).catch(() => {});
}
// Store reference images in module-level map for ImageGenConfirmation to pick up
const msgId = 'imggen-' + Date.now();
if (files && files.length > 0) {
const imageFiles = files.filter(f => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
imageGenRefStore.set(msgId, imageFiles.map(f => ({ mimeType: f.type, data: f.data })));
}
}
// Build image-gen-request — renders ImageGenConfirmation with interactive UI
const requestPayload: Record<string, unknown> = {
prompt: displayPrompt,
aspectRatio: '1:1',
resolution: '1K',
};
const requestContent = '```image-gen-request\n' + JSON.stringify(requestPayload) + '\n```';
// Add assistant message with ImageGenConfirmation UI
setMessages(prev => [...prev, {
id: msgId,
session_id: sessionId,
role: 'assistant',
content: requestContent,
created_at: new Date().toISOString(),
token_usage: null,
}]);
// Persist assistant message so it survives page reload
fetch('/api/chat/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, role: 'assistant', content: requestContent }),
}).catch(() => {});
}, [sessionId, messages.length]);
return (
<div className="flex h-full min-h-0 flex-col">
<MessageList
@@ -722,9 +661,12 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
loadingMore={loadingMore}
onLoadMore={loadEarlierMessages}
/>
{/* Batch image generation panels — shown above the input area */}
<BatchExecutionDashboard />
<BatchContextSync />
<MessageInput
onSend={sendMessage}
onImageGenerate={handleImageGenerate}
onCommand={handleCommand}
onStop={stopStreaming}
disabled={false}
+22 -34
View File
@@ -7,6 +7,7 @@ import { ImageGenCard } from './ImageGenCard';
import { useTranslation } from '@/hooks/useTranslation';
import { usePanel } from '@/hooks/usePanel';
import type { TranslationKey } from '@/i18n';
import type { ReferenceImage } from '@/types';
import type { ImageGenResult } from '@/hooks/useImageGen';
const ASPECT_RATIOS = [
@@ -19,8 +20,7 @@ interface ImageGenConfirmationProps {
initialPrompt: string;
initialAspectRatio: string;
initialResolution: string;
referenceImagePaths?: string[];
referenceImagesData?: Array<{ mimeType: string; data: string }>;
referenceImages?: ReferenceImage[];
}
type Status = 'idle' | 'generating' | 'completed' | 'error';
@@ -35,8 +35,7 @@ export function ImageGenConfirmation({
initialPrompt,
initialAspectRatio,
initialResolution,
referenceImagePaths,
referenceImagesData,
referenceImages,
}: ImageGenConfirmationProps) {
const { t } = useTranslation();
const { sessionId } = usePanel();
@@ -87,6 +86,10 @@ export function ImageGenConfirmation({
setError(null);
try {
// Split unified ReferenceImage[] back into base64 data vs file paths for the API
const refData = referenceImages?.filter(r => r.data).map(r => ({ mimeType: r.mimeType, data: r.data! }));
const refPaths = referenceImages?.filter(r => r.localPath).map(r => r.localPath!);
const res = await fetch('/api/media/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -95,11 +98,12 @@ export function ImageGenConfirmation({
aspectRatio,
imageSize: resolution,
sessionId,
...(referenceImagesData && referenceImagesData.length > 0
? { referenceImages: referenceImagesData }
: referenceImagePaths && referenceImagePaths.length > 0
? { referenceImagePaths }
: {}),
...(refData && refData.length > 0
? { referenceImages: refData }
: {}),
...(refPaths && refPaths.length > 0
? { referenceImagePaths: refPaths }
: {}),
}),
signal: controller.signal,
});
@@ -163,7 +167,7 @@ export function ImageGenConfirmation({
} finally {
abortRef.current = null;
}
}, [prompt, aspectRatio, resolution, initialPrompt, sessionId, referenceImagePaths, referenceImagesData]);
}, [prompt, aspectRatio, resolution, initialPrompt, sessionId, referenceImages]);
const handleRegenerate = useCallback(() => {
setResult(null);
@@ -185,7 +189,7 @@ export function ImageGenConfirmation({
aspectRatio={aspectRatio}
imageSize={resolution}
onRegenerate={handleRegenerate}
referenceImages={referenceImagesData}
referenceImages={referenceImages?.filter(r => r.data).map(r => ({ mimeType: r.mimeType, data: r.data! }))}
/>
</div>
);
@@ -200,18 +204,20 @@ export function ImageGenConfirmation({
</div>
<div className="p-4 space-y-3">
{/* Reference images preview */}
{referenceImagesData && referenceImagesData.length > 0 ? (
{/* Reference images preview — unified loop over all reference images */}
{referenceImages && referenceImages.length > 0 && (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
{t('imageGen.referenceImages' as TranslationKey)}
</label>
<div className="flex gap-2 flex-wrap">
{referenceImagesData.map((img, i) => (
{referenceImages.map((img, i) => (
<div key={i} className="w-16 h-16 rounded-md border border-border/30 overflow-hidden bg-muted/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:${img.mimeType};base64,${img.data}`}
src={img.data
? `data:${img.mimeType};base64,${img.data}`
: `/api/uploads?path=${encodeURIComponent(img.localPath!)}`}
alt={`Reference ${i + 1}`}
className="w-full h-full object-cover"
/>
@@ -219,25 +225,7 @@ export function ImageGenConfirmation({
))}
</div>
</div>
) : referenceImagePaths && referenceImagePaths.length > 0 ? (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
{t('imageGen.referenceImages' as TranslationKey)}
</label>
<div className="flex gap-2 flex-wrap">
{referenceImagePaths.map((p, i) => (
<div key={i} className="w-16 h-16 rounded-md border border-border/30 overflow-hidden bg-muted/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/uploads?path=${encodeURIComponent(p)}`}
alt={`Reference ${i + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</div>
) : null}
)}
{/* Prompt textarea */}
<div>
+5 -1
View File
@@ -14,12 +14,16 @@ export function ImageGenToggle() {
const { state, setEnabled } = useImageGen();
const { t } = useTranslation();
const handleToggle = () => {
setEnabled(!state.enabled);
};
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setEnabled(!state.enabled)}
onClick={handleToggle}
className={cn(
'inline-flex items-center justify-center rounded-full px-2.5 h-7 text-xs font-medium border transition-all',
state.enabled
+59 -6
View File
@@ -37,6 +37,49 @@ import type { FileAttachment, ProviderModelGroup } from '@/types';
import { nanoid } from 'nanoid';
import { ImageGenToggle } from './ImageGenToggle';
import { useImageGen } from '@/hooks/useImageGen';
import { PENDING_KEY, setRefImages, deleteRefImages } from '@/lib/image-ref-store';
const IMAGE_AGENT_SYSTEM_PROMPT = `你是一个图像生成助手。当用户请求生成图片时,分析用户意图并以结构化格式输出。
## 单张生成
如果用户只需要生成一张图片,输出:
\`\`\`image-gen-request
{"prompt":"详细的英文描述","aspectRatio":"1:1","resolution":"1K"}
\`\`\`
## 批量生成
如果用户提供了文档/列表/多个需求,需要批量生成多张图片,输出:
\`\`\`batch-plan
{"summary":"计划摘要","items":[{"prompt":"英文描述","aspectRatio":"1:1","resolution":"1K","tags":[]}]}
\`\`\`
## 参考图(垫图)
如果用户上传了图片,这些图片会自动作为参考图传给图片生成模型。你在 prompt 中应该描述如何利用这些参考图,例如:
- 基于参考图的风格/内容进行创作
- 将参考图中的元素融入新图
- 按照参考图的构图生成新图
## 连续编辑(基于上一次生成结果)
如果用户要求修改/编辑/调整之前生成的图片,在 JSON 中加入 "useLastGenerated": true,系统会自动将上次生成的结果图作为参考图传入。
编辑模式下 prompt 要简洁直接,只描述要做的修改,不要重复描述整张图片的内容。例如:
- 用户说"去掉右边的香水" → prompt: "Remove the perfume bottle on the right side of the image"
- 用户说"把背景换成蓝色" → prompt: "Change the background color to blue"
- 用户说"加个太阳" → prompt: "Add a sun in the sky"
\`\`\`image-gen-request
{"prompt":"简洁的英文编辑指令","aspectRatio":"1:1","resolution":"1K","useLastGenerated":true}
\`\`\`
## 规则
- 新图生成时 prompt 必须是详细的英文描述
- 编辑已有图片时 prompt 应该简洁直接,只描述修改内容
- aspectRatio 可选: 1:1, 16:9, 9:16, 3:2, 2:3, 4:3, 3:4
- resolution 可选: 1K, 2K, 4K
- 批量生成时每个 item 都需要独立的详细 prompt
- 如果用户没有特别要求比例和分辨率,使用 1:1 和 1K 作为默认值
- 如果用户上传了参考图,prompt 中要明确说明如何使用这些参考图
- 如果用户要求修改上一张生成的图片,必须加 "useLastGenerated": true
- 在输出结构化块之前,可以先简要说明你的理解和计划`;
// Accepted file types for upload
const ACCEPTED_FILE_TYPES = [
@@ -601,13 +644,23 @@ export function MessageInput({
return attachments;
};
// If imageGen toggle is on and no badge, call onImageGenerate directly (no model dependency)
if (imageGen.state.enabled && !badge && !isStreaming && onImageGenerate) {
// If Image Agent toggle is on and no badge, send via normal LLM with systemPromptAppend
if (imageGen.state.enabled && !badge && !isStreaming) {
const files = await convertFiles();
const hasFiles = files.length > 0;
if (!content && !hasFiles) return;
if (!content && files.length === 0) return;
// Store uploaded images as pending reference images for ImageGenConfirmation
const imageFiles = files.filter(f => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
setRefImages(PENDING_KEY, imageFiles.map(f => ({ mimeType: f.type, data: f.data })));
} else {
deleteRefImages(PENDING_KEY);
}
setInputValue('');
onImageGenerate(content, hasFiles ? files : undefined);
if (onSend) {
onSend(content, files.length > 0 ? files : undefined, IMAGE_AGENT_SYSTEM_PROMPT);
}
return;
}
@@ -1143,7 +1196,7 @@ export function MessageInput({
)}
</div>
{/* Image generation toggle */}
{/* Image Agent toggle */}
<ImageGenToggle />
</PromptInputTools>
+51 -5
View File
@@ -13,14 +13,17 @@ import { Copy01Icon, Tick01Icon, ArrowDown01Icon, ArrowUp01Icon } from "@hugeico
import { FileAttachmentDisplay } from './FileAttachmentDisplay';
import { ImageGenConfirmation } from './ImageGenConfirmation';
import { ImageGenCard } from './ImageGenCard';
import { imageGenRefStore } from './ChatView';
import { BatchPlanInlinePreview } from './batch-image-gen/BatchPlanInlinePreview';
import { buildReferenceImages } from '@/lib/image-ref-store';
import { parseDBDate } from '@/lib/utils';
import type { PlannerOutput } from '@/types';
interface ImageGenRequest {
prompt: string;
aspectRatio: string;
resolution: string;
referenceImages?: string[];
useLastGenerated?: boolean;
}
function parseImageGenRequest(text: string): { beforeText: string; request: ImageGenRequest; afterText: string } | null {
@@ -49,6 +52,7 @@ function parseImageGenRequest(text: string): { beforeText: string; request: Imag
aspectRatio: String(json.aspectRatio || '1:1'),
resolution: String(json.resolution || '1K'),
referenceImages: Array.isArray(json.referenceImages) ? json.referenceImages : undefined,
useLastGenerated: json.useLastGenerated === true,
},
afterText,
};
@@ -93,6 +97,33 @@ function parseImageGenResult(text: string): { beforeText: string; result: ImageG
}
}
function parseBatchPlan(text: string): { beforeText: string; plan: PlannerOutput; afterText: string } | null {
const regex = /```batch-plan\s*\n?([\s\S]*?)\n?\s*```/;
const match = text.match(regex);
if (!match) return null;
try {
const json = JSON.parse(match[1]);
const beforeText = text.slice(0, match.index).trim();
const afterText = text.slice((match.index || 0) + match[0].length).trim();
return {
beforeText,
plan: {
summary: json.summary || '',
items: Array.isArray(json.items) ? json.items.map((item: Record<string, unknown>) => ({
prompt: String(item.prompt || ''),
aspectRatio: String(item.aspectRatio || '1:1'),
resolution: String(item.resolution || '1K'),
tags: Array.isArray(item.tags) ? item.tags : [],
sourceRefs: Array.isArray(item.sourceRefs) ? item.sourceRefs : [],
})) : [],
},
afterText,
};
} catch {
return null;
}
}
interface MessageItemProps {
message: Message;
}
@@ -388,6 +419,18 @@ export function MessageItem({ message }: MessageItemProps) {
)}
</div>
) : (() => {
// Try batch-plan first (Image Agent batch mode)
const batchPlanResult = parseBatchPlan(displayText);
if (batchPlanResult) {
return (
<>
{batchPlanResult.beforeText && <MessageResponse>{batchPlanResult.beforeText}</MessageResponse>}
<BatchPlanInlinePreview plan={batchPlanResult.plan} messageId={message.id} />
{batchPlanResult.afterText && <MessageResponse>{batchPlanResult.afterText}</MessageResponse>}
</>
);
}
// Try image-gen-result first (new direct-call format)
const genResult = parseImageGenResult(displayText);
if (genResult) {
@@ -439,8 +482,11 @@ export function MessageItem({ message }: MessageItemProps) {
// Legacy: image-gen-request (model-dependent format, for old messages)
const parsed = parseImageGenRequest(displayText);
if (parsed) {
// Check module-level store for reference image base64 data
const refData = imageGenRefStore.get(message.id);
const refs = buildReferenceImages(
message.id,
parsed.request.useLastGenerated || false,
parsed.request.referenceImages,
);
return (
<>
{parsed.beforeText && <MessageResponse>{parsed.beforeText}</MessageResponse>}
@@ -448,8 +494,7 @@ export function MessageItem({ message }: MessageItemProps) {
initialPrompt={parsed.request.prompt}
initialAspectRatio={parsed.request.aspectRatio}
initialResolution={parsed.request.resolution}
referenceImagePaths={parsed.request.referenceImages}
referenceImagesData={refData}
referenceImages={refs.length > 0 ? refs : undefined}
/>
{parsed.afterText && <MessageResponse>{parsed.afterText}</MessageResponse>}
</>
@@ -458,6 +503,7 @@ export function MessageItem({ message }: MessageItemProps) {
const stripped = displayText
.replace(/```image-gen-request[\s\S]*?```/g, '')
.replace(/```image-gen-result[\s\S]*?```/g, '')
.replace(/```batch-plan[\s\S]*?```/g, '')
.trim();
return stripped ? <MessageResponse>{stripped}</MessageResponse> : null;
})()
+63 -9
View File
@@ -19,14 +19,17 @@ import {
} from '@/components/ai-elements/confirmation';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { ImageGenConfirmation } from './ImageGenConfirmation';
import { BatchPlanInlinePreview } from './batch-image-gen/BatchPlanInlinePreview';
import { PENDING_KEY, buildReferenceImages } from '@/lib/image-ref-store';
import type { ToolUIPart } from 'ai';
import type { PermissionRequestEvent } from '@/types';
import type { PermissionRequestEvent, PlannerOutput } from '@/types';
interface ImageGenRequest {
prompt: string;
aspectRatio: string;
resolution: string;
referenceImages?: string[];
useLastGenerated?: boolean;
}
function parseImageGenRequest(text: string): { beforeText: string; request: ImageGenRequest; afterText: string } | null {
@@ -55,6 +58,34 @@ function parseImageGenRequest(text: string): { beforeText: string; request: Imag
aspectRatio: String(json.aspectRatio || '1:1'),
resolution: String(json.resolution || '1K'),
referenceImages: Array.isArray(json.referenceImages) ? json.referenceImages : undefined,
useLastGenerated: json.useLastGenerated === true,
},
afterText,
};
} catch {
return null;
}
}
function parseBatchPlan(text: string): { beforeText: string; plan: PlannerOutput; afterText: string } | null {
const regex = /```batch-plan\s*\n?([\s\S]*?)\n?\s*```/;
const match = text.match(regex);
if (!match) return null;
try {
const json = JSON.parse(match[1]);
const beforeText = text.slice(0, match.index).trim();
const afterText = text.slice((match.index || 0) + match[0].length).trim();
return {
beforeText,
plan: {
summary: json.summary || '',
items: Array.isArray(json.items) ? json.items.map((item: Record<string, unknown>) => ({
prompt: String(item.prompt || ''),
aspectRatio: String(item.aspectRatio || '1:1'),
resolution: String(item.resolution || '1K'),
tags: Array.isArray(item.tags) ? item.tags : [],
sourceRefs: Array.isArray(item.sourceRefs) ? item.sourceRefs : [],
})) : [],
},
afterText,
};
@@ -492,10 +523,26 @@ export function StreamingMessage({
{/* Streaming text content rendered via Streamdown */}
{content && (() => {
console.log('[StreamingMessage] content:', JSON.stringify(content.slice(0, 500)));
// Try batch-plan first (Image Agent batch mode)
const batchPlanResult = parseBatchPlan(content);
if (batchPlanResult) {
return (
<>
{batchPlanResult.beforeText && <MessageResponse>{batchPlanResult.beforeText}</MessageResponse>}
<BatchPlanInlinePreview plan={batchPlanResult.plan} messageId={`streaming-${Date.now()}`} />
{batchPlanResult.afterText && <MessageResponse>{batchPlanResult.afterText}</MessageResponse>}
</>
);
}
// Try image-gen-request
const parsed = parseImageGenRequest(content);
console.log('[StreamingMessage] parseImageGenRequest result:', parsed ? 'OK' : 'null', 'isStreaming:', isStreaming);
if (parsed) {
const refs = buildReferenceImages(
PENDING_KEY,
parsed.request.useLastGenerated || false,
parsed.request.referenceImages,
);
return (
<>
{parsed.beforeText && <MessageResponse>{parsed.beforeText}</MessageResponse>}
@@ -503,22 +550,29 @@ export function StreamingMessage({
initialPrompt={parsed.request.prompt}
initialAspectRatio={parsed.request.aspectRatio}
initialResolution={parsed.request.resolution}
referenceImagePaths={parsed.request.referenceImages}
referenceImages={refs.length > 0 ? refs : undefined}
/>
{parsed.afterText && <MessageResponse>{parsed.afterText}</MessageResponse>}
</>
);
}
// Strip partial or unparseable image-gen-request blocks to avoid Shiki errors
// Strip partial or unparseable code fence blocks to avoid Shiki errors
if (isStreaming) {
const hasImageGenBlock = /```image-gen-request/.test(content);
const stripped = content.replace(/```image-gen-request[\s\S]*$/, '').trim();
const hasBatchPlanBlock = /```batch-plan/.test(content);
const stripped = content
.replace(/```image-gen-request[\s\S]*$/, '')
.replace(/```batch-plan[\s\S]*$/, '')
.trim();
if (stripped) return <MessageResponse>{stripped}</MessageResponse>;
// Show shimmer while the image-gen block is being streamed
if (hasImageGenBlock) return <Shimmer>{t('streaming.thinking')}</Shimmer>;
// Show shimmer while the structured block is being streamed
if (hasImageGenBlock || hasBatchPlanBlock) return <Shimmer>{t('streaming.thinking')}</Shimmer>;
return null;
}
const stripped = content.replace(/```image-gen-request[\s\S]*?```/g, '').trim();
const stripped = content
.replace(/```image-gen-request[\s\S]*?```/g, '')
.replace(/```batch-plan[\s\S]*?```/g, '')
.trim();
return stripped ? <MessageResponse>{stripped}</MessageResponse> : null;
})()}
@@ -0,0 +1,79 @@
'use client';
import { useState } from 'react';
import { useBatchImageGen } from '@/hooks/useBatchImageGen';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
export function BatchContextSync() {
const { state, syncToLlm, resetJob } = useBatchImageGen();
const { t } = useTranslation();
const [synced, setSynced] = useState(false);
if (state.phase !== 'completed' && state.phase !== 'syncing') return null;
const handleSync = async (syncMode: 'manual' | 'auto_batch' = 'manual') => {
await syncToLlm(syncMode);
setSynced(true);
};
const isSyncing = state.phase === 'syncing';
const { progress } = state;
return (
<div className="rounded-xl border border-purple-500/20 bg-card overflow-hidden">
<div className="px-4 py-3 bg-purple-500/5">
<h3 className="text-sm font-medium text-foreground">
{t('batchImageGen.completed' as TranslationKey)}
</h3>
<p className="text-xs text-muted-foreground mt-1">
{t('batchImageGen.completedStats' as TranslationKey)
.replace('{completed}', String(progress.completed))
.replace('{total}', String(progress.total))
}
</p>
</div>
<div className="px-4 py-3 flex items-center gap-2">
{!synced ? (
<>
<button
type="button"
onClick={() => handleSync('manual')}
disabled={isSyncing}
className="text-xs px-3 py-1.5 rounded-md bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{isSyncing ? '...' : t('batchImageGen.syncToChat' as TranslationKey)}
</button>
<div className="flex-1" />
<button
type="button"
onClick={resetJob}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t('common.close' as TranslationKey)}
</button>
</>
) : (
<>
<span className="text-xs text-green-500">
{t('batchImageGen.syncComplete' as TranslationKey)}
</span>
<div className="flex-1" />
<button
type="button"
onClick={resetJob}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t('common.close' as TranslationKey)}
</button>
</>
)}
{state.error && (
<p className="text-xs text-red-500">{state.error}</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,117 @@
'use client';
import { useBatchImageGen } from '@/hooks/useBatchImageGen';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import { BatchExecutionItem } from './BatchExecutionItem';
export function BatchExecutionDashboard() {
const { state, pauseJob, resumeJob, cancelJob, retryFailed } = useBatchImageGen();
const { t } = useTranslation();
if (state.phase !== 'executing' && state.phase !== 'completed') return null;
const { progress, items, currentJob } = state;
const isPaused = currentJob?.status === 'paused';
const isRunning = currentJob?.status === 'running';
const isCompleted = state.phase === 'completed';
const progressPercent = progress.total > 0
? Math.round(((progress.completed + progress.failed) / progress.total) * 100)
: 0;
const hasFailedItems = items.some(i => i.status === 'failed');
return (
<div className="rounded-xl border border-purple-500/20 bg-card overflow-hidden">
{/* Header with progress */}
<div className="px-4 py-3 border-b border-border/40 bg-purple-500/5">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-foreground">
{t('batchImageGen.totalProgress' as TranslationKey)}
</h3>
<span className="text-xs text-muted-foreground">
{progress.completed}/{progress.total}
{progress.failed > 0 && (
<span className="text-red-500 ml-1">({progress.failed} failed)</span>
)}
</span>
</div>
{/* Progress bar */}
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progressPercent}%`,
background: progress.failed > 0
? 'linear-gradient(90deg, rgb(168, 85, 247) 0%, rgb(239, 68, 68) 100%)'
: 'rgb(168, 85, 247)',
}}
/>
</div>
</div>
{/* Item list */}
<div className="p-3 space-y-1.5 max-h-[400px] overflow-y-auto">
{items.map(item => (
<BatchExecutionItem key={item.id} item={item} />
))}
</div>
{/* Controls */}
<div className="px-4 py-3 border-t border-border/40 flex items-center gap-2">
{isRunning && (
<>
<button
type="button"
onClick={pauseJob}
className="text-xs px-3 py-1.5 rounded-md border border-border/40 hover:bg-accent/50 text-foreground transition-colors"
>
{t('batchImageGen.pause' as TranslationKey)}
</button>
<button
type="button"
onClick={cancelJob}
className="text-xs px-3 py-1.5 rounded-md border border-red-500/30 text-red-500 hover:bg-red-500/10 transition-colors"
>
{t('batchImageGen.cancel' as TranslationKey)}
</button>
</>
)}
{isPaused && (
<>
<button
type="button"
onClick={resumeJob}
className="text-xs px-3 py-1.5 rounded-md bg-purple-600 text-white hover:bg-purple-700 transition-colors"
>
{t('batchImageGen.resume' as TranslationKey)}
</button>
<button
type="button"
onClick={cancelJob}
className="text-xs px-3 py-1.5 rounded-md border border-red-500/30 text-red-500 hover:bg-red-500/10 transition-colors"
>
{t('batchImageGen.cancel' as TranslationKey)}
</button>
</>
)}
{isCompleted && hasFailedItems && (
<button
type="button"
onClick={retryFailed}
className="text-xs px-3 py-1.5 rounded-md border border-border/40 hover:bg-accent/50 text-foreground transition-colors"
>
{t('batchImageGen.retryFailed' as TranslationKey)}
</button>
)}
{state.error && (
<p className="text-xs text-red-500 ml-2">{state.error}</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,79 @@
'use client';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import type { MediaJobItem } from '@/types';
interface BatchExecutionItemProps {
item: MediaJobItem;
}
export function BatchExecutionItem({ item }: BatchExecutionItemProps) {
const { t } = useTranslation();
const statusColor = {
pending: 'text-muted-foreground',
processing: 'text-blue-500',
completed: 'text-green-500',
failed: 'text-red-500',
cancelled: 'text-muted-foreground',
}[item.status];
const statusLabel = {
pending: t('batchImageGen.itemPending' as TranslationKey),
processing: t('batchImageGen.itemProcessing' as TranslationKey),
completed: t('batchImageGen.itemCompleted' as TranslationKey),
failed: t('batchImageGen.itemFailed' as TranslationKey),
cancelled: t('batchImageGen.cancel' as TranslationKey),
}[item.status];
return (
<div className="flex items-center gap-3 rounded-lg border border-border/40 bg-background px-3 py-2">
{/* Index */}
<span className="text-xs text-muted-foreground font-mono w-6 text-right shrink-0">
#{item.idx + 1}
</span>
{/* Status indicator */}
<div className="shrink-0">
{item.status === 'processing' ? (
<div className="w-4 h-4 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
) : item.status === 'completed' ? (
<div className="w-4 h-4 rounded-full bg-green-500/20 flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-green-500" viewBox="0 0 12 12" fill="none">
<path d="M2 6L5 9L10 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
) : item.status === 'failed' ? (
<div className="w-4 h-4 rounded-full bg-red-500/20 flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-red-500" viewBox="0 0 12 12" fill="none">
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
) : (
<div className="w-4 h-4 rounded-full bg-muted" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-xs text-foreground truncate">{item.prompt}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-muted-foreground">{item.aspect_ratio}</span>
<span className="text-[10px] text-muted-foreground">{item.image_size}</span>
<span className={`text-[10px] font-medium ${statusColor}`}>{statusLabel}</span>
{item.error && (
<span className="text-[10px] text-red-400 truncate">{item.error}</span>
)}
</div>
</div>
{/* Retry count */}
{item.retry_count > 0 && (
<span className="text-[10px] text-muted-foreground shrink-0">
retry {item.retry_count}
</span>
)}
</div>
);
}
@@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { useBatchImageGen } from '@/hooks/useBatchImageGen';
import { usePanel } from '@/hooks/usePanel';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import type { PlannerOutput, PlannerItem } from '@/types';
import { BatchPlanRow } from './BatchPlanRow';
import { BatchExecutionDashboard } from './BatchExecutionDashboard';
import { BatchContextSync } from './BatchContextSync';
interface BatchPlanInlinePreviewProps {
plan: PlannerOutput;
messageId: string;
}
export function BatchPlanInlinePreview({ plan: initialPlan, messageId }: BatchPlanInlinePreviewProps) {
const batchImageGen = useBatchImageGen();
const { sessionId } = usePanel();
const { t } = useTranslation();
// Local editable state — initialized from the parsed plan
const [localPlan, setLocalPlan] = useState<PlannerOutput>(initialPlan);
const [executed, setExecuted] = useState(false);
// Once execution starts, delegate to the global batch state
const isExecuting = executed && (
batchImageGen.state.phase === 'executing' ||
batchImageGen.state.phase === 'completed' ||
batchImageGen.state.phase === 'syncing'
);
const handleUpdateItem = (index: number, updates: Partial<PlannerItem>) => {
setLocalPlan(prev => {
const newItems = [...prev.items];
newItems[index] = { ...newItems[index], ...updates };
return { ...prev, items: newItems };
});
};
const handleAddItem = () => {
setLocalPlan(prev => ({
...prev,
items: [...prev.items, { prompt: '', aspectRatio: '1:1', resolution: '1K', tags: [], sourceRefs: [] }],
}));
};
const handleRemoveItem = (index: number) => {
setLocalPlan(prev => ({
...prev,
items: prev.items.filter((_, i) => i !== index),
}));
};
const handleExecute = async () => {
if (localPlan.items.length === 0) return;
setExecuted(true);
// Inject the (potentially edited) plan into batch state and start execution
await batchImageGen.injectPlanAndExecute(localPlan, sessionId || undefined);
// Now actually execute the job
await batchImageGen.executeJob(sessionId || undefined);
};
// Show execution dashboard once execution has started
if (isExecuting) {
return (
<div className="space-y-2">
<BatchExecutionDashboard />
<BatchContextSync />
</div>
);
}
return (
<div className="rounded-xl border border-purple-500/20 bg-card overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-border/40 bg-purple-500/5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">
{t('batchImageGen.planPreviewTitle' as TranslationKey)}
</h3>
<span className="text-xs text-muted-foreground">
{localPlan.items.length} {localPlan.items.length === 1 ? 'item' : 'items'}
</span>
</div>
{localPlan.summary && (
<p className="text-xs text-muted-foreground mt-1">{localPlan.summary}</p>
)}
</div>
{/* Items */}
<div className="p-3 space-y-2 max-h-[400px] overflow-y-auto">
{localPlan.items.map((item, i) => (
<BatchPlanRow
key={i}
item={item}
index={i}
onUpdate={handleUpdateItem}
onRemove={handleRemoveItem}
disabled={false}
/>
))}
</div>
{/* Footer Actions */}
<div className="px-4 py-3 border-t border-border/40 flex items-center gap-2">
<button
type="button"
onClick={handleAddItem}
className="text-xs text-purple-600 dark:text-purple-400 hover:underline"
>
+ {t('batchImageGen.addItem' as TranslationKey)}
</button>
<div className="flex-1" />
<button
type="button"
onClick={handleExecute}
disabled={localPlan.items.length === 0}
className="inline-flex items-center justify-center rounded-lg px-4 py-1.5 text-xs font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{t('batchImageGen.confirmAndExecute' as TranslationKey)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,91 @@
'use client';
import { useTranslation } from '@/hooks/useTranslation';
import type { TranslationKey } from '@/i18n';
import type { PlannerItem } from '@/types';
const ASPECT_RATIOS = ['1:1', '16:9', '9:16', '3:2', '2:3', '4:3', '3:4', '4:5', '5:4', '21:9'];
const RESOLUTIONS = ['1K', '2K', '4K'];
interface BatchPlanRowProps {
item: PlannerItem;
index: number;
onUpdate: (index: number, updates: Partial<PlannerItem>) => void;
onRemove: (index: number) => void;
disabled?: boolean;
}
export function BatchPlanRow({ item, index, onUpdate, onRemove, disabled }: BatchPlanRowProps) {
const { t } = useTranslation();
return (
<div className="group rounded-lg border border-border/60 bg-background p-3 space-y-2 hover:border-border transition-colors">
<div className="flex items-start gap-2">
<span className="mt-1 text-xs text-muted-foreground font-mono shrink-0 w-6 text-right">
#{index + 1}
</span>
<div className="flex-1 min-w-0">
{/* Prompt */}
<textarea
value={item.prompt}
onChange={e => onUpdate(index, { prompt: e.target.value })}
className="w-full rounded-md border border-border/40 bg-transparent px-2 py-1.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-purple-500/50"
rows={2}
disabled={disabled}
placeholder={t('batchImageGen.prompt' as TranslationKey)}
/>
{/* Controls Row */}
<div className="flex items-center gap-2 mt-1.5">
{/* Aspect Ratio */}
<select
value={item.aspectRatio}
onChange={e => onUpdate(index, { aspectRatio: e.target.value })}
className="rounded-md border border-border/40 bg-transparent px-1.5 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-purple-500/50"
disabled={disabled}
>
{ASPECT_RATIOS.map(r => (
<option key={r} value={r}>{r}</option>
))}
</select>
{/* Resolution */}
<select
value={item.resolution}
onChange={e => onUpdate(index, { resolution: e.target.value })}
className="rounded-md border border-border/40 bg-transparent px-1.5 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-purple-500/50"
disabled={disabled}
>
{RESOLUTIONS.map(r => (
<option key={r} value={r}>{r}</option>
))}
</select>
{/* Tags */}
{item.tags.length > 0 && (
<div className="flex items-center gap-1 overflow-hidden">
{item.tags.map((tag, i) => (
<span key={i} className="inline-block rounded-full bg-purple-500/10 text-purple-600 dark:text-purple-400 px-1.5 py-0 text-[10px]">
{tag}
</span>
))}
</div>
)}
<div className="flex-1" />
{/* Remove */}
<button
type="button"
onClick={() => onRemove(index)}
disabled={disabled}
className="text-[10px] text-muted-foreground hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-0"
>
{t('batchImageGen.removeItem' as TranslationKey)}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
export { BatchPlanRow } from './BatchPlanRow';
export { BatchPlanInlinePreview } from './BatchPlanInlinePreview';
export { BatchExecutionDashboard } from './BatchExecutionDashboard';
export { BatchExecutionItem } from './BatchExecutionItem';
export { BatchContextSync } from './BatchContextSync';
+4
View File
@@ -13,6 +13,7 @@ import { DocPreview } from "./DocPreview";
import { PanelContext, type PanelContent, type PreviewViewMode } from "@/hooks/usePanel";
import { UpdateContext, type UpdateInfo } from "@/hooks/useUpdate";
import { ImageGenContext, useImageGenState } from "@/hooks/useImageGen";
import { BatchImageGenContext, useBatchImageGenState } from "@/hooks/useBatchImageGen";
import { ErrorBoundary } from "./ErrorBoundary";
const CHATLIST_MIN = 180;
@@ -344,11 +345,13 @@ export function AppShell({ children }: { children: React.ReactNode }) {
);
const imageGenValue = useImageGenState();
const batchImageGenValue = useBatchImageGenState();
return (
<UpdateContext.Provider value={updateContextValue}>
<PanelContext.Provider value={panelContextValue}>
<ImageGenContext.Provider value={imageGenValue}>
<BatchImageGenContext.Provider value={batchImageGenValue}>
<TooltipProvider delayDuration={300}>
<div className="flex h-screen overflow-hidden">
<NavRail
@@ -400,6 +403,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div>
<UpdateDialog />
</TooltipProvider>
</BatchImageGenContext.Provider>
</ImageGenContext.Provider>
</PanelContext.Provider>
</UpdateContext.Provider>
+97 -30
View File
@@ -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>
))
) : (