Compare commits

...

2 Commits

Author SHA1 Message Date
Jeff Emmett 19ff896594 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>
2025-12-18 21:35:20 -05:00
Jeff Emmett 46cc0485a2 feat: add PWA support for offline cold load
- Add vite-plugin-pwa with Workbox caching strategy
- Cache all static assets (JS, CSS, HTML, fonts, WASM)
- Enable service worker in dev mode for testing
- Add PWA manifest with app name and icons
- Add SVG icons for PWA (192x192 and 512x512)
- Increase cache limit to 10MB for large chunks (Board ~8MB)
- Add runtime caching for API responses and Google Fonts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:34:05 -05:00
10 changed files with 4331 additions and 15 deletions

View File

@ -5,6 +5,7 @@
<title>Jeff Emmett</title>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🍄</text></svg>" />
<link rel="apple-touch-icon" href="/pwa-192x192.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Permissions-Policy" content="midi=*, microphone=*, camera=*, autoplay=*">
<link rel="preconnect" href="https://fonts.googleapis.com" />

3979
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -115,6 +115,7 @@
"playwright": "^1.57.0",
"typescript": "^5.6.3",
"vite": "^6.0.3",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^3.2.4",

View File

@ -7,10 +7,21 @@ import * as crypto from './crypto';
// Get the worker API URL based on environment
function getApiUrl(): string {
// In development, use the local worker
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
return 'http://localhost:5172';
const hostname = window.location.hostname;
// 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
return 'https://jeffemmett-canvas.jeffemmett.workers.dev';
}

View File

@ -21,6 +21,7 @@ export interface ClientConfig {
runpodTextEndpointId?: string
runpodWhisperEndpointId?: 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,
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,
geminiApiKey: import.meta.env.VITE_GEMINI_API_KEY || import.meta.env.NEXT_PUBLIC_GEMINI_API_KEY,
}
} else {
// 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,
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,
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
}
}
/**
* 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
}
}

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
<rect width="192" height="192" fill="#1a1a2e" rx="24"/>
<text x="96" y="130" font-size="120" text-anchor="middle" font-family="system-ui">🍄</text>
</svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" fill="#1a1a2e" rx="64"/>
<text x="256" y="350" font-size="320" text-anchor="middle" font-family="system-ui">🍄</text>
</svg>

After

Width:  |  Height:  |  Size: 250 B

View File

@ -11,6 +11,7 @@ import React, { useState, useRef, useEffect } from "react"
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
import { usePinnedToView } from "@/hooks/usePinnedToView"
import { useMaximize } from "@/hooks/useMaximize"
import { getGeminiConfig } from "@/lib/clientConfig"
// ============================================================================
// 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> {
// In actual implementation, this would call the Gemini MCP server
// For now, return a placeholder
console.log(`Generating page ${pageNumber} with prompt:`, prompt.substring(0, 100) + '...')
console.log(`🍄 Generating page ${pageNumber} with Gemini Nano Banana Pro...`)
console.log(`📝 Prompt preview:`, prompt.substring(0, 100) + '...')
// Simulate generation delay
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 1000))
const geminiConfig = getGeminiConfig()
// Return placeholder image
return `https://via.placeholder.com/825x1275/1a1a1a/00ff00?text=Page+${pageNumber}`
if (!geminiConfig) {
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(

View File

@ -2,6 +2,7 @@ import { defineConfig, loadEnv } from "vite"
import react from "@vitejs/plugin-react"
import wasm from "vite-plugin-wasm"
import topLevelAwait from "vite-plugin-top-level-await"
import { VitePWA } from "vite-plugin-pwa"
export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory.
@ -25,17 +26,103 @@ export default defineConfig(({ mode }) => {
return {
envPrefix: ["VITE_"],
plugins: [react(), wasm(), topLevelAwait()],
plugins: [
react(),
wasm(),
topLevelAwait(),
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'auto',
workbox: {
// Cache all static assets
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,wasm}'],
// Increase the limit for large chunks (Board is ~8MB with tldraw, automerge, etc.)
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
// Runtime caching for dynamic requests
runtimeCaching: [
{
// Cache API responses with network-first strategy
urlPattern: /^https?:\/\/.*\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24, // 24 hours
},
networkTimeoutSeconds: 10,
},
},
{
// Cache fonts
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
},
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
},
},
},
],
},
manifest: {
name: 'Jeff Emmett Canvas',
short_name: 'Canvas',
description: 'Collaborative canvas for research and creativity',
theme_color: '#1a1a2e',
background_color: '#1a1a2e',
display: 'standalone',
start_url: '/',
icons: [
{
src: '/pwa-192x192.svg',
sizes: '192x192',
type: 'image/svg+xml',
},
{
src: '/pwa-512x512.svg',
sizes: '512x512',
type: 'image/svg+xml',
},
{
src: '/pwa-512x512.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'maskable',
},
],
},
devOptions: {
// Enable SW in development for testing
enabled: true,
type: 'module',
},
}),
],
server: {
host: "0.0.0.0",
port: 5173,
strictPort: true,
// Force IPv4 to ensure compatibility with WSL2 and remote devices
listen: "0.0.0.0",
// Configure HMR to use the correct hostname for WebSocket connections
// Configure HMR to use the client's hostname for WebSocket connections
// This allows HMR to work from any network (localhost, LAN, Tailscale)
hmr: {
host: wslIp,
port: 5173,
// Use 'clientPort' to let client determine the correct host
clientPort: 5173,
},
// Proxy API requests to the worker server
proxy: {

View File

@ -106,9 +106,10 @@ const { preflight, corsify } = cors({
}
// 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 (
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