feat: Gemini image generation for MycroZine + Tailscale dev support
MycroZine Generator: - Implement actual Gemini image generation (replaces placeholders) - Use Nano Banana Pro (gemini-2.0-flash-exp-image-generation) as primary - Fallback to Gemini 2.0 Flash experimental - Graceful degradation to placeholder if no API key Client Config: - Add geminiApiKey to ClientConfig interface - Add isGeminiConfigured() and getGeminiConfig() functions - Support user-specific API keys from localStorage Local Development: - Fix CORS to allow Tailscale IPs (100.x) and all private ranges - Update cryptidEmailService to use same host for worker URL on local IPs - Supports localhost, LAN (192.168.x, 10.x, 172.16-31.x), and Tailscale 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
525ea694b5
commit
8cf0bad804
|
|
@ -7,10 +7,21 @@ import * as crypto from './crypto';
|
||||||
|
|
||||||
// Get the worker API URL based on environment
|
// Get the worker API URL based on environment
|
||||||
function getApiUrl(): string {
|
function getApiUrl(): string {
|
||||||
// In development, use the local worker
|
const hostname = window.location.hostname;
|
||||||
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
|
||||||
return 'http://localhost:5172';
|
// In development (localhost, local IPs, Tailscale IPs), use the local worker on same host
|
||||||
|
const isLocalDev =
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname === '127.0.0.1' ||
|
||||||
|
hostname.startsWith('192.168.') ||
|
||||||
|
hostname.startsWith('10.') ||
|
||||||
|
hostname.startsWith('172.') ||
|
||||||
|
hostname.startsWith('100.'); // Tailscale
|
||||||
|
|
||||||
|
if (isLocalDev) {
|
||||||
|
return `http://${hostname}:5172`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, use the deployed worker
|
// In production, use the deployed worker
|
||||||
return 'https://jeffemmett-canvas.jeffemmett.workers.dev';
|
return 'https://jeffemmett-canvas.jeffemmett.workers.dev';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export interface ClientConfig {
|
||||||
runpodTextEndpointId?: string
|
runpodTextEndpointId?: string
|
||||||
runpodWhisperEndpointId?: string
|
runpodWhisperEndpointId?: string
|
||||||
ollamaUrl?: string
|
ollamaUrl?: string
|
||||||
|
geminiApiKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,6 +53,7 @@ export function getClientConfig(): ClientConfig {
|
||||||
runpodTextEndpointId: import.meta.env.VITE_RUNPOD_TEXT_ENDPOINT_ID || import.meta.env.NEXT_PUBLIC_RUNPOD_TEXT_ENDPOINT_ID,
|
runpodTextEndpointId: import.meta.env.VITE_RUNPOD_TEXT_ENDPOINT_ID || import.meta.env.NEXT_PUBLIC_RUNPOD_TEXT_ENDPOINT_ID,
|
||||||
runpodWhisperEndpointId: import.meta.env.VITE_RUNPOD_WHISPER_ENDPOINT_ID || import.meta.env.NEXT_PUBLIC_RUNPOD_WHISPER_ENDPOINT_ID,
|
runpodWhisperEndpointId: import.meta.env.VITE_RUNPOD_WHISPER_ENDPOINT_ID || import.meta.env.NEXT_PUBLIC_RUNPOD_WHISPER_ENDPOINT_ID,
|
||||||
ollamaUrl: import.meta.env.VITE_OLLAMA_URL || import.meta.env.NEXT_PUBLIC_OLLAMA_URL,
|
ollamaUrl: import.meta.env.VITE_OLLAMA_URL || import.meta.env.NEXT_PUBLIC_OLLAMA_URL,
|
||||||
|
geminiApiKey: import.meta.env.VITE_GEMINI_API_KEY || import.meta.env.NEXT_PUBLIC_GEMINI_API_KEY,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Next.js environment
|
// Next.js environment
|
||||||
|
|
@ -89,6 +91,7 @@ export function getClientConfig(): ClientConfig {
|
||||||
runpodTextEndpointId: process.env.VITE_RUNPOD_TEXT_ENDPOINT_ID || process.env.NEXT_PUBLIC_RUNPOD_TEXT_ENDPOINT_ID,
|
runpodTextEndpointId: process.env.VITE_RUNPOD_TEXT_ENDPOINT_ID || process.env.NEXT_PUBLIC_RUNPOD_TEXT_ENDPOINT_ID,
|
||||||
runpodWhisperEndpointId: process.env.VITE_RUNPOD_WHISPER_ENDPOINT_ID || process.env.NEXT_PUBLIC_RUNPOD_WHISPER_ENDPOINT_ID,
|
runpodWhisperEndpointId: process.env.VITE_RUNPOD_WHISPER_ENDPOINT_ID || process.env.NEXT_PUBLIC_RUNPOD_WHISPER_ENDPOINT_ID,
|
||||||
ollamaUrl: process.env.VITE_OLLAMA_URL || process.env.NEXT_PUBLIC_OLLAMA_URL,
|
ollamaUrl: process.env.VITE_OLLAMA_URL || process.env.NEXT_PUBLIC_OLLAMA_URL,
|
||||||
|
geminiApiKey: process.env.VITE_GEMINI_API_KEY || process.env.NEXT_PUBLIC_GEMINI_API_KEY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -328,3 +331,88 @@ export function getOpenAIConfig(): { apiKey: string } | null {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Gemini integration is configured
|
||||||
|
* Reads from user profile settings (localStorage) or environment variables
|
||||||
|
*/
|
||||||
|
export function isGeminiConfigured(): boolean {
|
||||||
|
try {
|
||||||
|
// First try to get user-specific API keys if available
|
||||||
|
const session = JSON.parse(localStorage.getItem('session') || '{}')
|
||||||
|
if (session.authed && session.username) {
|
||||||
|
const userApiKeys = localStorage.getItem(`${session.username}_api_keys`)
|
||||||
|
if (userApiKeys) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(userApiKeys)
|
||||||
|
if (parsed.keys && parsed.keys.gemini && parsed.keys.gemini.trim() !== '') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Continue to fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to global API keys
|
||||||
|
const settings = localStorage.getItem("gemini_api_key")
|
||||||
|
if (settings && settings.trim() !== '') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check environment variable
|
||||||
|
const config = getClientConfig()
|
||||||
|
if (config.geminiApiKey && config.geminiApiKey.trim() !== '') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Gemini API key for API calls
|
||||||
|
* Reads from user profile settings (localStorage) or environment variables
|
||||||
|
*/
|
||||||
|
export function getGeminiConfig(): { apiKey: string } | null {
|
||||||
|
try {
|
||||||
|
// First try to get user-specific API keys if available
|
||||||
|
const session = JSON.parse(localStorage.getItem('session') || '{}')
|
||||||
|
if (session.authed && session.username) {
|
||||||
|
const userApiKeys = localStorage.getItem(`${session.username}_api_keys`)
|
||||||
|
if (userApiKeys) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(userApiKeys)
|
||||||
|
if (parsed.keys && parsed.keys.gemini && parsed.keys.gemini.trim() !== '') {
|
||||||
|
console.log('🔑 Found user-specific Gemini API key')
|
||||||
|
return { apiKey: parsed.keys.gemini }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('🔑 Error parsing user-specific API keys:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to global API keys in localStorage
|
||||||
|
const settings = localStorage.getItem("gemini_api_key")
|
||||||
|
if (settings && settings.trim() !== '') {
|
||||||
|
console.log('🔑 Found global Gemini API key in localStorage')
|
||||||
|
return { apiKey: settings }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to environment variable
|
||||||
|
const config = getClientConfig()
|
||||||
|
if (config.geminiApiKey && config.geminiApiKey.trim() !== '') {
|
||||||
|
console.log('🔑 Found Gemini API key in environment')
|
||||||
|
return { apiKey: config.geminiApiKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔑 No Gemini API key found')
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
console.log('🔑 Error getting Gemini config:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import React, { useState, useRef, useEffect } from "react"
|
||||||
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
|
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
|
||||||
import { usePinnedToView } from "@/hooks/usePinnedToView"
|
import { usePinnedToView } from "@/hooks/usePinnedToView"
|
||||||
import { useMaximize } from "@/hooks/useMaximize"
|
import { useMaximize } from "@/hooks/useMaximize"
|
||||||
|
import { getGeminiConfig } from "@/lib/clientConfig"
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -1059,15 +1060,154 @@ High contrast black and white with neon green accent highlights. Xerox texture,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generatePageImage(prompt: string, pageNumber: number): Promise<string> {
|
async function generatePageImage(prompt: string, pageNumber: number): Promise<string> {
|
||||||
// In actual implementation, this would call the Gemini MCP server
|
console.log(`🍄 Generating page ${pageNumber} with Gemini Nano Banana Pro...`)
|
||||||
// For now, return a placeholder
|
console.log(`📝 Prompt preview:`, prompt.substring(0, 100) + '...')
|
||||||
console.log(`Generating page ${pageNumber} with prompt:`, prompt.substring(0, 100) + '...')
|
|
||||||
|
|
||||||
// Simulate generation delay
|
const geminiConfig = getGeminiConfig()
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 1000))
|
|
||||||
|
|
||||||
// Return placeholder image
|
if (!geminiConfig) {
|
||||||
return `https://via.placeholder.com/825x1275/1a1a1a/00ff00?text=Page+${pageNumber}`
|
console.warn('⚠️ No Gemini API key configured, using placeholder')
|
||||||
|
return `https://via.placeholder.com/825x1275/1a1a1a/00ff00?text=Page+${pageNumber}+%28No+API+Key%29`
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKey } = geminiConfig
|
||||||
|
|
||||||
|
// Try Nano Banana Pro first (gemini-2.0-flash-exp-image-generation)
|
||||||
|
try {
|
||||||
|
const imageUrl = await generateWithNanoBananaPro(prompt, apiKey)
|
||||||
|
if (imageUrl) {
|
||||||
|
console.log(`✅ Page ${pageNumber} generated with Nano Banana Pro`)
|
||||||
|
return imageUrl
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Nano Banana Pro failed for page ${pageNumber}:`, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Gemini 2.0 Flash experimental
|
||||||
|
try {
|
||||||
|
const imageUrl = await generateWithGemini2Flash(prompt, apiKey)
|
||||||
|
if (imageUrl) {
|
||||||
|
console.log(`✅ Page ${pageNumber} generated with Gemini 2.0 Flash`)
|
||||||
|
return imageUrl
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Gemini 2.0 Flash failed for page ${pageNumber}:`, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: placeholder
|
||||||
|
console.warn(`⚠️ All generation methods failed for page ${pageNumber}, using placeholder`)
|
||||||
|
return `https://via.placeholder.com/825x1275/1a1a1a/00ff00?text=Page+${pageNumber}+%28Generation+Failed%29`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types for Gemini API response
|
||||||
|
interface GeminiPart {
|
||||||
|
text?: string
|
||||||
|
inlineData?: {
|
||||||
|
mimeType: string
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeminiCandidate {
|
||||||
|
content?: {
|
||||||
|
parts?: GeminiPart[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeminiResponse {
|
||||||
|
candidates?: GeminiCandidate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nano Banana Pro - highest quality, excellent text rendering
|
||||||
|
async function generateWithNanoBananaPro(prompt: string, apiKey: string): Promise<string | null> {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp-image-generation:generateContent?key=${apiKey}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
text: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ['IMAGE'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error('Nano Banana Pro API error:', response.status, errorText)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: GeminiResponse = await response.json()
|
||||||
|
|
||||||
|
// Extract image from response
|
||||||
|
const parts = data.candidates?.[0]?.content?.parts || []
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.inlineData?.mimeType?.startsWith('image/')) {
|
||||||
|
// Convert base64 to data URL
|
||||||
|
return `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini 2.0 Flash experimental fallback
|
||||||
|
async function generateWithGemini2Flash(prompt: string, apiKey: string): Promise<string | null> {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
text: `Generate an image: ${prompt}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generationConfig: {
|
||||||
|
responseModalities: ['IMAGE', 'TEXT'],
|
||||||
|
responseMimeType: 'image/png',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error('Gemini 2.0 Flash error:', response.status, errorText)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: GeminiResponse = await response.json()
|
||||||
|
|
||||||
|
// Extract image from response
|
||||||
|
const parts = data.candidates?.[0]?.content?.parts || []
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.inlineData?.mimeType?.startsWith('image/')) {
|
||||||
|
return `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function spawnPageOnCanvas(
|
async function spawnPageOnCanvas(
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,10 @@ const { preflight, corsify } = cors({
|
||||||
}
|
}
|
||||||
|
|
||||||
// For development - check if it's a localhost or local IP (both http and https)
|
// For development - check if it's a localhost or local IP (both http and https)
|
||||||
|
// Includes: localhost, 127.x, 192.168.x, 169.254.x, 10.x, 172.16-31.x (private), 100.x (Tailscale)
|
||||||
if (
|
if (
|
||||||
origin.match(
|
origin.match(
|
||||||
/^https?:\/\/(localhost|127\.0\.0\.1|192\.168\.|169\.254\.|10\.)/,
|
/^https?:\/\/(localhost|127\.\d+\.\d+\.\d+|192\.168\.|169\.254\.|10\.|172\.(1[6-9]|2\d|3[01])\.|100\.)/,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return origin
|
return origin
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue