Gallery UI overhaul: - GalleryGrid: replace equal-size grid with CSS columns masonry layout (6 columns), pure image cards without text/date info, hover gray ring highlight, favorited items show red heart badge - GalleryDetail: left-right layout dialog (70% image / 30% info), nearly full-screen size with minimal margins, black background for image area, absolute-positioned image container for correct sizing across all aspect ratios, favorite toggle button replaces tag manager, removed outer border - Gallery page: added "favorites only" filter button, replaced load-more button with IntersectionObserver infinite scroll, removed tag system references Favorites system: - db.ts: added `favorited` column to media_generations with migration - New API: PUT /api/media/[id]/favorite for toggle - gallery/route.ts: support favoritesOnly=1 filter, return favorited field - i18n: added favorites-related translations (en/zh) Other: - Default image model changed from Nano Banana Pro to Nano Banana 2 - providers/models/route.ts: skip media provider types in chat model selector Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
180 lines
6.6 KiB
TypeScript
180 lines
6.6 KiB
TypeScript
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 };
|