feat: unify reference image data structure, add Nano Banana 2 model

Reference Image 统一重构:
- 新增 ReferenceImage 类型 (src/types/index.ts),统一承载 base64 和 localPath 两种来源
- 重写 image-ref-store.ts:单一 Map<string, ReferenceImage[]> 替代原来的
  imageGenRefStore + lastGeneratedImagePaths 双通路
  - sessionStorage 持久化 last-generated 路径,解决页面刷新后数据丢失
  - buildReferenceImages() 作为唯一的 merge 入口,消除各组件手写拼接逻辑
- ImageGenConfirmation props 从两个 (referenceImagePaths + referenceImagesData)
  合并为单一 referenceImages?: ReferenceImage[]
  - 预览渲染改为单循环,根据 data/localPath 决定 img src,上传图和结果图同时显示
  - API 调用时再拆分回 base64 和路径两个字段,后端无需改动
- StreamingMessage / MessageItem 各自 5 行手动 merge 逻辑替换为一行
  buildReferenceImages() 调用
- ChatView 改用 setLastGeneratedImages() 和 transferPendingToMessage()
  替代直接操作 Map
- MessageInput 改用 setRefImages() / deleteRefImages() 替代直接操作 Map

新增 Gemini 3.1 Flash Image 模型:
- 新增 Nano Banana 2 (gemini-3.1-flash-image-preview) 作为默认图片生成模型
- 模型列表: Nano Banana 2 > Nano Banana Pro > Nano Banana
- 更新 ProviderManager、image-generator.ts、media/generate route 的默认值

其他已有改动一并提交:
- batch image generation 功能文件
- 依赖更新 (package.json / package-lock.json)
- i18n、db schema、AppShell、uploads route 等相关改动

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-27 00:55:24 +08:00
1 parent 00d3fef5a4
commit d5dd9cfe34
37 files changed
+3508 -343

No files matched your search

+15 -153
View File
@@ -1,14 +1,5 @@
import { NextRequest } from 'next/server';
import { generateImage, NoImageGeneratedError } from 'ai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { getDb, getSession } from '@/lib/db';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import os from 'os';
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot');
const MEDIA_DIR = path.join(dataDir, '.codepilot-media');
import { generateSingleImage, NoImageGeneratedError } from '@/lib/image-generator';
interface GenerateRequest {
prompt: string;
@@ -25,8 +16,6 @@ export const dynamic = 'force-dynamic';
export const maxDuration = 300;
export async function POST(request: NextRequest) {
const startTime = Date.now();
try {
const body: GenerateRequest = await request.json();
@@ -37,156 +26,29 @@ export async function POST(request: NextRequest) {
);
}
const db = getDb();
const provider = db.prepare(
"SELECT api_key FROM api_providers WHERE provider_type = 'gemini-image' AND api_key != '' LIMIT 1"
).get() as { api_key: string } | undefined;
if (!provider) {
return new Response(
JSON.stringify({ error: 'No Gemini Image provider configured. Please add a provider with type "gemini-image" in Settings.' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const requestedModel = body.model || 'gemini-3-pro-image-preview';
const aspectRatio = (body.aspectRatio || '1:1') as `${number}:${number}`;
const imageSize = body.imageSize || '1K';
const google = createGoogleGenerativeAI({ apiKey: provider.api_key });
// Build prompt: plain string or { text, images } for reference images
// Support both base64 referenceImages and on-disk referenceImagePaths
let refImageData: string[] = [];
if (body.referenceImages && body.referenceImages.length > 0) {
refImageData = body.referenceImages.map(img => img.data);
} else if (body.referenceImagePaths && body.referenceImagePaths.length > 0) {
for (const filePath of body.referenceImagePaths) {
if (fs.existsSync(filePath)) {
const buf = fs.readFileSync(filePath);
refImageData.push(buf.toString('base64'));
}
}
}
const prompt = refImageData.length > 0
? { text: body.prompt, images: refImageData }
: body.prompt;
const { images } = await generateImage({
model: google.image(requestedModel),
prompt,
providerOptions: {
google: {
imageConfig: { aspectRatio, imageSize },
},
},
maxRetries: 3,
abortSignal: AbortSignal.timeout(120_000),
const result = await generateSingleImage({
prompt: body.prompt,
model: body.model,
aspectRatio: body.aspectRatio,
imageSize: body.imageSize,
referenceImages: body.referenceImages,
referenceImagePaths: body.referenceImagePaths,
sessionId: body.sessionId,
});
const elapsed = Date.now() - startTime;
console.log(`[media/generate] ${requestedModel} ${imageSize} completed in ${elapsed}ms`);
// Ensure media directory exists
if (!fs.existsSync(MEDIA_DIR)) {
fs.mkdirSync(MEDIA_DIR, { recursive: true });
}
// Write images to disk
const savedImages: Array<{ mimeType: string; localPath: string }> = [];
for (const img of images) {
const ext = img.mediaType === 'image/jpeg' ? '.jpg'
: img.mediaType === 'image/webp' ? '.webp'
: '.png';
const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
const filePath = path.join(MEDIA_DIR, filename);
fs.writeFileSync(filePath, Buffer.from(img.uint8Array));
savedImages.push({
mimeType: img.mediaType,
localPath: filePath,
});
}
// Copy images to project directory if sessionId is provided
if (body.sessionId) {
try {
const session = getSession(body.sessionId);
if (session?.working_directory) {
const projectImgDir = path.join(session.working_directory, '.codepilot-images');
if (!fs.existsSync(projectImgDir)) {
fs.mkdirSync(projectImgDir, { recursive: true });
}
for (const saved of savedImages) {
const destPath = path.join(projectImgDir, path.basename(saved.localPath));
fs.copyFileSync(saved.localPath, destPath);
}
console.log(`[media/generate] Copied ${savedImages.length} image(s) to ${projectImgDir}`);
}
} catch (copyErr) {
console.warn('[media/generate] Failed to copy images to project directory:', copyErr);
}
}
// Save reference images to disk for gallery display
const savedRefImages: Array<{ mimeType: string; localPath: string }> = [];
if (refImageData.length > 0) {
const refMimeTypes = body.referenceImages
? body.referenceImages.map(img => img.mimeType)
: body.referenceImagePaths
? body.referenceImagePaths.map(() => 'image/png')
: [];
for (let i = 0; i < refImageData.length; i++) {
const mime = refMimeTypes[i] || 'image/png';
const ext = mime === 'image/jpeg' ? '.jpg' : mime === 'image/webp' ? '.webp' : '.png';
const filename = `ref-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
const filePath = path.join(MEDIA_DIR, filename);
fs.writeFileSync(filePath, Buffer.from(refImageData[i], 'base64'));
savedRefImages.push({ mimeType: mime, localPath: filePath });
}
}
// DB record
const id = crypto.randomBytes(16).toString('hex');
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
const localPath = savedImages.length > 0 ? savedImages[0].localPath : '';
const metadata: Record<string, unknown> = {
imageCount: savedImages.length,
elapsedMs: elapsed,
model: requestedModel,
};
if (savedRefImages.length > 0) {
metadata.referenceImages = savedRefImages;
}
db.prepare(
`INSERT INTO media_generations (id, type, status, provider, model, prompt, aspect_ratio, image_size, local_path, thumbnail_path, session_id, message_id, tags, metadata, error, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id, 'image', 'completed', 'gemini', requestedModel, body.prompt,
aspectRatio, imageSize, localPath, '',
body.sessionId || null, null,
'[]', JSON.stringify(metadata),
null, now, now
);
return new Response(
JSON.stringify({
id,
id: result.mediaGenerationId,
text: '',
images: savedImages,
model: requestedModel,
imageSize,
elapsedMs: elapsed,
images: result.images,
model: body.model || 'gemini-3.1-flash-image-preview',
imageSize: body.imageSize || '1K',
elapsedMs: result.elapsedMs,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
const elapsed = Date.now() - startTime;
console.error(`[media/generate] Failed after ${elapsed}ms:`, error);
console.error('[media/generate] Failed:', error);
if (NoImageGeneratedError.isInstance(error)) {
return new Response(
@@ -0,0 +1,38 @@
import { NextRequest } from 'next/server';
import { getMediaJob } from '@/lib/db';
import { cancelJob } from '@/lib/job-executor';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/media/jobs/:id/cancel — Cancel a running or paused job
*/
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
if (job.status !== 'running' && job.status !== 'paused' && job.status !== 'planned') {
return Response.json(
{ error: `Cannot cancel job with status "${job.status}".` },
{ status: 409 }
);
}
cancelJob(id);
return Response.json({ success: true, jobId: id, status: 'cancelled' });
} catch (error) {
console.error('[api/media/jobs/[id]/cancel] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to cancel job' },
{ status: 500 }
);
}
}
@@ -0,0 +1,49 @@
import { NextRequest } from 'next/server';
import { getMediaJob, getMediaJobItems, updateMediaJobItem } from '@/lib/db';
import type { UpdateMediaJobItemsRequest } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* PUT /api/media/jobs/:id/items — Batch edit items (during review phase)
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
if (job.status !== 'planned' && job.status !== 'draft') {
return Response.json(
{ error: 'Items can only be edited when job is in planned or draft status' },
{ status: 409 }
);
}
const body: UpdateMediaJobItemsRequest = await request.json();
for (const itemUpdate of body.items) {
updateMediaJobItem(itemUpdate.id, {
prompt: itemUpdate.prompt,
aspectRatio: itemUpdate.aspectRatio,
imageSize: itemUpdate.imageSize,
tags: itemUpdate.tags,
});
}
const items = getMediaJobItems(id);
return Response.json({ job, items });
} catch (error) {
console.error('[api/media/jobs/[id]/items] PUT failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to update items' },
{ status: 500 }
);
}
}
@@ -0,0 +1,38 @@
import { NextRequest } from 'next/server';
import { getMediaJob } from '@/lib/db';
import { pauseJob } from '@/lib/job-executor';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/media/jobs/:id/pause — Pause a running job
*/
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
if (job.status !== 'running') {
return Response.json(
{ error: `Cannot pause job with status "${job.status}". Must be "running".` },
{ status: 409 }
);
}
pauseJob(id);
return Response.json({ success: true, jobId: id, status: 'paused' });
} catch (error) {
console.error('[api/media/jobs/[id]/pause] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to pause job' },
{ status: 500 }
);
}
}
@@ -0,0 +1,89 @@
import { NextRequest } from 'next/server';
import { getMediaJob, getMediaJobItems } from '@/lib/db';
import { addProgressListener, isJobRunning } from '@/lib/job-executor';
import type { JobProgressEvent } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/media/jobs/:id/progress — SSE real-time progress stream
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const send = (event: string, data: unknown) => {
try {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
} catch {
// Stream may be closed
}
};
// Send current snapshot immediately
const items = getMediaJobItems(id);
const snapshot = {
total: items.length,
completed: items.filter(i => i.status === 'completed').length,
failed: items.filter(i => i.status === 'failed').length,
processing: items.filter(i => i.status === 'processing').length,
};
send('snapshot', { jobId: id, status: job.status, progress: snapshot, items });
// If the job is not running, send done immediately
if (!isJobRunning(id) && job.status !== 'running') {
send('done', { jobId: id, status: job.status });
controller.close();
return;
}
// Register progress listener
const cleanup = addProgressListener(id, (event: JobProgressEvent) => {
send(event.type, event);
// Close stream on terminal events
if (event.type === 'job_completed' || event.type === 'job_cancelled') {
send('done', { jobId: id });
cleanup();
controller.close();
}
});
// Heartbeat to keep connection alive
const heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(': heartbeat\n\n'));
} catch {
clearInterval(heartbeat);
cleanup();
}
}, 15000);
// Cleanup on client disconnect (AbortSignal)
_request.signal.addEventListener('abort', () => {
clearInterval(heartbeat);
cleanup();
try { controller.close(); } catch { /* already closed */ }
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
@@ -0,0 +1,42 @@
import { NextRequest } from 'next/server';
import { getMediaJob } from '@/lib/db';
import { startJob } from '@/lib/job-executor';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/media/jobs/:id/resume — Resume a paused job
*/
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
if (job.status !== 'paused') {
return Response.json(
{ error: `Cannot resume job with status "${job.status}". Must be "paused".` },
{ status: 409 }
);
}
// Resume by calling startJob (which handles paused → running)
startJob(id).catch(err => {
console.error(`[api/media/jobs/${id}/resume] Background execution error:`, err);
});
return Response.json({ success: true, jobId: id, status: 'running' });
} catch (error) {
console.error('[api/media/jobs/[id]/resume] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to resume job' },
{ status: 500 }
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest } from 'next/server';
import { getMediaJob, getMediaJobItems, deleteMediaJob } from '@/lib/db';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/media/jobs/:id — Get job detail with items
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
const items = getMediaJobItems(id);
return Response.json({ job, items });
} catch (error) {
console.error('[api/media/jobs/[id]] GET failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to get job' },
{ status: 500 }
);
}
}
/**
* DELETE /api/media/jobs/:id — Delete a job
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
// Only allow deleting jobs that are not currently running
if (job.status === 'running') {
return Response.json(
{ error: 'Cannot delete a running job. Pause or cancel it first.' },
{ status: 409 }
);
}
deleteMediaJob(id);
return Response.json({ success: true });
} catch (error) {
console.error('[api/media/jobs/[id]] DELETE failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to delete job' },
{ status: 500 }
);
}
}
@@ -0,0 +1,42 @@
import { NextRequest } from 'next/server';
import { getMediaJob } from '@/lib/db';
import { startJob } from '@/lib/job-executor';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/media/jobs/:id/start — Start executing a job
*/
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
if (job.status !== 'planned' && job.status !== 'paused') {
return Response.json(
{ error: `Cannot start job from status "${job.status}". Must be "planned" or "paused".` },
{ status: 409 }
);
}
// Start job asynchronously (don't await — it runs in the background)
startJob(id).catch(err => {
console.error(`[api/media/jobs/${id}/start] Background execution error:`, err);
});
return Response.json({ success: true, jobId: id, status: 'running' });
} catch (error) {
console.error('[api/media/jobs/[id]/start] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to start job' },
{ status: 500 }
);
}
}
@@ -0,0 +1,99 @@
import { NextRequest } from 'next/server';
import { getMediaJob, getMediaJobItems, addMessage, createContextEvent, markContextEventSynced } from '@/lib/db';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* POST /api/media/jobs/:id/sync-context — Sync completed results to LLM conversation
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const job = getMediaJob(id);
if (!job) {
return Response.json({ error: 'Job not found' }, { status: 404 });
}
const body = await request.json().catch(() => ({}));
const syncMode = body.syncMode || 'manual';
if (!job.session_id) {
return Response.json(
{ error: 'Job has no associated session. Cannot sync to conversation.' },
{ status: 400 }
);
}
const items = getMediaJobItems(id);
const completedItems = items.filter(i => i.status === 'completed');
if (completedItems.length === 0) {
return Response.json(
{ error: 'No completed items to sync' },
{ status: 400 }
);
}
// Build summary message
const lines: string[] = [
`## Batch Image Generation Complete`,
``,
`**${completedItems.length}/${items.length}** images generated successfully.`,
``,
];
for (const item of completedItems) {
const tags = JSON.parse(item.tags || '[]') as string[];
const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
lines.push(`- **#${item.idx + 1}**: ${item.prompt.slice(0, 100)}${item.prompt.length > 100 ? '...' : ''}${tagStr}`);
if (item.result_media_generation_id) {
lines.push(` - Generation ID: ${item.result_media_generation_id}`);
}
}
const failedItems = items.filter(i => i.status === 'failed');
if (failedItems.length > 0) {
lines.push(``, `**${failedItems.length} items failed:**`);
for (const item of failedItems) {
lines.push(`- #${item.idx + 1}: ${item.error || 'Unknown error'}`);
}
}
const summaryText = lines.join('\n');
// Add as user message to the session
addMessage(job.session_id, 'user', summaryText);
// Record context event
const payload = {
completedCount: completedItems.length,
failedCount: failedItems.length,
totalCount: items.length,
itemSummaries: completedItems.map(i => ({
idx: i.idx,
prompt: i.prompt.slice(0, 200),
mediaGenerationId: i.result_media_generation_id,
})),
};
const event = createContextEvent({
sessionId: job.session_id,
jobId: id,
payload,
syncMode,
});
markContextEventSynced(event.id);
return Response.json({ success: true, eventId: event.id, messageLength: summaryText.length });
} catch (error) {
console.error('[api/media/jobs/[id]/sync-context] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to sync context' },
{ status: 500 }
);
}
}
+161
View File
@@ -0,0 +1,161 @@
import { NextRequest } from 'next/server';
import { streamTextFromProvider } from '@/lib/text-generator';
import { getDb } from '@/lib/db';
import fs from 'fs';
import type { PlanMediaJobRequest } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
const PLANNER_SYSTEM_PROMPT = `You are an image generation planner. Given document content and a style prompt, you must plan a series of image generation tasks.
Your output MUST be a valid JSON object with this exact structure:
{
"summary": "Brief summary of the plan",
"items": [
{
"prompt": "Detailed image generation prompt",
"aspectRatio": "1:1",
"resolution": "1K",
"tags": ["tag1", "tag2"],
"sourceRefs": ["reference to source document section"]
}
]
}
Guidelines:
- Each prompt should be detailed, specific, and optimized for AI image generation
- Incorporate the style prompt into each item's prompt naturally
- Choose appropriate aspect ratios based on content (1:1, 16:9, 9:16, 3:2, 4:3, etc.)
- Add relevant tags for organization
- Reference the source document sections that inspired each image
- Keep resolution at "1K" unless the content specifically needs higher resolution
- Generate the requested number of items, or determine an appropriate count from the content`;
/**
* POST /api/media/jobs/plan — SSE streaming planner
* Does NOT write to DB — returns plan for client preview/editing
*/
export async function POST(request: NextRequest) {
try {
const body: PlanMediaJobRequest = await request.json();
if (!body.stylePrompt) {
return Response.json(
{ error: 'stylePrompt is required' },
{ status: 400 }
);
}
// Resolve the provider and model from the session or defaults
const db = getDb();
let providerId = '';
let modelId = '';
if (body.sessionId) {
const session = db.prepare('SELECT provider_id, model FROM chat_sessions WHERE id = ?').get(body.sessionId) as { provider_id: string; model: string } | undefined;
if (session) {
providerId = session.provider_id;
modelId = session.model;
}
}
// Fallback to default provider
if (!providerId) {
const defaultId = db.prepare("SELECT value FROM settings WHERE key = 'default_provider_id'").get() as { value: string } | undefined;
providerId = defaultId?.value || '';
}
if (!modelId) {
const defaultModel = db.prepare("SELECT value FROM settings WHERE key = 'default_model'").get() as { value: string } | undefined;
modelId = defaultModel?.value || 'claude-sonnet-4-20250514';
}
// Read document content
let docContent = body.docContent || '';
if (body.docPaths && body.docPaths.length > 0 && !docContent) {
const parts: string[] = [];
for (const docPath of body.docPaths) {
try {
const stat = fs.statSync(docPath);
if (stat.size > 100 * 1024) {
parts.push(`[File: ${docPath} — skipped, too large (${Math.round(stat.size / 1024)}KB)]`);
continue;
}
const content = fs.readFileSync(docPath, 'utf-8');
parts.push(`--- File: ${docPath} ---\n${content}`);
} catch {
parts.push(`[File: ${docPath} — could not be read]`);
}
}
docContent = parts.join('\n\n');
}
// Build the user prompt
const countHint = body.count ? `Generate exactly ${body.count} image items.` : 'Determine the appropriate number of images based on the content.';
const userPrompt = `Style prompt: ${body.stylePrompt}
${countHint}
${docContent ? `Document content:\n${docContent}` : 'No document provided — generate images based on the style prompt alone.'}`;
// Create SSE stream
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
};
send('planning_start', { message: 'Starting plan generation...' });
try {
let fullText = '';
for await (const chunk of streamTextFromProvider({
providerId,
model: modelId,
system: PLANNER_SYSTEM_PROMPT,
prompt: userPrompt,
maxTokens: 4096,
})) {
fullText += chunk;
send('text', { chunk });
}
// Extract JSON from the response
const jsonMatch = fullText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
send('error', { message: 'Failed to extract plan JSON from LLM response' });
send('done', {});
controller.close();
return;
}
const plan = JSON.parse(jsonMatch[0]);
send('plan_complete', { plan });
} catch (err) {
const message = err instanceof Error ? err.message : 'Planning failed';
send('error', { message });
}
send('done', {});
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
} catch (error) {
console.error('[api/media/jobs/plan] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Planning failed' },
{ status: 500 }
);
}
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest } from 'next/server';
import {
getAllMediaJobs,
getMediaJobsBySession,
createMediaJob,
createMediaJobItems,
getMediaJob,
getMediaJobItems,
} from '@/lib/db';
import type { CreateMediaJobRequest } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/media/jobs — List all jobs, optionally filtered by sessionId
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const sessionId = searchParams.get('sessionId');
const limit = parseInt(searchParams.get('limit') || '50');
const offset = parseInt(searchParams.get('offset') || '0');
const jobs = sessionId
? getMediaJobsBySession(sessionId)
: getAllMediaJobs(limit, offset);
return Response.json({ jobs });
} catch (error) {
console.error('[api/media/jobs] GET failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to list jobs' },
{ status: 500 }
);
}
}
/**
* POST /api/media/jobs — Create a new job with items (after plan confirmation)
*/
export async function POST(request: NextRequest) {
try {
const body: CreateMediaJobRequest = await request.json();
if (!body.items || body.items.length === 0) {
return Response.json(
{ error: 'At least one item is required' },
{ status: 400 }
);
}
// Create the job
const job = createMediaJob({
sessionId: body.sessionId,
docPaths: body.docPaths,
stylePrompt: body.stylePrompt,
batchConfig: body.batchConfig,
totalItems: body.items.length,
});
// Create the items
const items = createMediaJobItems(job.id, body.items);
return Response.json({ job: getMediaJob(job.id), items });
} catch (error) {
console.error('[api/media/jobs] POST failed:', error);
return Response.json(
{ error: error instanceof Error ? error.message : 'Failed to create job' },
{ status: 500 }
);
}
}
+3 -2
View File
@@ -33,9 +33,10 @@ export async function GET(request: NextRequest) {
});
}
// Security: only allow files within .codepilot-uploads/ directories
// Security: only allow files within known .codepilot-* directories
const resolved = path.resolve(filePath);
if (!resolved.includes('.codepilot-uploads')) {
const allowedDirs = ['.codepilot-uploads', '.codepilot-media', '.codepilot-images'];
if (!allowedDirs.some(dir => resolved.includes(dir))) {
return new Response(JSON.stringify({ error: 'Access denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },