arthrod's picture
Update app.py
1468d43 verified
#!/usr/bin/env python3
"""Cicero Cola - Design Studio Screen Analysis Platform"""
import os
import asyncio
import tempfile
import base64
from pathlib import Path
from typing import Optional
from PIL import Image
from loguru import logger
import uvicorn
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic_ai import Agent
from pydantic_ai.models.gemini import GeminiModel
from pydantic_ai.messages import BinaryContent
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
def setup_environment():
"""Setup environment variables and configurations."""
hf_token = os.environ.get("HF_TOKEN")
gemini_key = os.environ.get("GEMINI_API_KEY")
if hf_token:
print(f"βœ… HF_TOKEN loaded...")
else:
print("⚠️ HF_TOKEN not found in environment")
if gemini_key:
print(f"βœ… GEMINI_API_KEY loaded...")
else:
print("⚠️ GEMINI_API_KEY not found in environment")
return hf_token, gemini_key
async def analyze_image_with_ai(image_data: bytes, question: str, api_key: str) -> str:
"""Analyze image using pydantic_ai with BinaryContent."""
try:
# Set the API key in environment for Gemini
os.environ['GEMINI_API_KEY'] = api_key
# Create agent
agent = Agent('gemini-2.0-flash-exp')
logger.info(f"Agent created with Gemini model")
# Create binary content for the image
image_content = BinaryContent(
data=image_data,
media_type='image/png'
)
# Run the agent with image and question
result = await agent.run([question, image_content])
logger.info(f"Analysis completed successfully")
return result.data
except Exception as e:
logger.error(f"Analysis failed: {str(e)}")
return f"Analysis failed: {str(e)}"
# Initialize FastAPI app
app = FastAPI(title="Cicero Passa a Cola", description="Screen Analysis Platform")
# Configure CORS
origins = [
"http://localhost",
"https://localhost",
"http://localhost:7864",
"https://localhost:7864",
"http://127.0.0.1:7864",
"https://127.0.0.1:7864",
"http://localhost:8864",
"https://localhost:8864",
"http://127.0.0.1:8864",
"https://127.0.0.1:8864",
"https://cicero.im",
"http://cicero.im",
"https://huggingface.co",
"http://huggingface.co",
"https://*.huggingface.co",
"http://*.huggingface.co",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Setup environment
hf_token, gemini_key = setup_environment()
@app.get("/", response_class=HTMLResponse)
async def get_main_page():
"""Serve the main HTML page with Cicero Cola design studio interface."""
html_content = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cicero Passa a Cola</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
:root {{
--black: #000000;
--white: #ffffff;
--scarlet: #dc143c;
--gray-50: #fafafa;
--gray-100: #f5f5f5;
--gray-200: #e5e5e5;
--gray-300: #d4d4d4;
--gray-600: #525252;
--gray-800: #262626;
}}
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--white);
color: var(--black);
line-height: 1.6;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}}
.header {{
background: var(--black);
color: var(--white);
padding: 1rem 0;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
}}
.header::before {{
content: '';
position: absolute;
top: 0;
left: -50%;
width: 200%;
height: 100%;
background: linear-gradient(45deg, transparent, var(--scarlet), transparent);
opacity: 0.1;
animation: sweep 3s ease-in-out infinite;
}}
@keyframes sweep {{
0% {{ transform: translateX(-100%); }}
100% {{ transform: translateX(100%); }}
}}
.header-content {{
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}}
.brand {{
font-size: 2.8rem;
font-weight: 700;
letter-spacing: 0.1em;
position: relative;
z-index: 2;
font-family: 'Inter', sans-serif;
}}
.logo {{
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.02em;
position: relative;
z-index: 2;
flex: 1;
text-align: center;
font-family: 'Inter', sans-serif;
}}
.container {{
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
}}
.hero {{
padding: 2rem 0 1rem 0;
text-align: center;
background: var(--gray-50);
}}
.hero h1 {{
font-size: 2.5rem;
margin-bottom: 0;
font-weight: 600;
font-family: 'Inter', sans-serif;
}}
.main-controls {{
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
margin: 2rem auto;
max-width: 1000px;
min-height: 300px;
}}
.controls-panel {{
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: 16px;
padding: 2rem;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 1rem;
}}
.status-panel {{
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: 16px;
padding: 2rem;
display: flex;
flex-direction: column;
justify-content: center;
}}
.recording-controls {{
display: flex;
flex-direction: column;
gap: 1rem;
}}
.studio-grid {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
padding: 2rem 0;
}}
.studio-card {{
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: 16px;
padding: 2rem;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}}
.studio-card:hover {{
transform: translateY(-8px);
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
border-color: var(--scarlet);
}}
.studio-card::before {{
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--scarlet);
transform: scaleX(0);
transition: transform 0.3s ease;
}}
.studio-card:hover::before {{
transform: scaleX(1);
}}
.card-title {{
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--black);
font-family: 'Inter', sans-serif;
}}
.card-description {{
color: var(--gray-600);
margin-bottom: 2rem;
}}
.studio-recording-controls {{
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}}
.btn {{
padding: 1rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
width: 100%;
font-family: 'Inter', sans-serif;
}}
.btn-primary {{
background: var(--scarlet);
color: var(--white);
}}
.btn-primary:hover {{
background: #b91c3c;
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(220, 20, 60, 0.2);
}}
.btn-secondary {{
background: var(--black);
color: var(--white);
}}
.btn-secondary:hover {{
background: var(--gray-800);
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}}
.btn:disabled {{
opacity: 0.5;
cursor: not-allowed;
transform: none;
}}
.input-group {{
margin-bottom: 1.5rem;
}}
.input-label {{
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--black);
}}
.input-field {{
width: 100%;
padding: 0.75rem;
border: 1px solid var(--gray-300);
border-radius: 8px;
font-size: 0.95rem;
transition: border-color 0.3s ease;
background: var(--white);
}}
.input-field:focus {{
outline: none;
border-color: var(--scarlet);
box-shadow: 0 0 0 3px rgba(220, 20, 60, 0.1);
}}
.textarea {{
resize: vertical;
min-height: 100px;
}}
.status-display {{
padding: 1.5rem;
border-radius: 8px;
text-align: center;
font-weight: 500;
border: 1px solid var(--gray-200);
background: var(--gray-50);
font-family: 'Inter', sans-serif;
font-size: 1.1rem;
}}
.video-preview {{
width: 100%;
max-width: 100%;
border-radius: 12px;
background: var(--black);
margin-bottom: 2rem;
display: none;
}}
.upload-area {{
border: 2px dashed var(--gray-300);
border-radius: 12px;
padding: 2rem;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
margin-bottom: 2rem;
}}
.upload-area:hover {{
border-color: var(--scarlet);
background: var(--gray-50);
}}
.upload-area.dragover {{
border-color: var(--scarlet);
background: rgba(220, 20, 60, 0.05);
}}
.results-area {{
background: var(--gray-50);
border-radius: 12px;
padding: 2rem;
margin-top: 2rem;
border: 1px solid var(--gray-200);
min-height: 200px;
}}
.results-title {{
font-weight: 600;
margin-bottom: 1rem;
color: var(--black);
}}
.results-content {{
line-height: 1.7;
color: var(--gray-800);
font-family: 'Inter', sans-serif;
}}
.results-content h1, .results-content h2, .results-content h3,
.results-content h4, .results-content h5, .results-content h6 {{
font-family: 'Inter', sans-serif;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
color: var(--black);
}}
.results-content p {{
margin-bottom: 1rem;
font-family: 'Inter', sans-serif;
}}
.results-content ul, .results-content ol {{
margin-bottom: 1rem;
padding-left: 1.5rem;
font-family: 'Inter', sans-serif;
}}
.results-content li {{
margin-bottom: 0.5rem;
}}
.results-content strong {{
font-weight: 600;
color: var(--black);
}}
.results-content code {{
background: var(--gray-100);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace;
font-size: 0.9em;
}}
.results-content blockquote {{
border-left: 4px solid var(--scarlet);
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
color: var(--gray-600);
}}
.footer {{
background: var(--black);
color: var(--white);
text-align: center;
padding: 3rem 0;
margin-top: 4rem;
}}
.footer-content {{
opacity: 0.8;
}}
.accent {{
color: var(--scarlet);
}}
@media (max-width: 768px) {{
.logo {{
font-size: 2rem;
}}
.hero h1 {{
font-size: 2rem;
}}
.recording-controls {{
flex-direction: column;
}}
.btn {{
justify-content: center;
}}
}}
</style>
</head>
<body>
<header class="header">
<div class="container">
<div class="header-content">
<div class="brand">CICERO</div>
<div style="flex: 1;"></div> <!-- Spacer for balance -->
<div style="width: 80px;"></div> <!-- Spacer for balance -->
</div>
</div>
</header>
<section class="hero">
<div class="container">
<h1>Capture. Analyze. <span class="accent">Cole.</span></h1>
</div>
</section>
<main class="container">
<!-- Main Recording Controls -->
<div class="main-controls">
<div class="controls-panel">
<div class="recording-controls">
<button id="startBtn" class="btn btn-primary">
Start Recording
</button>
<button id="stopBtn" class="btn btn-secondary" disabled>
Stop Recording
</button>
<button id="snapshotBtn" class="btn btn-primary" disabled>
Capture & Analyze
</button>
</div>
</div>
<div class="status-panel">
<div id="status" class="status-display">Ready to start recording</div>
<!-- Results Area -->
<div class="results-area" id="resultsArea" style="display: none; margin-top: 2rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<div class="results-title">Analysis Results</div>
<button id="okBtn" class="btn btn-primary" style="padding: 0.5rem 1rem; width: auto;">OK</button>
</div>
<div class="results-content" id="resultsContent"></div>
</div>
</div>
</div>
<div class="studio-grid">
<!-- Live Recording Studio -->
<div class="studio-card">
<h2 class="card-title">Live Recording Studio</h2>
<p class="card-description">Capture your screen in real-time with professional-grade recording capabilities</p>
<video id="videoPreview" class="video-preview" autoplay muted playsinline></video>
</div>
<!-- AI Analysis Lab -->
<div class="studio-card">
<h2 class="card-title">AI Analysis Lab</h2>
<p class="card-description">Upload images for intelligent design and content analysis</p>
<div class="input-group">
<label class="input-label" for="apiKey">Gemini API Key</label>
<div style="display: flex; gap: 0.5rem;">
<input type="password" id="apiKey" class="input-field" placeholder="Enter your Gemini API key" value="{gemini_key or ''}" style="flex: 2; min-width: 300px;">
<button id="loadEnvBtn" class="btn btn-secondary" style="white-space: nowrap; width: 140px;">Load from Env</button>
</div>
</div>
<div class="input-group">
<label class="input-label" for="question">Analysis Question</label>
<textarea id="question" class="input-field textarea" placeholder="What would you like to analyze about this image?">Analyze this design from a professional perspective. What are the key visual elements, design principles, and potential improvements?</textarea>
</div>
<div class="upload-area" onclick="document.getElementById('imageUpload').click()">
<div>
<strong>Click to upload</strong> or drag and drop<br>
<small>PNG, JPG, GIF up to 10MB</small>
</div>
</div>
<input type="file" id="imageUpload" accept="image/*" style="display: none;">
<button id="analyzeBtn" class="btn btn-primary" style="width: 100%;">
Analyze Image
</button>
</div>
</div>
</main>
<footer class="footer">
<div class="container">
<div class="footer-content">
<p>Cicero Passa a Cola β€’ Powered by AI β€’ Built with ❀️</p>
</div>
</div>
</footer>
<script>
let mediaRecorder;
let stream;
let recordedChunks = [];
// DOM elements
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const snapshotBtn = document.getElementById('snapshotBtn');
const statusDiv = document.getElementById('status');
const videoPreview = document.getElementById('videoPreview');
const apiKeyInput = document.getElementById('apiKey');
const questionInput = document.getElementById('question');
const imageUpload = document.getElementById('imageUpload');
const analyzeBtn = document.getElementById('analyzeBtn');
const resultsArea = document.getElementById('resultsArea');
const resultsContent = document.getElementById('resultsContent');
const uploadArea = document.querySelector('.upload-area');
const loadEnvBtn = document.getElementById('loadEnvBtn');
const okBtn = document.getElementById('okBtn');
// Event listeners
startBtn.addEventListener('click', startRecording);
stopBtn.addEventListener('click', stopRecording);
snapshotBtn.addEventListener('click', captureSnapshot);
analyzeBtn.addEventListener('click', analyzeUploadedImage);
imageUpload.addEventListener('change', handleImageUpload);
loadEnvBtn.addEventListener('click', loadFromEnvironment);
okBtn.addEventListener('click', () => {{ resultsArea.style.display = 'none'; }});
// Drag and drop functionality
uploadArea.addEventListener('dragover', (e) => {{
e.preventDefault();
uploadArea.classList.add('dragover');
}});
uploadArea.addEventListener('dragleave', () => {{
uploadArea.classList.remove('dragover');
}});
uploadArea.addEventListener('drop', (e) => {{
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {{
imageUpload.files = files;
handleImageUpload();
}}
}});
function updateStatus(message, color = '#000') {{
statusDiv.textContent = message;
statusDiv.style.color = color;
}}
async function startRecording() {{
try {{
stream = await navigator.mediaDevices.getDisplayMedia({{
video: true,
audio: true
}});
videoPreview.srcObject = stream;
videoPreview.style.display = 'block';
mediaRecorder = new MediaRecorder(stream);
recordedChunks = [];
mediaRecorder.ondataavailable = (event) => {{
if (event.data.size > 0) {{
recordedChunks.push(event.data);
}}
}};
mediaRecorder.onstop = () => {{
const blob = new Blob(recordedChunks, {{ type: 'video/webm' }});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cicero-recording-${{Date.now()}}.webm`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}};
mediaRecorder.start();
updateStatus('Recording in progress...', '#000000');
startBtn.disabled = true;
stopBtn.disabled = false;
snapshotBtn.disabled = false;
stream.getVideoTracks()[0].onended = () => {{
stopRecording();
}};
}} catch (err) {{
console.error('Error starting recording:', err);
updateStatus('Error: ' + err.message, '#000000');
}}
}}
function stopRecording() {{
if (mediaRecorder && mediaRecorder.state === 'recording') {{
mediaRecorder.stop();
}}
if (stream) {{
stream.getTracks().forEach(track => track.stop());
stream = null;
}}
videoPreview.srcObject = null;
videoPreview.style.display = 'none';
updateStatus('Recording stopped', '#000000');
startBtn.disabled = false;
stopBtn.disabled = true;
snapshotBtn.disabled = true;
}}
async function captureSnapshot() {{
if (!videoPreview.srcObject) {{
updateStatus('No active recording to capture', '#000000');
return;
}}
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {{
updateStatus('Please enter Gemini API key', '#000000');
return;
}}
updateStatus('Capturing and analyzing...', '#000000');
const canvas = document.createElement('canvas');
canvas.width = videoPreview.videoWidth;
canvas.height = videoPreview.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(videoPreview, 0, 0);
canvas.toBlob(async (blob) => {{
try {{
// Save snapshot
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `cicero-snapshot-${{Date.now()}}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Analyze with AI
const formData = new FormData();
formData.append('image', blob, 'snapshot.png');
formData.append('question', questionInput.value);
formData.append('api_key', apiKey);
const response = await fetch('/analyze-image', {{
method: 'POST',
body: formData
}});
const result = await response.json();
if (response.ok) {{
showResults('Live Snapshot Analysis', result.analysis);
updateStatus('Snapshot captured and analyzed!', '#000000');
}} else {{
showResults('Analysis Failed', result.error || 'Unknown error');
updateStatus('Analysis failed', '#000000');
}}
}} catch (error) {{
console.error('Error:', error);
updateStatus('Error during analysis', '#000000');
}}
}}, 'image/png');
}}
function handleImageUpload() {{
const file = imageUpload.files[0];
if (file) {{
uploadArea.innerHTML = `
<div>
<strong>${{file.name}}</strong><br>
<small>Ready for analysis</small>
</div>
`;
}}
}}
async function analyzeUploadedImage() {{
const file = imageUpload.files[0];
const apiKey = apiKeyInput.value.trim();
const question = questionInput.value.trim();
if (!file) {{
alert('Please upload an image first');
return;
}}
if (!apiKey) {{
alert('Please enter your Gemini API key');
return;
}}
analyzeBtn.disabled = true;
analyzeBtn.innerHTML = 'Analyzing...';
try {{
const formData = new FormData();
formData.append('image', file);
formData.append('question', question);
formData.append('api_key', apiKey);
const response = await fetch('/analyze-image', {{
method: 'POST',
body: formData
}});
const result = await response.json();
if (response.ok) {{
showResults('Design Analysis', result.analysis);
}} else {{
showResults('Analysis Failed', result.error || 'Unknown error');
}}
}} catch (error) {{
console.error('Error:', error);
showResults('Error', 'Failed to analyze image');
}} finally {{
analyzeBtn.disabled = false;
analyzeBtn.innerHTML = 'Analyze Image';
}}
}}
async function loadFromEnvironment() {{
try {{
const response = await fetch('/get-env-key');
const result = await response.json();
if (response.ok && result.api_key) {{
apiKeyInput.value = result.api_key;
updateStatus('API key loaded from environment', '#000000');
}} else {{
updateStatus('No API key found in environment', '#000000');
}}
}} catch (error) {{
console.error('Error loading from environment:', error);
updateStatus('Error loading from environment', '#000000');
}}
}}
function showResults(title, content) {{
// Parse markdown content
const titleHtml = `<h3 style="font-family: 'Inter', sans-serif; font-weight: 600; color: var(--black); margin-bottom: 1rem;">${{title}}</h3>`;
const contentHtml = marked.parse(content);
resultsContent.innerHTML = titleHtml + contentHtml;
resultsArea.style.display = 'block';
resultsArea.scrollIntoView({{ behavior: 'smooth' }});
}}
</script>
</body>
</html>
"""
return html_content
@app.get("/get-env-key")
async def get_env_key():
"""Get API key from environment variables."""
try:
api_key = os.environ.get("GEMINI_API_KEY")
if api_key:
return JSONResponse(content={"api_key": api_key})
else:
return JSONResponse(content={"api_key": None}, status_code=404)
except Exception as e:
logger.error(f"Error getting environment key: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/analyze-image")
async def analyze_image_endpoint(
image: UploadFile = File(...),
question: str = Form(...),
api_key: str = Form(...)
):
"""Analyze uploaded image with AI."""
try:
# Read image data
image_data = await image.read()
# Validate image type
if image.content_type and not image.content_type.startswith('image/'):
raise HTTPException(status_code=400, detail=f"Invalid file type: {image.content_type}")
# Analyze with AI
result = await analyze_image_with_ai(image_data, question, api_key)
return JSONResponse(content={"analysis": result})
except Exception as e:
logger.error(f"Error analyzing image: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
print("πŸš€ Starting Cicero Passa a Cola...")
uvicorn.run(app, host="0.0.0.0", port=7860)