Skip to content

Commit d5dd9cf

Browse files
7418claude
andcommitted
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>
1 parent 00d3fef commit d5dd9cf

37 files changed

+3508
-343
lines changed

package-lock.json

Lines changed: 92 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
"electron:pack:linux": "npm run electron:build && electron-builder --linux --config electron-builder.yml"
2222
},
2323
"dependencies": {
24+
"@ai-sdk/anthropic": "^3.0.47",
2425
"@ai-sdk/google": "^3.0.31",
26+
"@ai-sdk/openai": "^3.0.34",
2527
"@anthropic-ai/claude-agent-sdk": "^0.2.33",
2628
"@google/genai": "^1.43.0",
2729
"@hugeicons/core-free-icons": "^3.1.1",

src/app/api/media/generate/route.ts

Lines changed: 15 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,5 @@
11
import { NextRequest } from 'next/server';
2-
import { generateImage, NoImageGeneratedError } from 'ai';
3-
import { createGoogleGenerativeAI } from '@ai-sdk/google';
4-
import { getDb, getSession } from '@/lib/db';
5-
import crypto from 'crypto';
6-
import fs from 'fs';
7-
import path from 'path';
8-
import os from 'os';
9-
10-
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot');
11-
const MEDIA_DIR = path.join(dataDir, '.codepilot-media');
2+
import { generateSingleImage, NoImageGeneratedError } from '@/lib/image-generator';
123

134
interface GenerateRequest {
145
prompt: string;
@@ -25,8 +16,6 @@ export const dynamic = 'force-dynamic';
2516
export const maxDuration = 300;
2617

2718
export async function POST(request: NextRequest) {
28-
const startTime = Date.now();
29-
3019
try {
3120
const body: GenerateRequest = await request.json();
3221

@@ -37,156 +26,29 @@ export async function POST(request: NextRequest) {
3726
);
3827
}
3928

40-
const db = getDb();
41-
const provider = db.prepare(
42-
"SELECT api_key FROM api_providers WHERE provider_type = 'gemini-image' AND api_key != '' LIMIT 1"
43-
).get() as { api_key: string } | undefined;
44-
45-
if (!provider) {
46-
return new Response(
47-
JSON.stringify({ error: 'No Gemini Image provider configured. Please add a provider with type "gemini-image" in Settings.' }),
48-
{ status: 400, headers: { 'Content-Type': 'application/json' } }
49-
);
50-
}
51-
52-
const requestedModel = body.model || 'gemini-3-pro-image-preview';
53-
const aspectRatio = (body.aspectRatio || '1:1') as `${number}:${number}`;
54-
const imageSize = body.imageSize || '1K';
55-
56-
const google = createGoogleGenerativeAI({ apiKey: provider.api_key });
57-
58-
// Build prompt: plain string or { text, images } for reference images
59-
// Support both base64 referenceImages and on-disk referenceImagePaths
60-
let refImageData: string[] = [];
61-
if (body.referenceImages && body.referenceImages.length > 0) {
62-
refImageData = body.referenceImages.map(img => img.data);
63-
} else if (body.referenceImagePaths && body.referenceImagePaths.length > 0) {
64-
for (const filePath of body.referenceImagePaths) {
65-
if (fs.existsSync(filePath)) {
66-
const buf = fs.readFileSync(filePath);
67-
refImageData.push(buf.toString('base64'));
68-
}
69-
}
70-
}
71-
const prompt = refImageData.length > 0
72-
? { text: body.prompt, images: refImageData }
73-
: body.prompt;
74-
75-
const { images } = await generateImage({
76-
model: google.image(requestedModel),
77-
prompt,
78-
providerOptions: {
79-
google: {
80-
imageConfig: { aspectRatio, imageSize },
81-
},
82-
},
83-
maxRetries: 3,
84-
abortSignal: AbortSignal.timeout(120_000),
29+
const result = await generateSingleImage({
30+
prompt: body.prompt,
31+
model: body.model,
32+
aspectRatio: body.aspectRatio,
33+
imageSize: body.imageSize,
34+
referenceImages: body.referenceImages,
35+
referenceImagePaths: body.referenceImagePaths,
36+
sessionId: body.sessionId,
8537
});
8638

87-
const elapsed = Date.now() - startTime;
88-
console.log(`[media/generate] ${requestedModel} ${imageSize} completed in ${elapsed}ms`);
89-
90-
// Ensure media directory exists
91-
if (!fs.existsSync(MEDIA_DIR)) {
92-
fs.mkdirSync(MEDIA_DIR, { recursive: true });
93-
}
94-
95-
// Write images to disk
96-
const savedImages: Array<{ mimeType: string; localPath: string }> = [];
97-
98-
for (const img of images) {
99-
const ext = img.mediaType === 'image/jpeg' ? '.jpg'
100-
: img.mediaType === 'image/webp' ? '.webp'
101-
: '.png';
102-
const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
103-
const filePath = path.join(MEDIA_DIR, filename);
104-
105-
fs.writeFileSync(filePath, Buffer.from(img.uint8Array));
106-
107-
savedImages.push({
108-
mimeType: img.mediaType,
109-
localPath: filePath,
110-
});
111-
}
112-
113-
// Copy images to project directory if sessionId is provided
114-
if (body.sessionId) {
115-
try {
116-
const session = getSession(body.sessionId);
117-
if (session?.working_directory) {
118-
const projectImgDir = path.join(session.working_directory, '.codepilot-images');
119-
if (!fs.existsSync(projectImgDir)) {
120-
fs.mkdirSync(projectImgDir, { recursive: true });
121-
}
122-
for (const saved of savedImages) {
123-
const destPath = path.join(projectImgDir, path.basename(saved.localPath));
124-
fs.copyFileSync(saved.localPath, destPath);
125-
}
126-
console.log(`[media/generate] Copied ${savedImages.length} image(s) to ${projectImgDir}`);
127-
}
128-
} catch (copyErr) {
129-
console.warn('[media/generate] Failed to copy images to project directory:', copyErr);
130-
}
131-
}
132-
133-
// Save reference images to disk for gallery display
134-
const savedRefImages: Array<{ mimeType: string; localPath: string }> = [];
135-
if (refImageData.length > 0) {
136-
const refMimeTypes = body.referenceImages
137-
? body.referenceImages.map(img => img.mimeType)
138-
: body.referenceImagePaths
139-
? body.referenceImagePaths.map(() => 'image/png')
140-
: [];
141-
for (let i = 0; i < refImageData.length; i++) {
142-
const mime = refMimeTypes[i] || 'image/png';
143-
const ext = mime === 'image/jpeg' ? '.jpg' : mime === 'image/webp' ? '.webp' : '.png';
144-
const filename = `ref-${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
145-
const filePath = path.join(MEDIA_DIR, filename);
146-
fs.writeFileSync(filePath, Buffer.from(refImageData[i], 'base64'));
147-
savedRefImages.push({ mimeType: mime, localPath: filePath });
148-
}
149-
}
150-
151-
// DB record
152-
const id = crypto.randomBytes(16).toString('hex');
153-
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
154-
const localPath = savedImages.length > 0 ? savedImages[0].localPath : '';
155-
156-
const metadata: Record<string, unknown> = {
157-
imageCount: savedImages.length,
158-
elapsedMs: elapsed,
159-
model: requestedModel,
160-
};
161-
if (savedRefImages.length > 0) {
162-
metadata.referenceImages = savedRefImages;
163-
}
164-
165-
db.prepare(
166-
`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)
167-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
168-
).run(
169-
id, 'image', 'completed', 'gemini', requestedModel, body.prompt,
170-
aspectRatio, imageSize, localPath, '',
171-
body.sessionId || null, null,
172-
'[]', JSON.stringify(metadata),
173-
null, now, now
174-
);
175-
17639
return new Response(
17740
JSON.stringify({
178-
id,
41+
id: result.mediaGenerationId,
17942
text: '',
180-
images: savedImages,
181-
model: requestedModel,
182-
imageSize,
183-
elapsedMs: elapsed,
43+
images: result.images,
44+
model: body.model || 'gemini-3.1-flash-image-preview',
45+
imageSize: body.imageSize || '1K',
46+
elapsedMs: result.elapsedMs,
18447
}),
18548
{ status: 200, headers: { 'Content-Type': 'application/json' } }
18649
);
18750
} catch (error) {
188-
const elapsed = Date.now() - startTime;
189-
console.error(`[media/generate] Failed after ${elapsed}ms:`, error);
51+
console.error('[media/generate] Failed:', error);
19052

19153
if (NoImageGeneratedError.isInstance(error)) {
19254
return new Response(
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { NextRequest } from 'next/server';
2+
import { getMediaJob } from '@/lib/db';
3+
import { cancelJob } from '@/lib/job-executor';
4+
5+
export const runtime = 'nodejs';
6+
export const dynamic = 'force-dynamic';
7+
8+
/**
9+
* POST /api/media/jobs/:id/cancel — Cancel a running or paused job
10+
*/
11+
export async function POST(
12+
_request: NextRequest,
13+
{ params }: { params: Promise<{ id: string }> }
14+
) {
15+
try {
16+
const { id } = await params;
17+
const job = getMediaJob(id);
18+
if (!job) {
19+
return Response.json({ error: 'Job not found' }, { status: 404 });
20+
}
21+
22+
if (job.status !== 'running' && job.status !== 'paused' && job.status !== 'planned') {
23+
return Response.json(
24+
{ error: `Cannot cancel job with status "${job.status}".` },
25+
{ status: 409 }
26+
);
27+
}
28+
29+
cancelJob(id);
30+
return Response.json({ success: true, jobId: id, status: 'cancelled' });
31+
} catch (error) {
32+
console.error('[api/media/jobs/[id]/cancel] POST failed:', error);
33+
return Response.json(
34+
{ error: error instanceof Error ? error.message : 'Failed to cancel job' },
35+
{ status: 500 }
36+
);
37+
}
38+
}

0 commit comments

Comments
 (0)