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
@@ -0,0 +1,485 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||
import type { MediaJob, MediaJobItem, PlannerOutput, PlannerItem, JobProgressEvent } from '@/types';
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
export type BatchPhase = 'idle' | 'entry' | 'planning' | 'reviewing' | 'executing' | 'completed' | 'syncing';
|
||||
|
||||
export interface BatchImageGenState {
|
||||
enabled: boolean;
|
||||
phase: BatchPhase;
|
||||
currentJob: MediaJob | null;
|
||||
items: MediaJobItem[];
|
||||
plannerOutput: PlannerOutput | null;
|
||||
planningText: string;
|
||||
progress: {
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
processing: number;
|
||||
};
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BatchImageGenContextValue {
|
||||
state: BatchImageGenState;
|
||||
setEnabled: (v: boolean) => void;
|
||||
startPlanning: (params: { stylePrompt: string; docPaths?: string[]; docContent?: string; count?: number; sessionId?: string }) => Promise<void>;
|
||||
updatePlanItem: (index: number, updates: Partial<PlannerItem>) => void;
|
||||
addPlanItem: () => void;
|
||||
removePlanItem: (index: number) => void;
|
||||
executeJob: (sessionId?: string) => Promise<void>;
|
||||
pauseJob: () => Promise<void>;
|
||||
resumeJob: () => Promise<void>;
|
||||
cancelJob: () => Promise<void>;
|
||||
retryFailed: () => Promise<void>;
|
||||
syncToLlm: (syncMode?: 'manual' | 'auto_batch') => Promise<void>;
|
||||
resetJob: () => void;
|
||||
injectPlanAndExecute: (plan: PlannerOutput, sessionId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Context
|
||||
// ==========================================
|
||||
|
||||
export const BatchImageGenContext = createContext<BatchImageGenContextValue | null>(null);
|
||||
|
||||
export function useBatchImageGen(): BatchImageGenContextValue {
|
||||
const ctx = useContext(BatchImageGenContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useBatchImageGen must be used within a BatchImageGenProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// State Hook
|
||||
// ==========================================
|
||||
|
||||
const initialState: BatchImageGenState = {
|
||||
enabled: false,
|
||||
phase: 'idle',
|
||||
currentJob: null,
|
||||
items: [],
|
||||
plannerOutput: null,
|
||||
planningText: '',
|
||||
progress: { total: 0, completed: 0, failed: 0, processing: 0 },
|
||||
error: null,
|
||||
};
|
||||
|
||||
export function useBatchImageGenState(): BatchImageGenContextValue {
|
||||
const [state, setState] = useState<BatchImageGenState>(initialState);
|
||||
const progressSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const setEnabled = useCallback((v: boolean) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
enabled: v,
|
||||
phase: v ? 'entry' : 'idle',
|
||||
error: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const startPlanning = useCallback(async (params: {
|
||||
stylePrompt: string;
|
||||
docPaths?: string[];
|
||||
docContent?: string;
|
||||
count?: number;
|
||||
sessionId?: string;
|
||||
}) => {
|
||||
setState(prev => ({ ...prev, phase: 'planning', planningText: '', error: null }));
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/media/jobs/plan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
stylePrompt: params.stylePrompt,
|
||||
docPaths: params.docPaths,
|
||||
docContent: params.docContent,
|
||||
count: params.count,
|
||||
sessionId: params.sessionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Planning failed' }));
|
||||
throw new Error(err.error || 'Planning failed');
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error('No response stream');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let fullText = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
let eventType = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
eventType = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
if (eventType === 'text') {
|
||||
fullText += data.chunk;
|
||||
setState(prev => ({ ...prev, planningText: fullText }));
|
||||
} else if (eventType === 'plan_complete') {
|
||||
const plan = data.plan as PlannerOutput;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
phase: 'reviewing',
|
||||
plannerOutput: plan,
|
||||
planningText: fullText,
|
||||
}));
|
||||
} else if (eventType === 'error') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
phase: 'entry',
|
||||
error: data.message || 'Planning failed',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
phase: 'entry',
|
||||
error: err instanceof Error ? err.message : 'Planning failed',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updatePlanItem = useCallback((index: number, updates: Partial<PlannerItem>) => {
|
||||
setState(prev => {
|
||||
if (!prev.plannerOutput) return prev;
|
||||
const newItems = [...prev.plannerOutput.items];
|
||||
newItems[index] = { ...newItems[index], ...updates };
|
||||
return { ...prev, plannerOutput: { ...prev.plannerOutput, items: newItems } };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addPlanItem = useCallback(() => {
|
||||
setState(prev => {
|
||||
if (!prev.plannerOutput) return prev;
|
||||
const newItem: PlannerItem = {
|
||||
prompt: '',
|
||||
aspectRatio: '1:1',
|
||||
resolution: '1K',
|
||||
tags: [],
|
||||
sourceRefs: [],
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
plannerOutput: {
|
||||
...prev.plannerOutput,
|
||||
items: [...prev.plannerOutput.items, newItem],
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removePlanItem = useCallback((index: number) => {
|
||||
setState(prev => {
|
||||
if (!prev.plannerOutput) return prev;
|
||||
const newItems = prev.plannerOutput.items.filter((_, i) => i !== index);
|
||||
return { ...prev, plannerOutput: { ...prev.plannerOutput, items: newItems } };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const executeJob = useCallback(async (sessionId?: string) => {
|
||||
const currentPlan = state.plannerOutput;
|
||||
if (!currentPlan || currentPlan.items.length === 0) return;
|
||||
|
||||
setState(prev => ({ ...prev, phase: 'executing', error: null }));
|
||||
|
||||
try {
|
||||
// Create the job
|
||||
const createRes = await fetch('/api/media/jobs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
items: currentPlan.items.map(item => ({
|
||||
prompt: item.prompt,
|
||||
aspectRatio: item.aspectRatio,
|
||||
imageSize: item.resolution,
|
||||
tags: item.tags,
|
||||
sourceRefs: item.sourceRefs,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!createRes.ok) {
|
||||
const err = await createRes.json().catch(() => ({ error: 'Failed to create job' }));
|
||||
throw new Error(err.error);
|
||||
}
|
||||
|
||||
const { job, items } = await createRes.json();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentJob: job,
|
||||
items,
|
||||
progress: { total: items.length, completed: 0, failed: 0, processing: 0 },
|
||||
}));
|
||||
|
||||
// Start execution
|
||||
const startRes = await fetch(`/api/media/jobs/${job.id}/start`, { method: 'POST' });
|
||||
if (!startRes.ok) {
|
||||
const err = await startRes.json().catch(() => ({ error: 'Failed to start job' }));
|
||||
throw new Error(err.error);
|
||||
}
|
||||
|
||||
// Connect to progress SSE
|
||||
connectProgressSSE(job.id);
|
||||
} catch (err) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
phase: 'reviewing',
|
||||
error: err instanceof Error ? err.message : 'Execution failed',
|
||||
}));
|
||||
}
|
||||
}, [state.plannerOutput]);
|
||||
|
||||
const connectProgressSSE = useCallback((jobId: string) => {
|
||||
// Close existing connection
|
||||
if (progressSourceRef.current) {
|
||||
progressSourceRef.current.close();
|
||||
}
|
||||
|
||||
const es = new EventSource(`/api/media/jobs/${jobId}/progress`);
|
||||
progressSourceRef.current = es;
|
||||
|
||||
const handleEvent = (e: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
|
||||
if (data.progress) {
|
||||
setState(prev => ({ ...prev, progress: data.progress }));
|
||||
}
|
||||
|
||||
if (data.items) {
|
||||
setState(prev => ({ ...prev, items: data.items }));
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
|
||||
es.addEventListener('snapshot', (e: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
progress: data.progress,
|
||||
items: data.items || prev.items,
|
||||
}));
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
es.addEventListener('item_started', handleEvent);
|
||||
es.addEventListener('item_completed', (e: MessageEvent) => {
|
||||
handleEvent(e);
|
||||
// Refresh items from server for latest state
|
||||
refreshItems(jobId);
|
||||
});
|
||||
es.addEventListener('item_failed', handleEvent);
|
||||
es.addEventListener('item_retry', handleEvent);
|
||||
|
||||
es.addEventListener('job_completed', (e: MessageEvent) => {
|
||||
handleEvent(e);
|
||||
setState(prev => ({ ...prev, phase: 'completed' }));
|
||||
es.close();
|
||||
progressSourceRef.current = null;
|
||||
refreshItems(jobId);
|
||||
});
|
||||
|
||||
es.addEventListener('job_paused', (e: MessageEvent) => {
|
||||
handleEvent(e);
|
||||
refreshItems(jobId);
|
||||
});
|
||||
|
||||
es.addEventListener('job_cancelled', (e: MessageEvent) => {
|
||||
handleEvent(e);
|
||||
setState(prev => ({ ...prev, phase: 'completed' }));
|
||||
es.close();
|
||||
progressSourceRef.current = null;
|
||||
refreshItems(jobId);
|
||||
});
|
||||
|
||||
es.addEventListener('done', () => {
|
||||
es.close();
|
||||
progressSourceRef.current = null;
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
progressSourceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshItems = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/media/jobs/${jobId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentJob: data.job,
|
||||
items: data.items,
|
||||
}));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const pauseJob = useCallback(async () => {
|
||||
if (!state.currentJob) return;
|
||||
try {
|
||||
await fetch(`/api/media/jobs/${state.currentJob.id}/pause`, { method: 'POST' });
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentJob: prev.currentJob ? { ...prev.currentJob, status: 'paused' } : null,
|
||||
}));
|
||||
} catch (err) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Failed to pause',
|
||||
}));
|
||||
}
|
||||
}, [state.currentJob]);
|
||||
|
||||
const resumeJob = useCallback(async () => {
|
||||
if (!state.currentJob) return;
|
||||
try {
|
||||
await fetch(`/api/media/jobs/${state.currentJob.id}/resume`, { method: 'POST' });
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentJob: prev.currentJob ? { ...prev.currentJob, status: 'running' } : null,
|
||||
}));
|
||||
connectProgressSSE(state.currentJob.id);
|
||||
} catch (err) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Failed to resume',
|
||||
}));
|
||||
}
|
||||
}, [state.currentJob, connectProgressSSE]);
|
||||
|
||||
const cancelJob = useCallback(async () => {
|
||||
if (!state.currentJob) return;
|
||||
try {
|
||||
await fetch(`/api/media/jobs/${state.currentJob.id}/cancel`, { method: 'POST' });
|
||||
if (progressSourceRef.current) {
|
||||
progressSourceRef.current.close();
|
||||
progressSourceRef.current = null;
|
||||
}
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
phase: 'completed',
|
||||
currentJob: prev.currentJob ? { ...prev.currentJob, status: 'cancelled' } : null,
|
||||
}));
|
||||
} catch (err) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Failed to cancel',
|
||||
}));
|
||||
}
|
||||
}, [state.currentJob]);
|
||||
|
||||
const retryFailed = useCallback(async () => {
|
||||
if (!state.currentJob) return;
|
||||
// Resume the job — the executor will pick up failed items that haven't exhausted retries
|
||||
try {
|
||||
await fetch(`/api/media/jobs/${state.currentJob.id}/resume`, { method: 'POST' });
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
phase: 'executing',
|
||||
currentJob: prev.currentJob ? { ...prev.currentJob, status: 'running' } : null,
|
||||
}));
|
||||
connectProgressSSE(state.currentJob.id);
|
||||
} catch (err) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : 'Failed to retry',
|
||||
}));
|
||||
}
|
||||
}, [state.currentJob, connectProgressSSE]);
|
||||
|
||||
const syncToLlm = useCallback(async (syncMode: 'manual' | 'auto_batch' = 'manual') => {
|
||||
if (!state.currentJob) return;
|
||||
|
||||
setState(prev => ({ ...prev, phase: 'syncing', error: null }));
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/media/jobs/${state.currentJob.id}/sync-context`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ syncMode }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Sync failed' }));
|
||||
throw new Error(err.error);
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
phase: 'idle',
|
||||
enabled: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
phase: 'completed',
|
||||
error: err instanceof Error ? err.message : 'Sync failed',
|
||||
}));
|
||||
}
|
||||
}, [state.currentJob]);
|
||||
|
||||
const resetJob = useCallback(() => {
|
||||
if (progressSourceRef.current) {
|
||||
progressSourceRef.current.close();
|
||||
progressSourceRef.current = null;
|
||||
}
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
const injectPlanAndExecute = useCallback(async (plan: PlannerOutput, sessionId?: string) => {
|
||||
// Inject plan into state so the UI shows the reviewing phase
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
enabled: true,
|
||||
phase: 'reviewing',
|
||||
plannerOutput: plan,
|
||||
error: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
setEnabled,
|
||||
startPlanning,
|
||||
updatePlanItem,
|
||||
addPlanItem,
|
||||
removePlanItem,
|
||||
executeJob,
|
||||
pauseJob,
|
||||
resumeJob,
|
||||
cancelJob,
|
||||
retryFailed,
|
||||
syncToLlm,
|
||||
resetJob,
|
||||
injectPlanAndExecute,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user