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