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
@@ -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' },
|
||||
|
||||
Reference in new issue
Block a user