/**
* Imagen - Internal AI Image Generation Tool
* Supports multiple models via OpenRouter API
*/
// ===== IndexedDB Storage =====
const ImagenDB = {
dbName: 'ImagenDB',
storeName: 'images',
db: null,
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
};
});
},
async saveImage(imageData) {
await this.ensureOpen();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.put(imageData);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},
async getAllImages() {
await this.ensureOpen();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onsuccess = () => {
// Sort by createdAt descending (newest first)
const images = request.result.sort((a, b) =>
new Date(b.createdAt) - new Date(a.createdAt)
);
resolve(images);
};
request.onerror = () => reject(request.error);
});
},
async deleteImage(id) {
await this.ensureOpen();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
},
async clearAll() {
await this.ensureOpen();
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
},
async ensureOpen() {
if (!this.db) {
await this.open();
}
}
};
// ===== State Management =====
const state = {
apiKey: localStorage.getItem('imagen_api_key') || '',
selectedModel: localStorage.getItem('imagen_model') || 'google/gemini-2.5-flash-image',
imageSize: localStorage.getItem('imagen_size') || '1024x1024',
imageQuality: localStorage.getItem('imagen_quality') || '1K',
aspectRatio: localStorage.getItem('imagen_aspect_ratio') || '1:1',
imageCount: 1,
references: [], // Dynamic array - unlimited references
images: [], // Will be loaded from IndexedDB
currentImage: null,
isGenerating: false
};
// ===== Model Configurations =====
const MODEL_CONFIGS = {
'google/gemini-2.5-flash-image': {
name: 'Gemini 2.5 Flash Image',
supportsImageSize: true,
supportsAspectRatio: true,
supportsImageInput: true,
maxReferences: 3
},
'google/gemini-2.5-flash-image-preview': {
name: 'Gemini 2.5 Flash Image (Preview)',
supportsImageSize: true,
supportsAspectRatio: true,
supportsImageInput: true,
maxReferences: 3
},
'google/gemini-3-pro-image-preview': {
name: 'Gemini 3 Pro Image (Preview)',
supportsImageSize: true,
supportsAspectRatio: true,
supportsImageInput: true,
maxReferences: 14
},
'openai/gpt-5-image': {
name: 'GPT-5 Image',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: true,
maxReferences: 1
},
'openai/gpt-5-image-mini': {
name: 'GPT-5 Image Mini',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: true,
maxReferences: 1
},
'black-forest-labs/flux.2-pro': {
name: 'Flux 2 Pro',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'black-forest-labs/flux.2-max': {
name: 'Flux 2 Max',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'black-forest-labs/flux.2-flex': {
name: 'Flux 2 Flex',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'black-forest-labs/flux.2-klein-4b': {
name: 'Flux 2 Klein 4B',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'bytedance-seed/seedream-4.5': {
name: 'Seedream 4.5',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'sourceful/riverflow-v2-fast-preview': {
name: 'Riverflow V2 Fast',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'sourceful/riverflow-v2-standard-preview': {
name: 'Riverflow V2 Standard',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
},
'sourceful/riverflow-v2-max-preview': {
name: 'Riverflow V2 Max',
supportsImageSize: false,
supportsAspectRatio: true,
supportsImageInput: false,
maxReferences: 0
}
};
// ===== DOM Elements =====
const elements = {
// Sidebar
modelSelectContainer: document.getElementById('modelSelectContainer'),
modelSelectTrigger: document.getElementById('modelSelectTrigger'),
modelSelectValue: document.getElementById('modelSelectValue'),
modelSelectOptions: document.getElementById('modelSelectOptions'),
geminiOptions: document.getElementById('geminiOptions'),
apiKey: document.getElementById('apiKey'),
saveApiKey: document.getElementById('saveApiKey'),
imageCount: document.getElementById('imageCount'),
decreaseCount: document.getElementById('decreaseCount'),
increaseCount: document.getElementById('increaseCount'),
clearReferences: document.getElementById('clearReferences'),
referenceSlots: document.getElementById('referenceSlots'),
// Main Content
promptInput: document.getElementById('promptInput'),
charCount: document.getElementById('charCount'),
generateBtn: document.getElementById('generateBtn'),
loadingContainer: document.getElementById('loadingContainer'),
gallery: document.getElementById('gallery'),
galleryEmpty: document.getElementById('galleryEmpty'),
clearGallery: document.getElementById('clearGallery'),
// Modal
imageModal: document.getElementById('imageModal'),
modalOverlay: document.getElementById('modalOverlay'),
modalClose: document.getElementById('modalClose'),
modalImage: document.getElementById('modalImage'),
modalMetadata: document.getElementById('modalMetadata'),
useAsReference: document.getElementById('useAsReference'),
recreateImage: document.getElementById('recreateImage'),
downloadImage: document.getElementById('downloadImage')
};
// ===== Initialization =====
async function init() {
// Load saved API key
if (state.apiKey) {
elements.apiKey.value = state.apiKey;
}
// Render reference slots
renderReferenceSlots();
// Restore saved model selection
if (state.selectedModel) {
const savedOption = document.querySelector(`.custom-select-option[data-value="${state.selectedModel}"]`);
if (savedOption) {
document.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('selected'));
savedOption.classList.add('selected');
elements.modelSelectValue.textContent = savedOption.textContent;
}
}
// Restore saved image quality/size
document.querySelectorAll('.btn-toggle').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.quality === state.imageQuality) {
btn.classList.add('active');
}
});
// Restore saved aspect ratio
document.querySelectorAll('.btn-aspect').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.ratio === state.aspectRatio) {
btn.classList.add('active');
}
});
// Load images from IndexedDB
try {
state.images = await ImagenDB.getAllImages();
} catch (error) {
console.error('Failed to load images from IndexedDB:', error);
state.images = [];
}
// Render gallery
renderGallery();
// Set up event listeners
setupEventListeners();
// Initialize UI state
updateGeminiOptionsVisibility();
}
// ===== Event Listeners =====
function setupEventListeners() {
// Custom dropdown - toggle
elements.modelSelectTrigger.addEventListener('click', () => {
elements.modelSelectContainer.classList.toggle('open');
});
// Custom dropdown - option selection
document.querySelectorAll('.custom-select-option').forEach(option => {
option.addEventListener('click', () => {
state.selectedModel = option.dataset.value;
localStorage.setItem('imagen_model', state.selectedModel);
elements.modelSelectValue.textContent = option.textContent;
document.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('selected'));
option.classList.add('selected');
elements.modelSelectContainer.classList.remove('open');
updateGeminiOptionsVisibility();
});
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!elements.modelSelectContainer.contains(e.target)) {
elements.modelSelectContainer.classList.remove('open');
}
});
// Size toggle buttons
document.querySelectorAll('.btn-toggle').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.btn-toggle').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.imageSize = btn.dataset.size;
state.imageQuality = btn.dataset.quality;
localStorage.setItem('imagen_size', state.imageSize);
localStorage.setItem('imagen_quality', state.imageQuality);
});
});
// Aspect ratio buttons
document.querySelectorAll('.btn-aspect').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.btn-aspect').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.aspectRatio = btn.dataset.ratio;
localStorage.setItem('imagen_aspect_ratio', state.aspectRatio);
});
});
// Image count
if (elements.decreaseCount) {
elements.decreaseCount.addEventListener('click', () => {
if (state.imageCount > 1) {
state.imageCount--;
elements.imageCount.value = state.imageCount;
}
});
}
if (elements.increaseCount) {
elements.increaseCount.addEventListener('click', () => {
if (state.imageCount < 8) {
state.imageCount++;
elements.imageCount.value = state.imageCount;
}
});
}
if (elements.imageCount) {
elements.imageCount.addEventListener('change', (e) => {
let val = parseInt(e.target.value);
if (isNaN(val) || val < 1) val = 1;
if (val > 8) val = 8;
state.imageCount = val;
elements.imageCount.value = val;
});
}
// API Key
elements.saveApiKey.addEventListener('click', () => {
state.apiKey = elements.apiKey.value.trim();
localStorage.setItem('imagen_api_key', state.apiKey);
showToast('API key saved!', 'success');
});
// Reference images are handled by renderReferenceSlots()
elements.clearReferences.addEventListener('click', clearAllReferences);
// Drag & Drop for reference images
setupDragAndDrop();
// Prompt input
elements.promptInput.addEventListener('input', () => {
elements.charCount.textContent = `${elements.promptInput.value.length} chars`;
});
// Generate button
elements.generateBtn.addEventListener('click', generateImages);
// Clear gallery
elements.clearGallery.addEventListener('click', async () => {
if (confirm('Are you sure you want to clear all generated images?')) {
state.images = [];
try {
await ImagenDB.clearAll();
} catch (e) {
console.warn('Could not clear IndexedDB:', e);
}
renderGallery();
showToast('Gallery cleared', 'success');
}
});
// Modal
elements.modalOverlay.addEventListener('click', closeModal);
elements.modalClose.addEventListener('click', closeModal);
elements.useAsReference.addEventListener('click', useImageAsReference);
elements.recreateImage.addEventListener('click', recreateImage);
elements.downloadImage.addEventListener('click', downloadCurrentImage);
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
if (e.key === 'Enter' && e.ctrlKey) generateImages();
});
}
// ===== Drag & Drop =====
function setupDragAndDrop() {
const dropZone = elements.referenceSlots;
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.add('drag-over');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.remove('drag-over');
}, false);
});
dropZone.addEventListener('drop', handleDrop, false);
}
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
[...files].forEach(file => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (event) => {
state.references.push(event.target.result);
renderReferenceSlots();
};
reader.readAsDataURL(file);
}
});
if (files.length > 0) {
showToast(`${files.length} image(s) added as reference`, 'success');
}
}
// ===== Reference Image Handling =====
function handleReferenceUpload(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
state.references.push(event.target.result);
renderReferenceSlots();
};
reader.readAsDataURL(file);
// Reset the input so the same file can be selected again
e.target.value = '';
}
function renderReferenceSlots() {
const container = document.getElementById('referenceSlots');
container.innerHTML = '';
// Render existing references
state.references.forEach((ref, index) => {
const slot = document.createElement('div');
slot.className = 'reference-slot filled';
slot.dataset.slot = index;
slot.innerHTML = `
`;
container.appendChild(slot);
});
// Add "Add new" slot
const addSlot = document.createElement('div');
addSlot.className = 'reference-slot empty add-new';
addSlot.innerHTML = `
+ Add
`;
container.appendChild(addSlot);
// Attach event listeners
container.querySelectorAll('.remove-ref').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const index = parseInt(btn.dataset.index);
removeReference(index);
});
});
const addInput = container.querySelector('#addReferenceInput');
if (addInput) {
addInput.addEventListener('change', handleReferenceUpload);
}
}
function removeReference(index) {
state.references.splice(index, 1);
renderReferenceSlots();
}
function clearAllReferences() {
state.references = [];
renderReferenceSlots();
showToast('References cleared', 'success');
}
// ===== Image Generation =====
async function generateImages() {
const prompt = elements.promptInput.value.trim();
if (!prompt) {
showToast('Please enter a prompt', 'warning');
return;
}
if (!state.apiKey) {
showToast('Please enter your OpenRouter API key', 'error');
return;
}
state.isGenerating = true;
elements.generateBtn.disabled = true;
elements.loadingContainer.style.display = 'flex';
const modelConfig = MODEL_CONFIGS[state.selectedModel];
const currentReferences = state.references.length > 0 ? [...state.references] : [];
console.log('Generating with references:', currentReferences.length, currentReferences);
let successCount = 0;
let failCount = 0;
// Generate images and display each one as it completes
const generateAndDisplay = async (index) => {
try {
const result = await generateSingleImage(prompt, modelConfig);
if (result) {
const imageData = {
id: Date.now() + index,
url: result,
prompt: prompt,
model: state.selectedModel,
modelName: modelConfig.name,
size: state.imageSize,
quality: state.imageQuality,
aspectRatio: state.aspectRatio,
references: currentReferences,
createdAt: new Date().toISOString()
};
state.images.unshift(imageData);
renderGallery();
successCount++;
// Save to IndexedDB in background
ImagenDB.saveImage(imageData).catch(e => console.error('Failed to save to IndexedDB:', e));
}
} catch (error) {
console.error('Failed to generate image:', error);
failCount++;
}
};
// Start all generations in parallel, each will render when done
const promises = [];
for (let i = 0; i < state.imageCount; i++) {
promises.push(generateAndDisplay(i));
}
// Wait for all to complete to update final UI state
await Promise.allSettled(promises);
state.isGenerating = false;
elements.generateBtn.disabled = false;
elements.loadingContainer.style.display = 'none';
if (successCount > 0) {
showToast(`${successCount} image(s) generated!`, 'success');
} else {
showToast('Failed to generate images. Check console for details.', 'error');
}
}
async function generateSingleImage(prompt, modelConfig) {
// Build message content
const content = [];
// Add reference images if supported
if (modelConfig.supportsImageInput) {
state.references.forEach((ref, index) => {
if (ref) {
content.push({
type: 'image_url',
image_url: {
url: ref,
detail: 'high'
}
});
}
});
}
// Add text prompt
content.push({
type: 'text',
text: prompt
});
// Build request body
const requestBody = {
model: state.selectedModel,
messages: [
{
role: 'user',
content: content.length === 1 ? prompt : content
}
],
modalities: modelConfig.modalities
};
// Add Gemini-specific options
if (modelConfig.supportsImageSize && state.selectedModel.includes('gemini')) {
requestBody.image_config = {
image_size: state.imageQuality.toLowerCase(),
aspect_ratio: state.aspectRatio
};
}
// Add aspect ratio for other models
if (modelConfig.supportsAspectRatio && !state.selectedModel.includes('gemini')) {
requestBody.aspect_ratio = state.aspectRatio;
}
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${state.apiKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'Imagen Internal Tool'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error?.message || `API error: ${response.status}`);
}
const data = await response.json();
// Extract image from response
// OpenRouter returns images in different formats depending on the model
const message = data.choices?.[0]?.message;
if (!message) {
throw new Error('No response from model');
}
// Log full response for debugging
console.log('API Response:', JSON.stringify(data, null, 2));
// Check for images array in message (OpenRouter SDK format)
// According to OpenRouter docs: message.images[].image_url.url
if (message.images && message.images.length > 0) {
const img = message.images[0];
// OpenRouter SDK format: { image_url: { url: "data:image/..." } }
if (img.image_url?.url) {
return img.image_url.url;
}
// Alternative formats
if (typeof img === 'string') {
if (img.startsWith('data:') || img.startsWith('http')) {
return img;
}
return `data:image/png;base64,${img}`;
}
if (img.url) return img.url;
if (img.b64_json) return `data:image/png;base64,${img.b64_json}`;
}
// Check for image in content parts (different models may use this format)
if (Array.isArray(message.content)) {
for (const part of message.content) {
// OpenAI-style image_url part
if (part.type === 'image_url' && part.image_url?.url) {
return part.image_url.url;
}
// Gemini-style inlineData part
if (part.inlineData?.data) {
const mimeType = part.inlineData.mimeType || 'image/png';
return `data:${mimeType};base64,${part.inlineData.data}`;
}
// Generic image part
if (part.type === 'image' && part.image) {
if (part.image.startsWith('data:')) {
return part.image;
}
return `data:image/png;base64,${part.image}`;
}
}
}
// Check if content itself is the image data (some models return this way)
if (typeof message.content === 'string' && message.content.startsWith('data:image')) {
return message.content;
}
throw new Error('No image in response. Check console for full API response.');
}
// ===== Gallery =====
function renderGallery() {
if (state.images.length === 0) {
elements.galleryEmpty.style.display = 'flex';
elements.gallery.innerHTML = '';
elements.gallery.appendChild(elements.galleryEmpty);
return;
}
elements.gallery.innerHTML = '';
state.images.forEach((image, index) => {
const card = document.createElement('div');
card.className = 'image-card';
// Sanitize URL - only allow data URIs and https URLs
const safeUrl = sanitizeImageUrl(image.url);
const safePrompt = escapeHtml(image.prompt);
card.innerHTML = `
Prompt: ${escapeHtml(image.prompt)}
Model: ${escapeHtml(image.modelName || image.model)}
Size/Quality: ${escapeHtml(image.quality || image.size)}
Aspect Ratio: ${escapeHtml(image.aspectRatio)}
Created: ${escapeHtml(new Date(image.createdAt).toLocaleString())}
${image.references?.length > 0 ? `References Used: ${escapeHtml(image.references.length)}
` : ''} `; elements.imageModal.classList.add('active'); } function closeModal() { elements.imageModal.classList.remove('active'); state.currentImage = null; } function useImageAsReference() { if (!state.currentImage) return; state.references.push(state.currentImage.url); renderReferenceSlots(); closeModal(); showToast('Image added as reference', 'success'); } function recreateImage() { if (!state.currentImage) return; // Restore prompt elements.promptInput.value = state.currentImage.prompt; elements.charCount.textContent = `${state.currentImage.prompt.length} chars`; // Restore model using custom select state.selectedModel = state.currentImage.model; localStorage.setItem('imagen_model', state.selectedModel); const modelOption = document.querySelector(`.custom-select-option[data-value="${state.currentImage.model}"]`); if (modelOption) { document.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('selected')); modelOption.classList.add('selected'); elements.modelSelectValue.textContent = modelOption.textContent; } updateGeminiOptionsVisibility(); // Restore quality/size document.querySelectorAll('.btn-toggle').forEach(btn => { btn.classList.remove('active'); if (btn.dataset.quality === state.currentImage.quality) { btn.classList.add('active'); state.imageSize = btn.dataset.size; state.imageQuality = btn.dataset.quality; } }); // Restore aspect ratio document.querySelectorAll('.btn-aspect').forEach(btn => { btn.classList.remove('active'); if (btn.dataset.ratio === state.currentImage.aspectRatio) { btn.classList.add('active'); state.aspectRatio = state.currentImage.aspectRatio; } }); // Restore references (always update the UI, even if empty to clear previous refs) console.log('Recreate - image references:', state.currentImage.references); if (state.currentImage.references && state.currentImage.references.length > 0) { state.references = [...state.currentImage.references]; console.log('Restored references:', state.references.length); } else { state.references = []; console.log('No references to restore'); } renderReferenceSlots(); closeModal(); showToast('Settings restored. Click Generate to recreate.', 'success'); // Scroll to top window.scrollTo({ top: 0, behavior: 'smooth' }); } function downloadCurrentImage() { if (!state.currentImage) return; const link = document.createElement('a'); link.href = state.currentImage.url; link.download = `imagen_${state.currentImage.id}.png`; document.body.appendChild(link); link.click(); document.body.removeChild(link); showToast('Download started', 'success'); } // ===== UI Helpers ===== function updateGeminiOptionsVisibility() { const isGemini = state.selectedModel.includes('gemini'); elements.geminiOptions.style.display = isGemini ? 'flex' : 'none'; } function showToast(message, type = 'info') { let container = document.querySelector('.toast-container'); if (!container) { container = document.createElement('div'); container.className = 'toast-container'; document.body.appendChild(container); } const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; container.appendChild(toast); setTimeout(() => { toast.style.animation = 'slideIn 0.3s ease reverse'; setTimeout(() => toast.remove(), 300); }, 3000); } function escapeHtml(text) { if (text == null) return ''; const div = document.createElement('div'); div.textContent = String(text); return div.innerHTML; } function sanitizeImageUrl(url) { if (!url) return ''; // Only allow data URIs and HTTPS URLs if (url.startsWith('data:image/')) { return url; } if (url.startsWith('https://')) { // Escape any potential attribute-breaking characters return url.replace(/"/g, '%22').replace(/'/g, '%27'); } // Block everything else (http, javascript:, etc.) console.warn('Blocked unsafe image URL:', url); return ''; } // ===== Global functions for inline handlers ===== window.removeReference = removeReference; // ===== Initialize ===== document.addEventListener('DOMContentLoaded', init);