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

+92
View File
@@ -8,7 +8,9 @@
"name": "codepilot",
"version": "0.15.1",
"dependencies": {
"@ai-sdk/anthropic": "^3.0.47",
"@ai-sdk/google": "^3.0.31",
"@ai-sdk/openai": "^3.0.34",
"@anthropic-ai/claude-agent-sdk": "^0.2.33",
"@google/genai": "^1.43.0",
"@hugeicons/core-free-icons": "^3.1.1",
@@ -66,6 +68,51 @@
"wait-on": "^9.0.3"
}
},
"node_modules/@ai-sdk/anthropic": {
"version": "3.0.47",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.47.tgz",
"integrity": "sha512-E6Z3i/xvxGDxRskMMbuX9+xDK4l5LesrP2O7YQ0CcbAkYP25qTo/kYGf/AsJrLkNIY23HeO/kheUWtG1XZllDA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.15"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/anthropic/node_modules/@ai-sdk/provider-utils": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz",
"integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "3.0.36",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.36.tgz",
@@ -128,6 +175,51 @@
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "3.0.34",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.34.tgz",
"integrity": "sha512-rnSsjV7cqIUcYdHgTcXRvN+xn0+wv/DB/U1SCxCVOKDsD9xaNgDjQTuMeJ+dUTzpH4Gn+XS0dJQELCWVIFYQVg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.15"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz",
"integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.7.tgz",
+2
View File
@@ -21,7 +21,9 @@
"electron:pack:linux": "npm run electron:build && electron-builder --linux --config electron-builder.yml"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.47",
"@ai-sdk/google": "^3.0.31",
"@ai-sdk/openai": "^3.0.34",
"@anthropic-ai/claude-agent-sdk": "^0.2.33",
"@google/genai": "^1.43.0",
"@hugeicons/core-free-icons": "^3.1.1",
+15 -153
View File
@@ -1,14 +1,5 @@
import { NextRequest } from 'next/server';
import { generateImage, NoImageGeneratedError } from 'ai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { getDb, getSession } from '@/lib/db';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import os from 'os';
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot');
const MEDIA_DIR = path.join(dataDir, '.codepilot-media');
import { generateSingleImage, NoImageGeneratedError } from '@/lib/image-generator';
interface GenerateRequest {
prompt: string;
@@ -25,8 +16,6 @@ export const dynamic = 'force-dynamic';
export const maxDuration = 300;
export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
const body: GenerateRequest = await request.json();
@@ -37,156 +26,29 @@ export async function POST(request: NextRequest) {
);
}
const db = getDb();
const provider = db.prepare(
"SELECT api_key FROM api_providers WHERE provider_type = 'gemini-image' AND api_key != '' LIMIT 1"
).get() as { api_key: string } | undefined;
if (!provider) {
return new Response(
JSON.stringify({ error: 'No Gemini Image provider configured. Please add a provider with type "gemini-image" in Settings.' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const requestedModel = body.model || 'gemini-3-pro-image-preview';
const aspectRatio = (body.aspectRatio || '1:1') as `${number}:${number}`;
const imageSize = body.imageSize || '1K';
const google = createGoogleGenerativeAI({ apiKey: provider.api_key });
// Build prompt: plain string or { text, images } for reference images
// Support both base64 referenceImages and on-disk referenceImagePaths
let refImageData: string[] = [];
if (body.referenceImages && body.referenceImages.length > 0) {
refImageData = body.referenceImages.map(img => img.data);
} else if (body.referenceImagePaths && body.referenceImagePaths.length > 0) {
for (const filePath of body.referenceImagePaths) {
if (fs.existsSync(filePath)) {
const buf = fs.readFileSync(filePath);
refImageData.push(buf.toString('base64'));
}
}
}
const prompt = refImageData.length > 0
? { text: body.prompt, images: refImageData }
: body.prompt;
const { images } = await generateImage({
model: google.image(requestedModel),
prompt,
providerOptions: {
google: {
imageConfig: { aspectRatio, imageSize },
},
},
maxRetries: 3,
abortSignal: AbortSignal.timeout(120_000),
const result = await generateSingleImage({
prompt: body.prompt,
model: body.model,
aspectRatio: body.aspectRatio,
imageSize: body.imageSize,
referenceImages: body.referenceImages,
referenceImagePaths: body.referenceImagePaths,
sessionId: body.sessionId,
});
const elapsed = Date.now() - startTime;
console.log(`[media/generate] ${requestedModel} ${imageSize} completed in ${elapsed}ms`);
// Ensure media directory exists
if (!fs.existsSync(MEDIA_DIR)) {
fs.mkdirSync(MEDIA_DIR, { recursive: true });
}
// Write images to disk
const savedImages: Array<{ mimeType: string; localPath: string }> = [];
for (const img of images) {
const ext = img.mediaType === 'image/jpeg' ? '.jpg'
: img.mediaType === 'image/webp' ? '.webp'
: '.png';
const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
const filePath = path.join(MEDIA_DIR, filename);
fs.writeFileSync(filePath, Buffer.from(img.uint8Array));
savedImages.push({
mimeType: img.mediaType,
localPath: filePath,
});
}
// Copy images to project directory if sessionId is provided
if (body.sessionId) {
try {
const session = getSession(body.sessionId);
if (session?.working_directory) {
const projectImgDir = path.join(session.working_directory, '.codepilot-images');
if (!fs.existsSync(projectImgDir)) {
fs.mkdirSync(projectImgDir, { recursive: true });
}
for (const saved of savedImages) {
const destPath = path.join(projectImgDir, path.basename(saved.localPath));
fs.copyFileSync(saved.localPath, destPath);
}
console.log(`[media/generate] Copied ${savedImages.length} image(s) to ${projectImgDir}`);
}
} catch (copyErr) {
console.warn('[media/generate] Failed to copy images to project directory:', copyErr);
}
}
// Save reference images to disk for gallery display
const savedRefImages: Array<{ mimeType: string; localPath: string }> = [];
if (refImageData.length > 0) {
const refMimeTypes = body.referenceImages
? body.referenceImages.map(img => img.mimeType)
: body.referenceImagePaths
? body.referenceImagePaths.map(() => 'image/png')
: [];
for (let i = 0; i < refImageData.length; i++) {
const mime = refMimeTypes[i] || 'image/png';
const ext = mime === 'image/jpeg' ? '.jpg' : mime === 'image/webp' ? '.webp' : '.png';
const filename = `ref-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
const filePath = path.join(MEDIA_DIR, filename);
fs.writeFileSync(filePath, Buffer.from(refImageData[i], 'base64'));
savedRefImages.push({ mimeType: mime, localPath: filePath });
}
}
// DB record
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
const localPath = savedImages.length > 0 ? savedImages[0].localPath : '';
const metadata: Record<string, unknown> = {
imageCount: savedImages.length,
elapsedMs: elapsed,
model: requestedModel,
};
if (savedRefImages.length > 0) {
metadata.referenceImages = savedRefImages;
}
db.prepare(
`INSERT INTO media_generations (id, type, status, provider, model, prompt, aspect_ratio, image_size, local_path, thumbnail_path, session_id, message_id, tags, metadata, error, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id, 'image', 'completed', 'gemini', requestedModel, body.prompt,
aspectRatio, imageSize, localPath, '',
body.sessionId || null, null,
'[]', JSON.stringify(metadata),
null, now, now
);
return new Response(
JSON.stringify({
id,
id: result.mediaGenerationId,
text: '',
images: savedImages,
model: requestedModel,
imageSize,
elapsedMs: elapsed,
images: result.images,
model: body.model || 'gemini-3.1-flash-image-preview',
imageSize: body.imageSize || '1K',
elapsedMs: result.elapsedMs,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
const elapsed = Date.now() - startTime;
console.error(`[media/generate] Failed after ${elapsed}ms:`, error);
console.error('[media/generate] Failed:', error);
if (NoImageGeneratedError.isInstance(error)) {
return new Response(
@@ -0,0 +1,38 @@
import { NextRequest } from 'next/server';
import { getMediaJob } from '@/lib/db';
import { cancelJob } from '@/lib/job-executor';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/media/jobs/:id/cancel — Cancel a running or paused job
*/
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
if (job.status !== 'running' && job.status !== 'paused' && job.status !== 'planned') {
return Response.json(
{ error: `Cannot cancel job with status "${job.status}".` },
{ status: 409 }
);
}
cancelJob(id);
return Response.json({ success: true, jobId: id, status: 'cancelled' });
} catch (error) {
console.error('[api/media/jobs/[id]/cancel] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to cancel job' },
{ status: 500 }
);
}
}
@@ -0,0 +1,49 @@
import { NextRequest } from 'next/server';
import { getMediaJob, getMediaJobItems, updateMediaJobItem } from '@/lib/db';
import type { UpdateMediaJobItemsRequest } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* PUT /api/media/jobs/:id/items — Batch edit items (during review phase)
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
if (job.status !== 'planned' && job.status !== 'draft') {
return Response.json(
{ error: 'Items can only be edited when job is in planned or draft status' },
{ status: 409 }
);
}
const body: UpdateMediaJobItemsRequest = await request.json();
for (const itemUpdate of body.items) {
updateMediaJobItem(itemUpdate.id, {
prompt: itemUpdate.prompt,
aspectRatio: itemUpdate.aspectRatio,
imageSize: itemUpdate.imageSize,
tags: itemUpdate.tags,
});
}
const items = getMediaJobItems(id);
return Response.json({ job, items });
} catch (error) {
console.error('[api/media/jobs/[id]/items] PUT failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to update items' },
{ status: 500 }
);
}
}
@@ -0,0 +1,38 @@
import { NextRequest } from 'next/server';
import { getMediaJob } from '@/lib/db';
import { pauseJob } from '@/lib/job-executor';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/media/jobs/:id/pause — Pause a running job
*/
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
if (job.status !== 'running') {
return Response.json(
{ error: `Cannot pause job with status "${job.status}". Must be "running".` },
{ status: 409 }
);
}
pauseJob(id);
return Response.json({ success: true, jobId: id, status: 'paused' });
} catch (error) {
console.error('[api/media/jobs/[id]/pause] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to pause job' },
{ status: 500 }
);
}
}
@@ -0,0 +1,89 @@
import { NextRequest } from 'next/server';
import { getMediaJob, getMediaJobItems } from '@/lib/db';
import { addProgressListener, isJobRunning } from '@/lib/job-executor';
import type { JobProgressEvent } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/media/jobs/:id/progress — SSE real-time progress stream
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const send = (event: string, data: unknown) => {
try {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
} catch {
// Stream may be closed
}
};
// Send current snapshot immediately
const items = getMediaJobItems(id);
const snapshot = {
total: items.length,
completed: items.filter(i => i.status === 'completed').length,
failed: items.filter(i => i.status === 'failed').length,
processing: items.filter(i => i.status === 'processing').length,
};
send('snapshot', { jobId: id, status: job.status, progress: snapshot, items });
// If the job is not running, send done immediately
if (!isJobRunning(id) && job.status !== 'running') {
send('done', { jobId: id, status: job.status });
controller.close();
return;
}
// Register progress listener
const cleanup = addProgressListener(id, (event: JobProgressEvent) => {
send(event.type, event);
// Close stream on terminal events
if (event.type === 'job_completed' || event.type === 'job_cancelled') {
send('done', { jobId: id });
cleanup();
controller.close();
}
});
// Heartbeat to keep connection alive
const heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(': heartbeat\n\n'));
} catch {
clearInterval(heartbeat);
cleanup();
}
}, 15000);
// Cleanup on client disconnect (AbortSignal)
_request.signal.addEventListener('abort', () => {
clearInterval(heartbeat);
cleanup();
try { controller.close(); } catch { /* already closed */ }
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
@@ -0,0 +1,42 @@
import { NextRequest } from 'next/server';
import { getMediaJob } from '@/lib/db';
import { startJob } from '@/lib/job-executor';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/media/jobs/:id/resume — Resume a paused job
*/
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
if (job.status !== 'paused') {
return Response.json(
{ error: `Cannot resume job with status "${job.status}". Must be "paused".` },
{ status: 409 }
);
}
// Resume by calling startJob (which handles paused → running)
startJob(id).catch(err => {
console.error(`[api/media/jobs/${id}/resume] Background execution error:`, err);
});
return Response.json({ success: true, jobId: id, status: 'running' });
} catch (error) {
console.error('[api/media/jobs/[id]/resume] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to resume job' },
{ status: 500 }
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest } from 'next/server';
import { getMediaJob, getMediaJobItems, deleteMediaJob } from '@/lib/db';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/media/jobs/:id — Get job detail with items
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
const items = getMediaJobItems(id);
return Response.json({ job, items });
} catch (error) {
console.error('[api/media/jobs/[id]] GET failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to get job' },
{ status: 500 }
);
}
}
/**
* DELETE /api/media/jobs/:id — Delete a job
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
// Only allow deleting jobs that are not currently running
if (job.status === 'running') {
return Response.json(
{ error: 'Cannot delete a running job. Pause or cancel it first.' },
{ status: 409 }
);
}
deleteMediaJob(id);
return Response.json({ success: true });
} catch (error) {
console.error('[api/media/jobs/[id]] DELETE failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to delete job' },
{ status: 500 }
);
}
}
@@ -0,0 +1,42 @@
import { NextRequest } from 'next/server';
import { getMediaJob } from '@/lib/db';
import { startJob } from '@/lib/job-executor';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/media/jobs/:id/start — Start executing a job
*/
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
if (job.status !== 'planned' && job.status !== 'paused') {
return Response.json(
{ error: `Cannot start job from status "${job.status}". Must be "planned" or "paused".` },
{ status: 409 }
);
}
// Start job asynchronously (don't await — it runs in the background)
startJob(id).catch(err => {
console.error(`[api/media/jobs/${id}/start] Background execution error:`, err);
});
return Response.json({ success: true, jobId: id, status: 'running' });
} catch (error) {
console.error('[api/media/jobs/[id]/start] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to start job' },
{ status: 500 }
);
}
}
@@ -0,0 +1,99 @@
import { NextRequest } from 'next/server';
import { getMediaJob, getMediaJobItems, addMessage, createContextEvent, markContextEventSynced } from '@/lib/db';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/media/jobs/:id/sync-context — Sync completed results to LLM conversation
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
const body = await request.json().catch(() => ({}));
const syncMode = body.syncMode || 'manual';
if (!job.session_id) {
return Response.json(
{ error: 'Job has no associated session. Cannot sync to conversation.' },
{ status: 400 }
);
}
const items = getMediaJobItems(id);
const completedItems = items.filter(i => i.status === 'completed');
if (completedItems.length === 0) {
return Response.json(
{ error: 'No completed items to sync' },
{ status: 400 }
);
}
// Build summary message
const lines: string[] = [
`## Batch Image Generation Complete`,
``,
`**${completedItems.length}/${items.length}** images generated successfully.`,
``,
];
for (const item of completedItems) {
const tags = JSON.parse(item.tags || '[]') as string[];
const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
lines.push(`- **#${item.idx + 1}**: ${item.prompt.slice(0, 100)}${item.prompt.length > 100 ? '...' : ''}${tagStr}`);
if (item.result_media_generation_id) {
lines.push(` - Generation ID: ${item.result_media_generation_id}`);
}
}
const failedItems = items.filter(i => i.status === 'failed');
if (failedItems.length > 0) {
lines.push(``, `**${failedItems.length} items failed:**`);
for (const item of failedItems) {
lines.push(`- #${item.idx + 1}: ${item.error || 'Unknown error'}`);
}
}
const summaryText = lines.join('\n');
// Add as user message to the session
addMessage(job.session_id, 'user', summaryText);
// Record context event
const payload = {
completedCount: completedItems.length,
failedCount: failedItems.length,
totalCount: items.length,
itemSummaries: completedItems.map(i => ({
idx: i.idx,
prompt: i.prompt.slice(0, 200),
mediaGenerationId: i.result_media_generation_id,
})),
};
const event = createContextEvent({
sessionId: job.session_id,
jobId: id,
payload,
syncMode,
});
markContextEventSynced(event.id);
return Response.json({ success: true, eventId: event.id, messageLength: summaryText.length });
} catch (error) {
console.error('[api/media/jobs/[id]/sync-context] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to sync context' },
{ status: 500 }
);
}
}
+161
View File
@@ -0,0 +1,161 @@
import { NextRequest } from 'next/server';
import { streamTextFromProvider } from '@/lib/text-generator';
import { getDb } from '@/lib/db';
import fs from 'fs';
import type { PlanMediaJobRequest } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
const PLANNER_SYSTEM_PROMPT = `You are an image generation planner. Given document content and a style prompt, you must plan a series of image generation tasks.
Your output MUST be a valid JSON object with this exact structure:
{
"summary": "Brief summary of the plan",
"items": [
{
"prompt": "Detailed image generation prompt",
"aspectRatio": "1:1",
"resolution": "1K",
"tags": ["tag1", "tag2"],
"sourceRefs": ["reference to source document section"]
}
]
}
Guidelines:
- Each prompt should be detailed, specific, and optimized for AI image generation
- Incorporate the style prompt into each item's prompt naturally
- Choose appropriate aspect ratios based on content (1:1, 16:9, 9:16, 3:2, 4:3, etc.)
- Add relevant tags for organization
- Reference the source document sections that inspired each image
- Keep resolution at "1K" unless the content specifically needs higher resolution
- Generate the requested number of items, or determine an appropriate count from the content`;
/**
* POST /api/media/jobs/plan — SSE streaming planner
* Does NOT write to DB — returns plan for client preview/editing
*/
export async function POST(request: NextRequest) {
try {
const body: PlanMediaJobRequest = await request.json();
if (!body.stylePrompt) {
return Response.json(
{ error: 'stylePrompt is required' },
{ status: 400 }
);
}
// Resolve the provider and model from the session or defaults
const db = getDb();
let providerId = '';
let modelId = '';
if (body.sessionId) {
const session = db.prepare('SELECT provider_id, model FROM chat_sessions WHERE id = ?').get(body.sessionId) as { provider_id: string; model: string } | undefined;
if (session) {
providerId = session.provider_id;
modelId = session.model;
}
}
// Fallback to default provider
if (!providerId) {
const defaultId = db.prepare("SELECT value FROM settings WHERE key = 'default_provider_id'").get() as { value: string } | undefined;
providerId = defaultId?.value || '';
}
if (!modelId) {
const defaultModel = db.prepare("SELECT value FROM settings WHERE key = 'default_model'").get() as { value: string } | undefined;
modelId = defaultModel?.value || 'claude-sonnet-4-20250514';
}
// Read document content
let docContent = body.docContent || '';
if (body.docPaths && body.docPaths.length > 0 && !docContent) {
const parts: string[] = [];
for (const docPath of body.docPaths) {
try {
const stat = fs.statSync(docPath);
if (stat.size > 100 * 1024) {
parts.push(`[File: ${docPath} — skipped, too large (${Math.round(stat.size / 1024)}KB)]`);
continue;
}
const content = fs.readFileSync(docPath, 'utf-8');
parts.push(`--- File: ${docPath} ---\n${content}`);
} catch {
parts.push(`[File: ${docPath} — could not be read]`);
}
}
docContent = parts.join('\n\n');
}
// Build the user prompt
const countHint = body.count ? `Generate exactly ${body.count} image items.` : 'Determine the appropriate number of images based on the content.';
const userPrompt = `Style prompt: ${body.stylePrompt}
${countHint}
${docContent ? `Document content:\n${docContent}` : 'No document provided — generate images based on the style prompt alone.'}`;
// Create SSE stream
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
};
send('planning_start', { message: 'Starting plan generation...' });
try {
let fullText = '';
for await (const chunk of streamTextFromProvider({
providerId,
model: modelId,
system: PLANNER_SYSTEM_PROMPT,
prompt: userPrompt,
maxTokens: 4096,
})) {
fullText += chunk;
send('text', { chunk });
}
// Extract JSON from the response
const jsonMatch = fullText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
send('error', { message: 'Failed to extract plan JSON from LLM response' });
send('done', {});
controller.close();
return;
}
const plan = JSON.parse(jsonMatch[0]);
send('plan_complete', { plan });
} catch (err) {
const message = err instanceof Error ? err.message : 'Planning failed';
send('error', { message });
}
send('done', {});
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
} catch (error) {
console.error('[api/media/jobs/plan] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Planning failed' },
{ status: 500 }
);
}
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest } from 'next/server';
import {
getAllMediaJobs,
getMediaJobsBySession,
createMediaJob,
createMediaJobItems,
getMediaJob,
getMediaJobItems,
} from '@/lib/db';
import type { CreateMediaJobRequest } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/media/jobs — List all jobs, optionally filtered by sessionId
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const sessionId = searchParams.get('sessionId');
const limit = parseInt(searchParams.get('limit') || '50');
const offset = parseInt(searchParams.get('offset') || '0');
const jobs = sessionId
? getMediaJobsBySession(sessionId)
: getAllMediaJobs(limit, offset);
return Response.json({ jobs });
} catch (error) {
console.error('[api/media/jobs] GET failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to list jobs' },
{ status: 500 }
);
}
}
/**
* POST /api/media/jobs — Create a new job with items (after plan confirmation)
*/
export async function POST(request: NextRequest) {
try {
const body: CreateMediaJobRequest = await request.json();
if (!body.items || body.items.length === 0) {
return Response.json(
{ error: 'At least one item is required' },
{ status: 400 }
);
}
// Create the job
const job = createMediaJob({
sessionId: body.sessionId,
docPaths: body.docPaths,
stylePrompt: body.stylePrompt,
batchConfig: body.batchConfig,
totalItems: body.items.length,
});
// Create the items
const items = createMediaJobItems(job.id, body.items);
return Response.json({ job: getMediaJob(job.id), items });
} catch (error) {
console.error('[api/media/jobs] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to create job' },
{ status: 500 }
);
}
}
+3 -2
View File
@@ -33,9 +33,10 @@ export async function GET(request: NextRequest) {
});
}
// Security: only allow files within .codepilot-uploads/ directories
// Security: only allow files within known .codepilot-* directories
const resolved = path.resolve(filePath);
if (!resolved.includes('.codepilot-uploads')) {
const allowedDirs = ['.codepilot-uploads', '.codepilot-media', '.codepilot-images'];
if (!allowedDirs.some(dir => resolved.includes(dir))) {
return new Response(JSON.stringify({ error: 'Access denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
+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>
))
) : (
+485
View File
@@ -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,
};
}
+48 -2
View File
@@ -309,8 +309,8 @@ const en = {
// ── Image Generation ──────────────────────────────────────
'imageGen.toggle': 'Image Generation',
'imageGen.toggleLabel': 'Image',
'imageGen.toggleTooltip': 'Toggle image generation mode',
'imageGen.toggleLabel': 'Image Agent',
'imageGen.toggleTooltip': 'Toggle Image Agent — AI analyzes intent and generates single or batch images',
'imageGen.generating': 'Generating image...',
'imageGen.params': 'Generation Parameters',
'imageGen.aspectRatio': 'Aspect Ratio',
@@ -336,6 +336,52 @@ const en = {
'imageGen.generatingStatus': 'Generating...',
'imageGen.stopButton': 'Stop',
// ── Batch Image Generation ─────────────────────────────────
'batchImageGen.toggle': 'Batch Generate',
'batchImageGen.toggleTooltip': 'Toggle batch image generation mode',
'batchImageGen.entryTitle': 'Batch Image Generation',
'batchImageGen.stylePrompt': 'Style Prompt',
'batchImageGen.stylePromptPlaceholder': 'Describe the visual style for all images...',
'batchImageGen.uploadDocs': 'Upload Documents',
'batchImageGen.uploadDocsHint': 'Upload text/markdown files as source content',
'batchImageGen.count': 'Image Count',
'batchImageGen.countAuto': 'Auto',
'batchImageGen.aspectRatioStrategy': 'Aspect Ratio',
'batchImageGen.resolution': 'Resolution',
'batchImageGen.generatePlan': 'Generate Plan',
'batchImageGen.planning': 'Planning...',
'batchImageGen.planPreviewTitle': 'Generation Plan',
'batchImageGen.planSummary': 'Plan Summary',
'batchImageGen.editPrompt': 'Edit prompt',
'batchImageGen.addItem': 'Add Item',
'batchImageGen.removeItem': 'Remove',
'batchImageGen.regeneratePlan': 'Regenerate Plan',
'batchImageGen.confirmAndExecute': 'Confirm & Execute',
'batchImageGen.executing': 'Executing...',
'batchImageGen.totalProgress': 'Progress',
'batchImageGen.itemPending': 'Pending',
'batchImageGen.itemProcessing': 'Generating...',
'batchImageGen.itemCompleted': 'Done',
'batchImageGen.itemFailed': 'Failed',
'batchImageGen.retryFailed': 'Retry Failed',
'batchImageGen.retryItem': 'Retry',
'batchImageGen.pause': 'Pause',
'batchImageGen.resume': 'Resume',
'batchImageGen.cancel': 'Cancel',
'batchImageGen.syncToChat': 'Sync to Conversation',
'batchImageGen.syncComplete': 'Synced to conversation',
'batchImageGen.syncMode': 'Sync Mode',
'batchImageGen.syncManual': 'Manual',
'batchImageGen.syncAutoBatch': 'Auto Batch',
'batchImageGen.completed': 'Batch generation complete',
'batchImageGen.completedStats': '{completed}/{total} images generated',
'batchImageGen.noProvider': 'Please configure a text generation provider in Settings',
'batchImageGen.tags': 'Tags',
'batchImageGen.sourceRefs': 'Sources',
'batchImageGen.prompt': 'Prompt',
'batchImageGen.aspectRatio': 'Ratio',
'batchImageGen.imageSize': 'Size',
// ── Gallery ─────────────────────────────────────────────────
'gallery.title': 'Gallery',
'gallery.empty': 'No images generated yet',
+48 -2
View File
@@ -306,8 +306,8 @@ const zh: Record<TranslationKey, string> = {
// ── Image Generation ──────────────────────────────────────
'imageGen.toggle': '图片生成',
'imageGen.toggleLabel': '生图',
'imageGen.toggleTooltip': '切换图片生成模式',
'imageGen.toggleLabel': 'Image Agent',
'imageGen.toggleTooltip': '切换 Image Agent — AI 自动分析意图,支持单张和批量生图',
'imageGen.generating': '正在生成图片...',
'imageGen.params': '生成参数',
'imageGen.aspectRatio': '画面比例',
@@ -333,6 +333,52 @@ const zh: Record<TranslationKey, string> = {
'imageGen.generatingStatus': '正在生成...',
'imageGen.stopButton': '停止',
// ── Batch Image Generation ─────────────────────────────────
'batchImageGen.toggle': '批量生图',
'batchImageGen.toggleTooltip': '切换批量图片生成模式',
'batchImageGen.entryTitle': '批量图片生成',
'batchImageGen.stylePrompt': '风格提示词',
'batchImageGen.stylePromptPlaceholder': '描述所有图片的视觉风格...',
'batchImageGen.uploadDocs': '上传文档',
'batchImageGen.uploadDocsHint': '上传文本/Markdown 文件作为内容来源',
'batchImageGen.count': '图片数量',
'batchImageGen.countAuto': '自动',
'batchImageGen.aspectRatioStrategy': '画面比例',
'batchImageGen.resolution': '分辨率',
'batchImageGen.generatePlan': '生成计划',
'batchImageGen.planning': '规划中...',
'batchImageGen.planPreviewTitle': '生成计划',
'batchImageGen.planSummary': '计划摘要',
'batchImageGen.editPrompt': '编辑提示词',
'batchImageGen.addItem': '添加项目',
'batchImageGen.removeItem': '删除',
'batchImageGen.regeneratePlan': '重新生成计划',
'batchImageGen.confirmAndExecute': '确认并执行',
'batchImageGen.executing': '执行中...',
'batchImageGen.totalProgress': '进度',
'batchImageGen.itemPending': '等待中',
'batchImageGen.itemProcessing': '生成中...',
'batchImageGen.itemCompleted': '完成',
'batchImageGen.itemFailed': '失败',
'batchImageGen.retryFailed': '重试失败项',
'batchImageGen.retryItem': '重试',
'batchImageGen.pause': '暂停',
'batchImageGen.resume': '继续',
'batchImageGen.cancel': '取消',
'batchImageGen.syncToChat': '同步到对话',
'batchImageGen.syncComplete': '已同步到对话',
'batchImageGen.syncMode': '同步模式',
'batchImageGen.syncManual': '手动',
'batchImageGen.syncAutoBatch': '自动批量',
'batchImageGen.completed': '批量生成完成',
'batchImageGen.completedStats': '已生成 {completed}/{total} 张图片',
'batchImageGen.noProvider': '请先在设置中配置文本生成服务商',
'batchImageGen.tags': '标签',
'batchImageGen.sourceRefs': '来源',
'batchImageGen.prompt': '提示词',
'batchImageGen.aspectRatio': '比例',
'batchImageGen.imageSize': '尺寸',
// ── Gallery ─────────────────────────────────────────────────
'gallery.title': '素材库',
'gallery.empty': '暂无生成的图片',
+350 -1
View File
@@ -3,7 +3,7 @@ import path from 'path';
import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import type { ChatSession, Message, SettingsMap, TaskItem, TaskStatus, ApiProvider, CreateProviderRequest, UpdateProviderRequest } from '@/types';
import type { ChatSession, Message, SettingsMap, TaskItem, TaskStatus, ApiProvider, CreateProviderRequest, UpdateProviderRequest, MediaJob, MediaJobStatus, MediaJobItem, MediaJobItemStatus, MediaContextEvent, BatchConfig } from '@/types';
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot');
const DB_PATH = path.join(dataDir, 'codepilot.db');
@@ -136,6 +136,57 @@ function initDb(db: Database.Database): void {
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS media_jobs (
id TEXT PRIMARY KEY,
session_id TEXT,
status TEXT NOT NULL DEFAULT 'draft'
CHECK(status IN ('draft','planning','planned','running','paused','completed','cancelled','failed')),
doc_paths TEXT NOT NULL DEFAULT '[]',
style_prompt TEXT NOT NULL DEFAULT '',
batch_config TEXT NOT NULL DEFAULT '{}',
total_items INTEGER NOT NULL DEFAULT 0,
completed_items INTEGER NOT NULL DEFAULT 0,
failed_items INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT,
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS media_job_items (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
idx INTEGER NOT NULL DEFAULT 0,
prompt TEXT NOT NULL DEFAULT '',
aspect_ratio TEXT NOT NULL DEFAULT '1:1',
image_size TEXT NOT NULL DEFAULT '1K',
model TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
source_refs TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','processing','completed','failed','cancelled')),
retry_count INTEGER NOT NULL DEFAULT 0,
result_media_generation_id TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (job_id) REFERENCES media_jobs(id) ON DELETE CASCADE,
FOREIGN KEY (result_media_generation_id) REFERENCES media_generations(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS media_context_events (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
job_id TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
sync_mode TEXT NOT NULL DEFAULT 'manual'
CHECK(sync_mode IN ('manual','auto_batch')),
synced_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (job_id) REFERENCES media_jobs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON chat_sessions(updated_at);
@@ -143,6 +194,11 @@ function initDb(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_generations(created_at);
CREATE INDEX IF NOT EXISTS idx_media_session_id ON media_generations(session_id);
CREATE INDEX IF NOT EXISTS idx_media_status ON media_generations(status);
CREATE INDEX IF NOT EXISTS idx_media_jobs_session_id ON media_jobs(session_id);
CREATE INDEX IF NOT EXISTS idx_media_jobs_status ON media_jobs(status);
CREATE INDEX IF NOT EXISTS idx_media_job_items_job_id ON media_job_items(job_id);
CREATE INDEX IF NOT EXISTS idx_media_job_items_status ON media_job_items(status);
CREATE INDEX IF NOT EXISTS idx_media_context_events_job_id ON media_context_events(job_id);
`);
// Run migrations for existing databases
@@ -271,6 +327,76 @@ function migrateDb(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_media_status ON media_generations(status);
`);
// Ensure media_jobs tables exist for databases created before this migration
db.exec(`
CREATE TABLE IF NOT EXISTS media_jobs (
id TEXT PRIMARY KEY,
session_id TEXT,
status TEXT NOT NULL DEFAULT 'draft'
CHECK(status IN ('draft','planning','planned','running','paused','completed','cancelled','failed')),
doc_paths TEXT NOT NULL DEFAULT '[]',
style_prompt TEXT NOT NULL DEFAULT '',
batch_config TEXT NOT NULL DEFAULT '{}',
total_items INTEGER NOT NULL DEFAULT 0,
completed_items INTEGER NOT NULL DEFAULT 0,
failed_items INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT,
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS media_job_items (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
idx INTEGER NOT NULL DEFAULT 0,
prompt TEXT NOT NULL DEFAULT '',
aspect_ratio TEXT NOT NULL DEFAULT '1:1',
image_size TEXT NOT NULL DEFAULT '1K',
model TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
source_refs TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','processing','completed','failed','cancelled')),
retry_count INTEGER NOT NULL DEFAULT 0,
result_media_generation_id TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (job_id) REFERENCES media_jobs(id) ON DELETE CASCADE,
FOREIGN KEY (result_media_generation_id) REFERENCES media_generations(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS media_context_events (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
job_id TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
sync_mode TEXT NOT NULL DEFAULT 'manual'
CHECK(sync_mode IN ('manual','auto_batch')),
synced_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (job_id) REFERENCES media_jobs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_media_jobs_session_id ON media_jobs(session_id);
CREATE INDEX IF NOT EXISTS idx_media_jobs_status ON media_jobs(status);
CREATE INDEX IF NOT EXISTS idx_media_job_items_job_id ON media_job_items(job_id);
CREATE INDEX IF NOT EXISTS idx_media_job_items_status ON media_job_items(status);
CREATE INDEX IF NOT EXISTS idx_media_context_events_job_id ON media_context_events(job_id);
`);
// Recover stale jobs: mark 'running' jobs as 'paused' after process restart
db.exec(`
UPDATE media_jobs SET status = 'paused', updated_at = datetime('now')
WHERE status = 'running'
`);
db.exec(`
UPDATE media_job_items SET status = 'pending', updated_at = datetime('now')
WHERE status = 'processing'
`);
// Migrate existing settings to a default provider if api_providers is empty
const providerCount = db.prepare('SELECT COUNT(*) as count FROM api_providers').get() as { count: number };
if (providerCount.count === 0) {
@@ -692,6 +818,229 @@ export function getTokenUsageStats(days: number = 30): {
return { summary, daily };
}
// ==========================================
// Media Job Operations
// ==========================================
const DEFAULT_BATCH_CONFIG: BatchConfig = {
concurrency: 2,
maxRetries: 2,
retryDelayMs: 2000,
};
export function createMediaJob(params: {
sessionId?: string;
docPaths?: string[];
stylePrompt?: string;
batchConfig?: Partial<BatchConfig>;
totalItems: number;
}): MediaJob {
const db = getDb();
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
const config = { ...DEFAULT_BATCH_CONFIG, ...params.batchConfig };
db.prepare(
`INSERT INTO media_jobs (id, session_id, status, doc_paths, style_prompt, batch_config, total_items, completed_items, failed_items, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`
).run(
id,
params.sessionId || null,
'planned',
JSON.stringify(params.docPaths || []),
params.stylePrompt || '',
JSON.stringify(config),
params.totalItems,
now,
now,
);
return getMediaJob(id)!;
}
export function getMediaJob(id: string): MediaJob | undefined {
const db = getDb();
return db.prepare('SELECT * FROM media_jobs WHERE id = ?').get(id) as MediaJob | undefined;
}
export function getMediaJobsBySession(sessionId: string): MediaJob[] {
const db = getDb();
return db.prepare('SELECT * FROM media_jobs WHERE session_id = ? ORDER BY created_at DESC').all(sessionId) as MediaJob[];
}
export function getAllMediaJobs(limit = 50, offset = 0): MediaJob[] {
const db = getDb();
return db.prepare('SELECT * FROM media_jobs ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset) as MediaJob[];
}
export function updateMediaJobStatus(id: string, status: MediaJobStatus): void {
const db = getDb();
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
const completedAt = (status === 'completed' || status === 'cancelled' || status === 'failed') ? now : null;
db.prepare(
'UPDATE media_jobs SET status = ?, updated_at = ?, completed_at = COALESCE(?, completed_at) WHERE id = ?'
).run(status, now, completedAt, id);
}
export function updateMediaJobCounters(id: string): void {
const db = getDb();
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
db.prepare(`
UPDATE media_jobs SET
completed_items = (SELECT COUNT(*) FROM media_job_items WHERE job_id = ? AND status = 'completed'),
failed_items = (SELECT COUNT(*) FROM media_job_items WHERE job_id = ? AND status = 'failed'),
updated_at = ?
WHERE id = ?
`).run(id, id, now, id);
}
export function deleteMediaJob(id: string): boolean {
const db = getDb();
const result = db.prepare('DELETE FROM media_jobs WHERE id = ?').run(id);
return result.changes > 0;
}
// ==========================================
// Media Job Item Operations
// ==========================================
export function createMediaJobItems(jobId: string, items: Array<{
prompt: string;
aspectRatio?: string;
imageSize?: string;
model?: string;
tags?: string[];
sourceRefs?: string[];
}>): MediaJobItem[] {
const db = getDb();
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
const insertStmt = db.prepare(
`INSERT INTO media_job_items (id, job_id, idx, prompt, aspect_ratio, image_size, model, tags, source_refs, status, retry_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`
);
const ids: string[] = [];
const transaction = db.transaction(() => {
for (let i = 0; i < items.length; i++) {
const item = items[i];
const id = crypto.randomBytes(16).toString('hex');
ids.push(id);
insertStmt.run(
id, jobId, i,
item.prompt,
item.aspectRatio || '1:1',
item.imageSize || '1K',
item.model || '',
JSON.stringify(item.tags || []),
JSON.stringify(item.sourceRefs || []),
now, now,
);
}
});
transaction();
return ids.map(id => db.prepare('SELECT * FROM media_job_items WHERE id = ?').get(id) as MediaJobItem);
}
export function getMediaJobItems(jobId: string): MediaJobItem[] {
const db = getDb();
return db.prepare('SELECT * FROM media_job_items WHERE job_id = ? ORDER BY idx ASC').all(jobId) as MediaJobItem[];
}
export function getMediaJobItem(id: string): MediaJobItem | undefined {
const db = getDb();
return db.prepare('SELECT * FROM media_job_items WHERE id = ?').get(id) as MediaJobItem | undefined;
}
export function getPendingJobItems(jobId: string, maxRetries: number): MediaJobItem[] {
const db = getDb();
return db.prepare(
`SELECT * FROM media_job_items
WHERE job_id = ? AND (status = 'pending' OR (status = 'failed' AND retry_count < ?))
ORDER BY idx ASC`
).all(jobId, maxRetries) as MediaJobItem[];
}
export function updateMediaJobItem(id: string, updates: {
status?: MediaJobItemStatus;
retryCount?: number;
resultMediaGenerationId?: string | null;
error?: string | null;
prompt?: string;
aspectRatio?: string;
imageSize?: string;
tags?: string[];
}): MediaJobItem | undefined {
const db = getDb();
const existing = getMediaJobItem(id);
if (!existing) return undefined;
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
db.prepare(`
UPDATE media_job_items SET
status = ?,
retry_count = ?,
result_media_generation_id = ?,
error = ?,
prompt = ?,
aspect_ratio = ?,
image_size = ?,
tags = ?,
updated_at = ?
WHERE id = ?
`).run(
updates.status ?? existing.status,
updates.retryCount ?? existing.retry_count,
updates.resultMediaGenerationId !== undefined ? updates.resultMediaGenerationId : existing.result_media_generation_id,
updates.error !== undefined ? updates.error : existing.error,
updates.prompt ?? existing.prompt,
updates.aspectRatio ?? existing.aspect_ratio,
updates.imageSize ?? existing.image_size,
updates.tags ? JSON.stringify(updates.tags) : existing.tags,
now,
id,
);
return getMediaJobItem(id);
}
export function cancelPendingJobItems(jobId: string): void {
const db = getDb();
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
db.prepare(
"UPDATE media_job_items SET status = 'cancelled', updated_at = ? WHERE job_id = ? AND status IN ('pending', 'failed')"
).run(now, jobId);
}
// ==========================================
// Media Context Event Operations
// ==========================================
export function createContextEvent(params: {
sessionId: string;
jobId: string;
payload: Record<string, unknown>;
syncMode?: 'manual' | 'auto_batch';
}): MediaContextEvent {
const db = getDb();
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
db.prepare(
`INSERT INTO media_context_events (id, session_id, job_id, payload, sync_mode, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
).run(id, params.sessionId, params.jobId, JSON.stringify(params.payload), params.syncMode || 'manual', now);
return db.prepare('SELECT * FROM media_context_events WHERE id = ?').get(id) as MediaContextEvent;
}
export function markContextEventSynced(id: string): void {
const db = getDb();
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
db.prepare('UPDATE media_context_events SET synced_at = ? WHERE id = ?').run(now, id);
}
// ==========================================
// Graceful Shutdown
// ==========================================
+179
View File
@@ -0,0 +1,179 @@
import { generateImage, NoImageGeneratedError } from 'ai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { getDb, getSession } from '@/lib/db';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import os from 'os';
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot');
const MEDIA_DIR = path.join(dataDir, '.codepilot-media');
export interface GenerateSingleImageParams {
prompt: string;
model?: string;
aspectRatio?: string;
imageSize?: string;
referenceImages?: { mimeType: string; data: string }[];
referenceImagePaths?: string[];
sessionId?: string;
abortSignal?: AbortSignal;
}
export interface GenerateSingleImageResult {
mediaGenerationId: string;
images: Array<{ mimeType: string; localPath: string }>;
elapsedMs: number;
}
/**
* Shared image generation function.
* Handles: Provider lookup → Gemini API call → file save → project dir copy → DB record.
*/
export async function generateSingleImage(params: GenerateSingleImageParams): Promise<GenerateSingleImageResult> {
const startTime = Date.now();
const db = getDb();
const provider = db.prepare(
"SELECT api_key, extra_env FROM api_providers WHERE provider_type = 'gemini-image' AND api_key != '' LIMIT 1"
).get() as { api_key: string; extra_env?: string } | undefined;
if (!provider) {
throw new Error('No Gemini Image provider configured. Please add a provider with type "gemini-image" in Settings.');
}
// Read configured model from extra_env, fall back to default
let configuredModel = 'gemini-3.1-flash-image-preview';
try {
const env = JSON.parse(provider.extra_env || '{}');
if (env.GEMINI_IMAGE_MODEL) configuredModel = env.GEMINI_IMAGE_MODEL;
} catch { /* use default */ }
const requestedModel = params.model || configuredModel;
const aspectRatio = (params.aspectRatio || '1:1') as `${number}:${number}`;
const imageSize = params.imageSize || '1K';
const google = createGoogleGenerativeAI({ apiKey: provider.api_key });
// Build prompt: plain string or { text, images } for reference images
// Combine both base64 data and file paths — both can be provided simultaneously
const refImageData: string[] = [];
if (params.referenceImagePaths && params.referenceImagePaths.length > 0) {
for (const filePath of params.referenceImagePaths) {
if (fs.existsSync(filePath)) {
const buf = fs.readFileSync(filePath);
refImageData.push(buf.toString('base64'));
}
}
}
if (params.referenceImages && params.referenceImages.length > 0) {
refImageData.push(...params.referenceImages.map(img => img.data));
}
const prompt = refImageData.length > 0
? { text: params.prompt, images: refImageData }
: params.prompt;
const { images } = await generateImage({
model: google.image(requestedModel),
prompt,
providerOptions: {
google: {
imageConfig: { aspectRatio, imageSize },
},
},
maxRetries: 3,
abortSignal: params.abortSignal || AbortSignal.timeout(300_000),
});
const elapsed = Date.now() - startTime;
console.log(`[image-generator] ${requestedModel} ${imageSize} completed in ${elapsed}ms`);
// Ensure media directory exists
if (!fs.existsSync(MEDIA_DIR)) {
fs.mkdirSync(MEDIA_DIR, { recursive: true });
}
// Write images to disk
const savedImages: Array<{ mimeType: string; localPath: string }> = [];
for (const img of images) {
const ext = img.mediaType === 'image/jpeg' ? '.jpg'
: img.mediaType === 'image/webp' ? '.webp'
: '.png';
const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
const filePath = path.join(MEDIA_DIR, filename);
fs.writeFileSync(filePath, Buffer.from(img.uint8Array));
savedImages.push({ mimeType: img.mediaType, localPath: filePath });
}
// Copy images to project directory if sessionId is provided
if (params.sessionId) {
try {
const session = getSession(params.sessionId);
if (session?.working_directory) {
const projectImgDir = path.join(session.working_directory, '.codepilot-images');
if (!fs.existsSync(projectImgDir)) {
fs.mkdirSync(projectImgDir, { recursive: true });
}
for (const saved of savedImages) {
const destPath = path.join(projectImgDir, path.basename(saved.localPath));
fs.copyFileSync(saved.localPath, destPath);
}
console.log(`[image-generator] Copied ${savedImages.length} image(s) to ${projectImgDir}`);
}
} catch (copyErr) {
console.warn('[image-generator] Failed to copy images to project directory:', copyErr);
}
}
// Save reference images to disk for gallery display
const savedRefImages: Array<{ mimeType: string; localPath: string }> = [];
if (refImageData.length > 0) {
const refMimeTypes = params.referenceImages
? params.referenceImages.map(img => img.mimeType)
: params.referenceImagePaths
? params.referenceImagePaths.map(() => 'image/png')
: [];
for (let i = 0; i < refImageData.length; i++) {
const mime = refMimeTypes[i] || 'image/png';
const ext = mime === 'image/jpeg' ? '.jpg' : mime === 'image/webp' ? '.webp' : '.png';
const filename = `ref-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
const filePath = path.join(MEDIA_DIR, filename);
fs.writeFileSync(filePath, Buffer.from(refImageData[i], 'base64'));
savedRefImages.push({ mimeType: mime, localPath: filePath });
}
}
// DB record
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
const localPath = savedImages.length > 0 ? savedImages[0].localPath : '';
const metadata: Record<string, unknown> = {
imageCount: savedImages.length,
elapsedMs: elapsed,
model: requestedModel,
};
if (savedRefImages.length > 0) {
metadata.referenceImages = savedRefImages;
}
db.prepare(
`INSERT INTO media_generations (id, type, status, provider, model, prompt, aspect_ratio, image_size, local_path, thumbnail_path, session_id, message_id, tags, metadata, error, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id, 'image', 'completed', 'gemini', requestedModel, params.prompt,
aspectRatio, imageSize, localPath, '',
params.sessionId || null, null,
'[]', JSON.stringify(metadata),
null, now, now
);
return {
mediaGenerationId: id,
images: savedImages,
elapsedMs: elapsed,
};
}
// Re-export for backward compatibility in error handling
export { NoImageGeneratedError };
+123
View File
@@ -0,0 +1,123 @@
/**
* Unified reference-image store for image generation.
*
* Keys:
* PENDING_KEY – images uploaded via the input bar, not yet bound to a message
* LAST_GENERATED_KEY – file paths of the most recently generated images (sessionStorage-backed)
* <message-id> – images bound to a specific assistant message
*/
import type { ReferenceImage } from '@/types';
export const PENDING_KEY = '__pending__';
export const LAST_GENERATED_KEY = '__last_generated__';
const store = new Map<string, ReferenceImage[]>();
// ── sessionStorage persistence for last-generated paths ──
const SS_KEY = 'imgref:last_generated';
function loadLastGenerated(): void {
if (typeof window === 'undefined') return;
try {
const raw = sessionStorage.getItem(SS_KEY);
if (raw) {
const arr: ReferenceImage[] = JSON.parse(raw);
if (Array.isArray(arr) && arr.length > 0) {
store.set(LAST_GENERATED_KEY, arr);
}
}
} catch {
// ignore
}
}
// Auto-restore on module load (client only)
loadLastGenerated();
// ── Public helpers ──
/** Set reference images for a given key. */
export function setRefImages(key: string, images: ReferenceImage[]): void {
if (images.length === 0) {
store.delete(key);
} else {
store.set(key, images);
}
}
/** Delete reference images for a given key. */
export function deleteRefImages(key: string): void {
store.delete(key);
}
/** Get reference images for a given key (or undefined). */
export function getRefImages(key: string): ReferenceImage[] | undefined {
return store.get(key);
}
/**
* Transfer pending reference images to a specific message ID.
* Called when a streaming response transitions to a persisted MessageItem.
*/
export function transferPendingToMessage(messageId: string): void {
const pending = store.get(PENDING_KEY);
if (pending) {
store.set(messageId, pending);
store.delete(PENDING_KEY);
}
}
/**
* Store generated image paths and persist to sessionStorage.
* Called when image generation completes.
*/
export function setLastGeneratedImages(paths: string[]): void {
const images: ReferenceImage[] = paths.map(p => ({ mimeType: 'image/png', localPath: p }));
store.set(LAST_GENERATED_KEY, images);
if (typeof window !== 'undefined') {
try {
sessionStorage.setItem(SS_KEY, JSON.stringify(images));
} catch {
// storage full
}
}
}
/**
* Single merge entry-point: build a unified ReferenceImage[] for a given context.
*
* @param key Store key (PENDING_KEY or message.id)
* @param useLastGenerated Whether the LLM requested editing last-generated images
* @param extraPaths Additional file paths from the parsed request (referenceImages field)
*/
export function buildReferenceImages(
key: string,
useLastGenerated: boolean,
extraPaths?: string[],
): ReferenceImage[] {
const result: ReferenceImage[] = [];
// 1. User-uploaded base64 images
const uploaded = store.get(key);
if (uploaded) {
result.push(...uploaded);
}
// 2. Last-generated images (if LLM requested)
if (useLastGenerated) {
const lastGen = store.get(LAST_GENERATED_KEY);
if (lastGen) {
result.push(...lastGen);
}
}
// 3. Extra paths from the parsed request JSON
if (extraPaths && extraPaths.length > 0) {
for (const p of extraPaths) {
result.push({ mimeType: 'image/png', localPath: p });
}
}
return result;
}
+362
View File
@@ -0,0 +1,362 @@
import {
getMediaJob,
getMediaJobItems,
getPendingJobItems,
updateMediaJobStatus,
updateMediaJobItem,
updateMediaJobCounters,
cancelPendingJobItems,
} from '@/lib/db';
import { generateSingleImage } from '@/lib/image-generator';
import type { BatchConfig, JobProgressEvent, MediaJobItem } from '@/types';
// ==========================================
// globalThis Singleton (survives hot reload)
// ==========================================
interface RunningJob {
jobId: string;
abortController: AbortController;
progressListeners: Set<(event: JobProgressEvent) => void>;
config: BatchConfig;
activeCount: number;
isRunning: boolean;
}
const GLOBAL_KEY = '__mediaJobExecutor__' as const;
function getRunningJobs(): Map<string, RunningJob> {
if (!(globalThis as Record<string, unknown>)[GLOBAL_KEY]) {
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = new Map<string, RunningJob>();
}
return (globalThis as Record<string, unknown>)[GLOBAL_KEY] as Map<string, RunningJob>;
}
const DEFAULT_CONFIG: BatchConfig = {
concurrency: 2,
maxRetries: 2,
retryDelayMs: 2000,
};
// ==========================================
// Progress Listener Management
// ==========================================
export function addProgressListener(jobId: string, listener: (event: JobProgressEvent) => void): () => void {
const running = getRunningJobs().get(jobId);
if (running) {
running.progressListeners.add(listener);
}
// Return cleanup function
return () => {
const r = getRunningJobs().get(jobId);
if (r) {
r.progressListeners.delete(listener);
}
};
}
function emitProgress(jobId: string, event: JobProgressEvent): void {
const running = getRunningJobs().get(jobId);
if (!running) return;
for (const listener of running.progressListeners) {
try {
listener(event);
} catch (err) {
console.warn('[job-executor] Progress listener error:', err);
}
}
}
function buildProgressSnapshot(jobId: string): JobProgressEvent['progress'] {
const items = getMediaJobItems(jobId);
return {
total: items.length,
completed: items.filter(i => i.status === 'completed').length,
failed: items.filter(i => i.status === 'failed').length,
processing: items.filter(i => i.status === 'processing').length,
};
}
// ==========================================
// Core Executor
// ==========================================
/**
* Start executing a job. Transitions job to 'running' and processes items with concurrency control.
*/
export async function startJob(jobId: string): Promise<void> {
const job = getMediaJob(jobId);
if (!job) throw new Error(`Job ${jobId} not found`);
if (job.status !== 'planned' && job.status !== 'paused') {
throw new Error(`Job ${jobId} cannot be started from status "${job.status}"`);
}
// Parse batch config
let config: BatchConfig;
try {
config = { ...DEFAULT_CONFIG, ...JSON.parse(job.batch_config) };
} catch {
config = { ...DEFAULT_CONFIG };
}
const abortController = new AbortController();
const runningJob: RunningJob = {
jobId,
abortController,
progressListeners: new Set(),
config,
activeCount: 0,
isRunning: true,
};
// Preserve existing listeners if resuming
const existing = getRunningJobs().get(jobId);
if (existing) {
for (const listener of existing.progressListeners) {
runningJob.progressListeners.add(listener);
}
}
getRunningJobs().set(jobId, runningJob);
updateMediaJobStatus(jobId, 'running');
// Execute the queue
try {
await executeQueue(runningJob);
} catch (err) {
console.error(`[job-executor] Job ${jobId} queue error:`, err);
}
}
async function executeQueue(runningJob: RunningJob): Promise<void> {
const { jobId, config, abortController } = runningJob;
while (runningJob.isRunning) {
// Get items that need processing
const pending = getPendingJobItems(jobId, config.maxRetries);
if (pending.length === 0 && runningJob.activeCount === 0) {
break; // All done
}
if (pending.length === 0) {
// Wait for active items to complete
await new Promise(resolve => setTimeout(resolve, 500));
continue;
}
// Fill up to concurrency limit
while (runningJob.isRunning && runningJob.activeCount < config.concurrency) {
const nextItems = getPendingJobItems(jobId, config.maxRetries);
const nextItem = nextItems.find(i => i.status === 'pending' || i.status === 'failed');
if (!nextItem) break;
runningJob.activeCount++;
processItem(runningJob, nextItem).finally(() => {
runningJob.activeCount--;
});
}
// Wait a bit before checking again
await new Promise(resolve => setTimeout(resolve, 300));
}
// Finalize the job
finalizeJob(jobId);
}
async function processItem(runningJob: RunningJob, item: MediaJobItem): Promise<void> {
const { jobId, abortController, config } = runningJob;
const now = new Date().toISOString();
// If this is a retry, wait with exponential backoff
if (item.retry_count > 0) {
const delay = config.retryDelayMs * Math.pow(3, item.retry_count - 1);
await new Promise(resolve => setTimeout(resolve, delay));
// Check if still running after delay
if (!runningJob.isRunning || abortController.signal.aborted) return;
}
// Mark as processing
updateMediaJobItem(item.id, { status: 'processing' });
emitProgress(jobId, {
type: 'item_started',
jobId,
itemId: item.id,
itemIdx: item.idx,
progress: buildProgressSnapshot(jobId),
timestamp: now,
});
try {
const result = await generateSingleImage({
prompt: item.prompt,
aspectRatio: item.aspect_ratio,
imageSize: item.image_size,
model: item.model || undefined,
sessionId: getMediaJob(jobId)?.session_id || undefined,
abortSignal: abortController.signal,
});
// Success
updateMediaJobItem(item.id, {
status: 'completed',
resultMediaGenerationId: result.mediaGenerationId,
error: null,
});
updateMediaJobCounters(jobId);
emitProgress(jobId, {
type: 'item_completed',
jobId,
itemId: item.id,
itemIdx: item.idx,
progress: buildProgressSnapshot(jobId),
mediaGenerationId: result.mediaGenerationId,
timestamp: new Date().toISOString(),
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
const statusCode = extractStatusCode(err);
const isNonRetryable = statusCode !== undefined && [400, 401, 403].includes(statusCode);
const retriesExhausted = item.retry_count + 1 >= config.maxRetries;
if (isNonRetryable || retriesExhausted) {
// Mark as failed permanently
updateMediaJobItem(item.id, {
status: 'failed',
retryCount: item.retry_count + 1,
error: errorMessage,
});
updateMediaJobCounters(jobId);
emitProgress(jobId, {
type: 'item_failed',
jobId,
itemId: item.id,
itemIdx: item.idx,
progress: buildProgressSnapshot(jobId),
error: errorMessage,
retryCount: item.retry_count + 1,
timestamp: new Date().toISOString(),
});
} else {
// Mark for retry
updateMediaJobItem(item.id, {
status: 'failed',
retryCount: item.retry_count + 1,
error: errorMessage,
});
emitProgress(jobId, {
type: 'item_retry',
jobId,
itemId: item.id,
itemIdx: item.idx,
progress: buildProgressSnapshot(jobId),
error: errorMessage,
retryCount: item.retry_count + 1,
timestamp: new Date().toISOString(),
});
}
}
}
function finalizeJob(jobId: string): void {
const job = getMediaJob(jobId);
if (!job) return;
// Only finalize if the job was still running (not paused/cancelled externally)
if (job.status !== 'running') return;
updateMediaJobCounters(jobId);
const updatedJob = getMediaJob(jobId);
if (!updatedJob) return;
const items = getMediaJobItems(jobId);
const allDone = items.every(i => i.status === 'completed' || i.status === 'failed' || i.status === 'cancelled');
if (allDone) {
const hasFailures = items.some(i => i.status === 'failed');
const finalStatus = hasFailures && updatedJob.completed_items === 0 ? 'failed' : 'completed';
updateMediaJobStatus(jobId, finalStatus);
emitProgress(jobId, {
type: 'job_completed',
jobId,
progress: buildProgressSnapshot(jobId),
timestamp: new Date().toISOString(),
});
}
// Cleanup
getRunningJobs().delete(jobId);
}
// ==========================================
// Control Operations
// ==========================================
/**
* Pause a running job. Current items finish but no new items start.
*/
export function pauseJob(jobId: string): void {
const running = getRunningJobs().get(jobId);
if (!running) throw new Error(`Job ${jobId} is not running`);
running.isRunning = false;
updateMediaJobStatus(jobId, 'paused');
emitProgress(jobId, {
type: 'job_paused',
jobId,
progress: buildProgressSnapshot(jobId),
timestamp: new Date().toISOString(),
});
}
/**
* Cancel a running or paused job.
*/
export function cancelJob(jobId: string): void {
const running = getRunningJobs().get(jobId);
if (running) {
running.isRunning = false;
running.abortController.abort();
}
cancelPendingJobItems(jobId);
updateMediaJobStatus(jobId, 'cancelled');
emitProgress(jobId, {
type: 'job_cancelled',
jobId,
progress: buildProgressSnapshot(jobId),
timestamp: new Date().toISOString(),
});
getRunningJobs().delete(jobId);
}
/**
* Check if a job is currently running in memory.
*/
export function isJobRunning(jobId: string): boolean {
return getRunningJobs().has(jobId);
}
// ==========================================
// Helpers
// ==========================================
function extractStatusCode(err: unknown): number | undefined {
if (err && typeof err === 'object' && 'status' in err) {
return (err as { status: number }).status;
}
if (err instanceof Error && err.message) {
const match = err.message.match(/\b(4\d{2}|5\d{2})\b/);
if (match) return parseInt(match[1]);
}
return undefined;
}
+119
View File
@@ -0,0 +1,119 @@
import { streamText } from 'ai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { getDb } from '@/lib/db';
import type { ApiProvider } from '@/types';
export interface StreamTextParams {
providerId: string;
model: string;
system: string;
prompt: string;
maxTokens?: number;
abortSignal?: AbortSignal;
}
/**
* Resolve a provider from DB by ID. Falls back to the default provider if not found.
*/
function resolveProvider(providerId: string): ApiProvider | undefined {
const db = getDb();
// Try the specified provider first
let provider = db.prepare('SELECT * FROM api_providers WHERE id = ?').get(providerId) as ApiProvider | undefined;
if (provider && provider.api_key) return provider;
// Fallback: get the default provider
const defaultId = db.prepare("SELECT value FROM settings WHERE key = 'default_provider_id'").get() as { value: string } | undefined;
if (defaultId?.value) {
provider = db.prepare('SELECT * FROM api_providers WHERE id = ?').get(defaultId.value) as ApiProvider | undefined;
if (provider && provider.api_key) return provider;
}
// Last resort: any provider with an API key (excluding gemini-image)
return db.prepare(
"SELECT * FROM api_providers WHERE api_key != '' AND provider_type != 'gemini-image' ORDER BY sort_order ASC LIMIT 1"
).get() as ApiProvider | undefined;
}
/**
* Create an AI SDK language model instance from a provider config.
*/
function createLanguageModel(provider: ApiProvider, modelId: string) {
const providerType = provider.provider_type;
if (providerType === 'anthropic') {
const anthropic = createAnthropic({
apiKey: provider.api_key,
baseURL: provider.base_url || undefined,
});
return anthropic(modelId);
}
if (providerType === 'gemini-image') {
// Gemini providers can also do text generation
const google = createGoogleGenerativeAI({
apiKey: provider.api_key,
baseURL: provider.base_url || undefined,
});
return google(modelId);
}
if (providerType === 'openrouter') {
// OpenRouter uses OpenAI-compatible API
const openrouter = createOpenAI({
apiKey: provider.api_key,
baseURL: provider.base_url || 'https://openrouter.ai/api/v1',
});
return openrouter(modelId);
}
// custom, bedrock, vertex — use OpenAI-compatible endpoint
if (provider.base_url) {
const custom = createOpenAI({
apiKey: provider.api_key,
baseURL: provider.base_url,
});
return custom(modelId);
}
throw new Error(`Unsupported provider type "${providerType}" or missing base_url for custom provider.`);
}
/**
* Stream text from the user's current provider.
* Returns an async iterable of text chunks.
*/
export async function* streamTextFromProvider(params: StreamTextParams): AsyncIterable<string> {
const provider = resolveProvider(params.providerId);
if (!provider) {
throw new Error('No text generation provider available. Please configure a provider in Settings.');
}
const model = createLanguageModel(provider, params.model);
const result = streamText({
model,
system: params.system,
prompt: params.prompt,
maxOutputTokens: params.maxTokens || 4096,
abortSignal: params.abortSignal || AbortSignal.timeout(120_000),
});
for await (const chunk of result.textStream) {
yield chunk;
}
}
/**
* Generate complete text (non-streaming) from the user's current provider.
* Useful when you need the full response as a string.
*/
export async function generateTextFromProvider(params: StreamTextParams): Promise<string> {
const chunks: string[] = [];
for await (const chunk of streamTextFromProvider(params)) {
chunks.push(chunk);
}
return chunks.join('');
}
+149
View File
@@ -406,6 +406,16 @@ export const SETTING_KEYS = {
MAX_THINKING_TOKENS: 'max_thinking_tokens',
} as const;
// ==========================================
// Reference Image Types (for image generation)
// ==========================================
export interface ReferenceImage {
mimeType: string;
data?: string; // base64 (user upload)
localPath?: string; // file path (generated result)
}
// ==========================================
// File Attachment Types
// ==========================================
@@ -435,6 +445,145 @@ export function formatFileSize(bytes: number): string {
// Claude Client Types
// ==========================================
// ==========================================
// Batch Image Generation Types
// ==========================================
export type MediaJobStatus = 'draft' | 'planning' | 'planned' | 'running' | 'paused' | 'completed' | 'cancelled' | 'failed';
export type MediaJobItemStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
export interface MediaJob {
id: string;
session_id: string | null;
status: MediaJobStatus;
doc_paths: string; // JSON array of file paths
style_prompt: string;
batch_config: string; // JSON of BatchConfig
total_items: number;
completed_items: number;
failed_items: number;
created_at: string;
updated_at: string;
completed_at: string | null;
}
export interface MediaJobItem {
id: string;
job_id: string;
idx: number;
prompt: string;
aspect_ratio: string;
image_size: string;
model: string;
tags: string; // JSON array of strings
source_refs: string; // JSON array of strings
status: MediaJobItemStatus;
retry_count: number;
result_media_generation_id: string | null;
error: string | null;
created_at: string;
updated_at: string;
}
export interface MediaContextEvent {
id: string;
session_id: string;
job_id: string;
payload: string; // JSON object
sync_mode: 'manual' | 'auto_batch';
synced_at: string | null;
created_at: string;
}
export interface BatchConfig {
concurrency: number; // max parallel image generations (default: 2)
maxRetries: number; // max retry attempts per item (default: 2)
retryDelayMs: number; // base delay for exponential backoff (default: 2000)
}
export interface PlannerItem {
prompt: string;
aspectRatio: string;
resolution: string;
tags: string[];
sourceRefs: string[];
}
export interface PlannerOutput {
summary: string;
items: PlannerItem[];
}
export type JobProgressEventType =
| 'item_started'
| 'item_completed'
| 'item_failed'
| 'item_retry'
| 'job_completed'
| 'job_paused'
| 'job_cancelled';
export interface JobProgressEvent {
type: JobProgressEventType;
jobId: string;
itemId?: string;
itemIdx?: number;
progress: {
total: number;
completed: number;
failed: number;
processing: number;
};
error?: string;
retryCount?: number;
mediaGenerationId?: string;
timestamp: string;
}
// --- Batch Image Gen API Types ---
export interface CreateMediaJobRequest {
sessionId?: string;
items: Array<{
prompt: string;
aspectRatio?: string;
imageSize?: string;
model?: string;
tags?: string[];
sourceRefs?: string[];
}>;
batchConfig?: Partial<BatchConfig>;
stylePrompt?: string;
docPaths?: string[];
}
export interface PlanMediaJobRequest {
docPaths?: string[];
docContent?: string;
stylePrompt: string;
sessionId?: string;
count?: number;
}
export interface UpdateMediaJobItemsRequest {
items: Array<{
id: string;
prompt?: string;
aspectRatio?: string;
imageSize?: string;
tags?: string[];
}>;
}
export interface MediaJobResponse {
job: MediaJob;
items: MediaJobItem[];
}
export interface MediaJobListResponse {
jobs: MediaJob[];
}
export interface ClaudeStreamOptions {
prompt: string;
sessionId: string;