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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
})()
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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