| from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query, Request
|
| from fastapi.responses import StreamingResponse, HTMLResponse, Response, RedirectResponse, JSONResponse, FileResponse
|
| from fastapi.middleware.cors import CORSMiddleware
|
| from pydantic import BaseModel
|
| import uvicorn, asyncio, json, os, requests as req, time, concurrent.futures
|
| from brain import Brain
|
| from crawler import crawl as do_crawl, CreditLedger
|
| from auth import (
|
| get_current_user, get_user_db, create_session,
|
| google_login_url, exchange_code, get_google_userinfo,
|
| COOKIE_NAME, GOOGLE_CLIENT_ID, DEFAULT_CREDITS, is_admin_email,
|
| )
|
|
|
| from errors import get_logger
|
|
|
| log = get_logger("app")
|
|
|
| app = FastAPI()
|
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
| brain: Brain | None = None
|
| BASE = "https://amogaddy-generai.hf.space"
|
|
|
| @app.get("/logo.png")
|
| def logo():
|
| return FileResponse("logo.png", media_type="image/png")
|
|
|
|
|
|
|
| def page(title, content, active=""):
|
| nav = [("/","Home","Home"),("/ragno","Ragno","Rete Ragno"),
|
| ("/ws/ui","Live","Live"),("/worldmonitor/","🌍","World Monitor"),
|
| ("/status/ui","Stats","Stato"),("/commands","CMD","Comandi"),("/docs","API","API"),
|
| ("/about","Info","Come funziona")]
|
| nav_html = "".join(f'<a href="{h}" class="nav-item{" active" if h==active else ""}">{i} {l}</a>' for h,i,l in nav)
|
|
|
| return f"""<!DOCTYPE html><html lang="it"><head>
|
| <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| <script>
|
| (function(){{
|
| try {{
|
| var nav = performance.getEntriesByType('navigation')[0];
|
| var isReload = nav && nav.type === 'reload';
|
| var firstEntry = !sessionStorage.getItem('generai_visited');
|
| if ((isReload || firstEntry) && location.pathname !== '/') {{
|
| sessionStorage.setItem('generai_visited', '1');
|
| location.replace('{BASE}/');
|
| return;
|
| }}
|
| sessionStorage.setItem('generai_visited', '1');
|
| }} catch(e) {{}}
|
| }})();
|
| </script>
|
| <title>GenerAI — {title}</title>
|
| <!-- Open Graph / Discord embed -->
|
| <meta property="og:type" content="website">
|
| <meta property="og:url" content="{BASE}/">
|
| <meta property="og:title" content="GenerAI — Assistente AI in italiano">
|
| <meta property="og:description" content="AI con memoria locale e ricerca web. Fai domande, ricevi risposte precise in italiano.">
|
| <meta property="og:image" content="{BASE}/logo.png">
|
| <meta property="og:image:width" content="512">
|
| <meta property="og:image:height" content="512">
|
| <meta property="og:site_name" content="GenerAI">
|
| <!-- Twitter Card -->
|
| <meta name="twitter:card" content="summary">
|
| <meta name="twitter:title" content="GenerAI — Assistente AI in italiano">
|
| <meta name="twitter:description" content="AI con memoria locale e ricerca web.">
|
| <meta name="twitter:image" content="{BASE}/logo.png">
|
| <!-- Favicon -->
|
| <link rel="icon" type="image/png" href="{BASE}/logo.png">
|
| <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=Outfit:wght@300;400;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| <style>
|
| :root {{
|
| --primary: #ff7b00;
|
| --primary-glow: rgba(255, 123, 0, 0.4);
|
| --bg: #0a0a0c;
|
| --glass: rgba(255, 255, 255, 0.03);
|
| --glass-border: rgba(255, 255, 255, 0.08);
|
| --glass-heavy: rgba(255, 255, 255, 0.07);
|
| --text: #f0f0f5;
|
| --text-dim: #9494a3;
|
| --radius: 18px;
|
| }}
|
| * {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
| body {{
|
| font-family: 'Outfit', sans-serif;
|
| background: var(--bg);
|
| color: var(--text);
|
| min-height: 100vh;
|
| overflow-x: hidden;
|
| position: relative;
|
| }}
|
| /* Liquid Background Effect */
|
| body::before {{
|
| content: '';
|
| position: fixed;
|
| top: -50%; left: -50%; width: 200%; height: 200%;
|
| background: radial-gradient(circle at 30% 30%, rgba(255, 123, 0, 0.12) 0%, transparent 40%),
|
| radial-gradient(circle at 70% 60%, rgba(255, 60, 0, 0.08) 0%, transparent 40%);
|
| filter: blur(80px);
|
| z-index: -1;
|
| animation: liquid 20s ease-in-out infinite alternate;
|
| }}
|
| @keyframes liquid {{
|
| 0% {{ transform: translate(0, 0) rotate(0deg); }}
|
| 100% {{ transform: translate(5%, 5%) rotate(10deg); }}
|
| }}
|
|
|
| nav {{
|
| background: rgba(15, 15, 20, 0.7);
|
| backdrop-filter: blur(20px) saturate(180%);
|
| -webkit-backdrop-filter: blur(20px) saturate(180%);
|
| border-bottom: 1px solid var(--glass-border);
|
| display: flex; align-items: center; padding: 0 32px; height: 64px; gap: 8px;
|
| position: sticky; top: 0; z-index: 100;
|
| }}
|
| .logo {{
|
| font-weight: 700; font-size: 20px; margin-right: 24px;
|
| background: linear-gradient(135deg, #ff9f0a, #ff3b30);
|
| -webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
| letter-spacing: -0.5px;
|
| }}
|
| .nav-item {{
|
| color: var(--text-dim); text-decoration: none; padding: 8px 16px; border-radius: 12px;
|
| font-size: 14px; font-weight: 500; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| }}
|
| .nav-item:hover {{ background: var(--glass); color: var(--text); }}
|
| .nav-item.active {{ background: var(--primary); color: #fff; box-shadow: 0 4px 15px var(--primary-glow); }}
|
|
|
| .content {{ padding: 40px 24px; max-width: 1000px; margin: 0 auto; }}
|
| h1 {{ font-size: 36px; font-weight: 700; margin-bottom: 8px; letter-spacing: -1px; }}
|
| .sub {{ color: var(--text-dim); margin-bottom: 32px; font-size: 16px; line-height: 1.6; }}
|
|
|
| .card {{
|
| background: var(--glass);
|
| backdrop-filter: blur(16px);
|
| -webkit-backdrop-filter: blur(16px);
|
| border: 1px solid var(--glass-border);
|
| border-radius: var(--radius);
|
| padding: 28px; margin-bottom: 24px;
|
| transition: transform 0.3s ease, border-color 0.3s ease;
|
| }}
|
| .card:hover {{ border-color: rgba(255, 123, 0, 0.3); }}
|
| .card h2 {{ font-size: 18px; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; color: #ff9f0a; }}
|
| .card h2::before {{ content: ''; width: 4px; height: 18px; background: var(--primary); border-radius: 10px; }}
|
|
|
| .badge {{
|
| display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 20px;
|
| font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
|
| }}
|
| .badge.green {{ background: rgba(52, 199, 89, 0.15); color: #34c759; border: 1px solid rgba(52, 199, 89, 0.2); }}
|
| .badge.red {{ background: rgba(255, 59, 48, 0.15); color: #ff3b30; border: 1px solid rgba(255, 59, 48, 0.2); }}
|
| .badge.blue {{ background: rgba(0, 122, 255, 0.15); color: #007aff; border: 1px solid rgba(0, 122, 255, 0.2); }}
|
| .badge.orange {{ background: rgba(255, 159, 10, 0.15); color: #ff9f0a; border: 1px solid rgba(255, 159, 10, 0.2); }}
|
|
|
| .btn {{
|
| display: inline-flex; align-items: center; justify-content: center;
|
| padding: 12px 24px; border-radius: 14px; text-decoration: none; font-size: 15px;
|
| font-weight: 600; cursor: pointer; border: none; font-family: inherit;
|
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); gap: 8px;
|
| }}
|
| .btn-primary {{ background: var(--primary); color: #fff; box-shadow: 0 4px 15px var(--primary-glow); }}
|
| .btn-primary:hover {{ transform: translateY(-2px); box-shadow: 0 6px 20px var(--primary-glow); opacity: 0.9; }}
|
| .btn-secondary {{ background: var(--glass-heavy); color: var(--text); border: 1px solid var(--glass-border); }}
|
| .btn-secondary:hover {{ background: var(--glass-border); transform: translateY(-2px); }}
|
| .btn:disabled {{ opacity: 0.4; cursor: not-allowed; transform: none !important; }}
|
|
|
| input, textarea {{
|
| background: rgba(0,0,0,0.2); border: 1px solid var(--glass-border);
|
| color: var(--text); border-radius: 14px; padding: 16px; font-size: 16px;
|
| width: 100%; transition: all 0.3s ease; font-family: inherit;
|
| }}
|
| input:focus, textarea:focus {{ outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px var(--primary-glow); }}
|
|
|
| .cmd-text {{ font-family: 'JetBrains Mono', monospace; font-size: 14px; background: rgba(0,0,0,0.3); padding: 16px; border-radius: 12px; color: #ff9f0a; border: 1px solid var(--glass-border); }}
|
| .tab {{ font-size: 13px; padding: 6px 16px; border-radius: 10px; cursor: pointer; border: 1px solid var(--glass-border); background: var(--glass); color: var(--text-dim); transition: 0.2s; }}
|
| .tab.active {{ background: var(--primary); color: #fff; border-color: var(--primary); }}
|
|
|
| table {{ width: 100%; border-collapse: separate; border-spacing: 0 8px; }}
|
| th {{ color: var(--text-dim); font-weight: 500; padding: 12px; text-align: left; font-size: 13px; }}
|
| td {{ padding: 16px 12px; background: var(--glass); border-top: 1px solid var(--glass-border); border-bottom: 1px solid var(--glass-border); vertical-align: middle; }}
|
| td:first-child {{ border-left: 1px solid var(--glass-border); border-radius: 12px 0 0 12px; }}
|
| td:last-child {{ border-right: 1px solid var(--glass-border); border-radius: 0 12px 12px 0; }}
|
| tr:hover td {{ background: var(--glass-heavy); }}
|
|
|
| .progress {{ height: 8px; background: var(--glass-heavy); border-radius: 10px; overflow: hidden; }}
|
| .progress-bar {{ height: 100%; background: linear-gradient(90deg, #ff9f0a, #ff3b30); border-radius: 10px; transition: width 0.5s ease; }}
|
|
|
| /* Chat styles */
|
| #steps {{ border-left: 2px solid var(--glass-border); padding-left: 16px; margin-bottom: 24px; }}
|
| .step-item {{ font-size: 14px; color: var(--text-dim); margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }}
|
| .step-item.active {{ color: var(--primary); font-weight: 500; }}
|
| #result {{ font-size: 17px; line-height: 1.8; color: #e0e0e0; letter-spacing: 0.2px; }}
|
| </style></head><body>
|
| <nav>
|
| <a href="/" style="display:flex;align-items:center;gap:10px;text-decoration:none;margin-right:20px">
|
| <img src="{BASE}/logo.png" alt="GenerAI" style="width:36px;height:36px;border-radius:50%;object-fit:cover">
|
| <span class="logo">GenerAI</span>
|
| </a>
|
| {nav_html}
|
| <div id="_auth-nav" style="margin-left:auto;display:flex;align-items:center;gap:10px"></div>
|
| </nav>
|
| <script>
|
| (function(){{
|
| fetch('{BASE}/auth/me').then(function(r){{return r.json();}}).then(function(u){{
|
| var el=document.getElementById('_auth-nav');
|
| if(!el) return;
|
| if(u.logged_in){{
|
| el.innerHTML=
|
| '<a href="{BASE}/account" style="display:flex;align-items:center;gap:10px;text-decoration:none">'
|
| +'<img src="'+u.picture+'" style="width:28px;height:28px;border-radius:50%;border:2px solid var(--glass-border)" onerror="this.style.display=\\'none\\'">'
|
| +'<span style="font-size:13px;color:#ccc;max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+u.name+'</span>'
|
| +'<span id="_nav-cr" style="background:rgba(255,123,0,0.15);color:var(--primary);border:1px solid rgba(255,123,0,0.3);padding:3px 9px;border-radius:20px;font-size:12px;font-weight:600">'+(u.credits>=1000000?'∞':u.credits.toLocaleString('it-IT'))+' cr</span>'
|
| +'</a>'
|
| +'<a href="{BASE}/auth/logout" style="font-size:12px;color:#666;text-decoration:none;padding:4px 10px;border:1px solid #333;border-radius:8px">Esci</a>';
|
| }} else {{
|
| el.innerHTML=
|
| '<a href="{BASE}/auth/login" style="display:inline-flex;align-items:center;gap:8px;background:#fff;color:#444;font-size:13px;font-weight:600;padding:7px 14px;border-radius:10px;text-decoration:none;box-shadow:0 1px 6px rgba(0,0,0,0.3)">'
|
| +'<svg width="16" height="16" viewBox="0 0 18 18"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/><path fill="#FBBC05" d="M3.964 10.706a5.41 5.41 0 0 1-.282-1.706c0-.593.102-1.17.282-1.706V4.962H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 6.294C4.672 4.167 6.656 3.58 9 3.58z"/></svg>'
|
| +'Accedi con Google</a>';
|
| }}
|
| }}).catch(function(){{}});
|
| }})();
|
| </script>
|
| <div class="content">{content}</div>
|
| </body></html>"""
|
|
|
|
|
|
|
| def _login_page() -> str:
|
| google_url = f"{BASE}/auth/login"
|
| return f"""<!DOCTYPE html><html lang="it"><head>
|
| <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| <title>GenerAI — Accedi</title>
|
| <link rel="icon" type="image/png" href="{BASE}/logo.png">
|
| <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
|
| <style>
|
| *{{margin:0;padding:0;box-sizing:border-box}}
|
| body{{background:#0d0d0f;font-family:Outfit,sans-serif;min-height:100vh;
|
| display:flex;align-items:center;justify-content:center;
|
| background:radial-gradient(ellipse 80% 60% at 50% 0%,rgba(255,123,0,.12) 0%,transparent 70%),#0d0d0f}}
|
| .card{{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);
|
| border-radius:24px;padding:48px 40px;text-align:center;max-width:420px;width:90%;
|
| box-shadow:0 20px 60px rgba(0,0,0,.5)}}
|
| .logo{{width:72px;height:72px;border-radius:50%;margin:0 auto 24px;object-fit:cover;
|
| box-shadow:0 0 30px rgba(255,123,0,.35)}}
|
| h1{{font-size:28px;font-weight:700;color:#fff;margin-bottom:8px}}
|
| p{{font-size:15px;color:rgba(255,255,255,.45);margin-bottom:36px;line-height:1.6}}
|
| .google-btn{{display:inline-flex;align-items:center;gap:12px;background:#fff;color:#3c4043;
|
| font-size:15px;font-weight:600;padding:13px 24px;border-radius:12px;text-decoration:none;
|
| box-shadow:0 2px 12px rgba(0,0,0,.35);transition:box-shadow .2s,transform .15s}}
|
| .google-btn:hover{{box-shadow:0 4px 20px rgba(0,0,0,.5);transform:translateY(-1px)}}
|
| .note{{margin-top:28px;font-size:12px;color:rgba(255,255,255,.2);line-height:1.6}}
|
| </style></head><body>
|
| <div class="card">
|
| <img src="{BASE}/logo.png" class="logo" alt="GenerAI">
|
| <h1>GenerAI</h1>
|
| <p>Il motore di ricerca puro.<br>Nessun intermediario — solo il web.</p>
|
| <a href="{google_url}" class="google-btn">
|
| <svg width="20" height="20" viewBox="0 0 18 18">
|
| <path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/>
|
| <path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/>
|
| <path fill="#FBBC05" d="M3.964 10.706a5.41 5.41 0 0 1-.282-1.706c0-.593.102-1.17.282-1.706V4.962H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z"/>
|
| <path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 6.294C4.672 4.167 6.656 3.58 9 3.58z"/>
|
| </svg>
|
| Accedi con Google
|
| </a>
|
| <p class="note">Accedendo ricevi 10.000 crediti gratuiti.<br>1 credito = 1 pagina esplorata dal ragno.</p>
|
| <p class="note">Il login non funziona? <a href="{BASE}/" target="_blank" style="color:rgba(255,123,0,.7)">Prova qui</a></p>
|
| </div>
|
| </body></html>"""
|
|
|
|
|
|
|
|
|
| @app.get("/", response_class=HTMLResponse)
|
| async def home(request: Request):
|
| user = get_current_user(request)
|
| if not user:
|
| return HTMLResponse(_login_page())
|
|
|
| kb = brain.kb_size if brain else 0
|
| ok = brain is not None
|
| content = f"""
|
| <div style="display:flex;align-items:center;gap:24px;margin-bottom:32px;flex-wrap:wrap">
|
| <img src="{BASE}/logo.png" alt="GenerAI"
|
| style="width:100px;height:100px;border-radius:50%;object-fit:cover;
|
| box-shadow:0 0 40px rgba(255,123,0,0.4)">
|
| <div>
|
| <h1 style="margin-bottom:6px">Ciao! Sono GenerAI</h1>
|
| <p class="sub" style="margin-bottom:0">Il tuo assistente AI specializzato in italiano.<br>
|
| Cerco sul web e apprendo autonomamente per darti la risposta migliore.</p>
|
| </div>
|
| </div>
|
| <div class="card"><h2>Stato del sistema</h2>
|
| <table>
|
| <tr><th>Modulo</th><th>Stato</th><th>Dettagli</th></tr>
|
| <tr><td>AI Cervello</td><td><span class="badge {"green" if ok else "red"}">{"Operativo" if ok else "Avvio in corso..."}</span></td><td>Ricerca e risposta</td></tr>
|
| <tr><td>KB Memoria</td><td><span class="badge orange">Autonoma</span></td><td>{kb} concetti appresi</td></tr>
|
| <tr><td>Web Ricerca Web</td><td><span class="badge green">Chromium</span></td><td>Browser headless (Playwright)</td></tr>
|
| <tr><td>Cloud MEGA</td><td><span class="badge blue">Sincronizzato</span></td><td>Backup DB nel cloud</td></tr>
|
| </table></div>
|
| <div class="card"><h2>Cosa vuoi fare?</h2>
|
| <div style="display:flex;gap:12px;flex-wrap:wrap">
|
| <a href="/ragno" class="btn btn-primary">🕷 Rete Ragno — Esplora il web</a>
|
| <a href="/status/ui" class="btn btn-secondary">Stats Guarda le statistiche</a>
|
| </div></div>"""
|
| return HTMLResponse(page("Home", content, "/"))
|
|
|
| @app.get("/chat")
|
| def chat_page():
|
| return RedirectResponse("/ragno")
|
|
|
|
|
|
|
| @app.get("/account", response_class=HTMLResponse)
|
| async def account_page(request: Request):
|
| user = get_current_user(request)
|
| if not user:
|
| return RedirectResponse("/")
|
|
|
| admin = is_admin_email(user.get("email", ""))
|
| credits_val = user.get("credits", 0)
|
| credits_str = "∞" if admin or credits_val >= 1_000_000 else f"{credits_val:,}".replace(",", ".")
|
| created = user.get("created_at")
|
| created_str = time.strftime("%d/%m/%Y", time.localtime(created)) if created else "—"
|
| picture = user.get("picture", "")
|
|
|
| content = f"""
|
| <div class="card" style="text-align:center;padding:40px 28px">
|
| <img src="{picture}" alt="" style="width:88px;height:88px;border-radius:50%;
|
| border:3px solid var(--glass-border);box-shadow:0 0 30px rgba(255,123,0,0.25);margin-bottom:16px"
|
| onerror="this.style.display='none'">
|
| <h1 style="margin-bottom:4px">{user.get("name","")}</h1>
|
| <p class="sub" style="margin-bottom:0">{user.get("email","")}</p>
|
| </div>
|
|
|
| <div class="card"><h2>Crediti</h2>
|
| <div style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap">
|
| <div>
|
| <div style="font-size:36px;font-weight:700;color:var(--primary)">{credits_str}</div>
|
| <p class="sub" style="margin-bottom:0;font-size:13px">1 credito = 1 pagina esplorata dalla Rete Ragno</p>
|
| </div>
|
| {'<span class="badge orange">Account Admin — illimitati</span>' if admin else '<span class="badge blue">Piano gratuito — 10.000 crediti</span>'}
|
| </div></div>
|
|
|
| <div class="card"><h2>Informazioni account</h2>
|
| <table>
|
| <tr><th>Campo</th><th>Valore</th></tr>
|
| <tr><td>Nome</td><td>{user.get("name","")}</td></tr>
|
| <tr><td>Email</td><td>{user.get("email","")}</td></tr>
|
| <tr><td>Iscritto dal</td><td>{created_str}</td></tr>
|
| <tr><td>ID account</td><td style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text-dim)">{user.get("id","")}</td></tr>
|
| </table></div>
|
|
|
| <div class="card"><h2>Azioni</h2>
|
| <div style="display:flex;gap:12px;flex-wrap:wrap">
|
| <a href="/ragno" class="btn btn-primary">🕷 Vai alla Rete Ragno</a>
|
| <a href="/auth/logout" class="btn btn-secondary">Esci dall'account</a>
|
| </div></div>"""
|
| return HTMLResponse(page("Account", content, ""))
|
|
|
|
|
|
|
| @app.get("/auth/login")
|
| def auth_login():
|
| url = google_login_url()
|
| if not url:
|
| return HTMLResponse("<h2>GOOGLE_CLIENT_ID non configurato.</h2><a href='/'>Torna alla home</a>", status_code=503)
|
| return RedirectResponse(url)
|
|
|
| @app.get("/auth/callback")
|
| async def auth_callback(code: str = "", error: str = ""):
|
| if error or not code:
|
| return RedirectResponse("/?error=auth_failed")
|
| token_data = await asyncio.to_thread(exchange_code, code)
|
| access_token = token_data.get("access_token")
|
| if not access_token:
|
| return RedirectResponse("/?error=token_failed")
|
| userinfo = await asyncio.to_thread(get_google_userinfo, access_token)
|
| gid = userinfo.get("sub")
|
| if not gid:
|
| return RedirectResponse("/?error=userinfo_failed")
|
| db = get_user_db()
|
| db.get_or_create(
|
| gid,
|
| userinfo.get("email", ""),
|
| userinfo.get("name", userinfo.get("email", "Utente")),
|
| userinfo.get("picture", ""),
|
| )
|
| token = create_session(gid)
|
| _schedule_mega_upload()
|
| resp = RedirectResponse("/ragno")
|
| resp.set_cookie(COOKIE_NAME, token, max_age=86400 * 30,
|
| httponly=True, samesite="lax", secure=True)
|
| return resp
|
|
|
| @app.get("/auth/logout")
|
| def auth_logout():
|
| resp = RedirectResponse("/")
|
| resp.delete_cookie(COOKIE_NAME)
|
| return resp
|
|
|
| @app.get("/auth/me")
|
| def auth_me(request: Request):
|
| user = get_current_user(request)
|
| if not user:
|
| return {"logged_in": False}
|
| return {
|
| "logged_in": True,
|
| "name": user["name"],
|
| "email": user["email"],
|
| "picture": user.get("picture", ""),
|
| "credits": user.get("credits", 0),
|
| }
|
|
|
|
|
|
|
| @app.get("/credits")
|
| def credits_endpoint():
|
| ledger = CreditLedger()
|
| return {"remaining": ledger.remaining, "total_used": ledger.total_used, "default": 10_000}
|
|
|
|
|
|
|
|
|
| @app.get("/ragno/crawl")
|
| async def ragno_crawl_stream(request: Request,
|
| q: str = Query(default=""),
|
| budget: int = Query(default=10, ge=1, le=100)):
|
| if not q.strip():
|
| return JSONResponse({"error": "Query mancante"}, status_code=400)
|
|
|
| user = get_current_user(request)
|
| if not user:
|
| return JSONResponse({"error": "login_required",
|
| "message": "Devi accedere con Google per usare la Rete Ragno."},
|
| status_code=401)
|
|
|
| gid = user["id"]
|
| db = get_user_db()
|
| user_credits = db.remaining(gid)
|
| if user_credits <= 0:
|
| return JSONResponse({"error": "no_credits",
|
| "message": "Crediti esauriti. Contatta l'amministratore."},
|
| status_code=402)
|
|
|
| query = q.strip()
|
| budget = min(budget, 100, user_credits)
|
|
|
| def _use_credit(n: int = 1) -> bool:
|
| return db.use_credits(gid, n)
|
|
|
| def _remaining() -> int:
|
| return db.remaining(gid)
|
|
|
| async def event_gen():
|
| loop = asyncio.get_running_loop()
|
| queue: asyncio.Queue = asyncio.Queue()
|
|
|
| def _run():
|
| try:
|
| for ev in do_crawl(query, budget,
|
| use_credit_fn=_use_credit,
|
| credits_remaining_fn=_remaining):
|
| loop.call_soon_threadsafe(queue.put_nowait, ev)
|
| except Exception as exc:
|
| loop.call_soon_threadsafe(queue.put_nowait,
|
| {"type": "error", "message": str(exc), "credits_remaining": _remaining()})
|
| finally:
|
| loop.call_soon_threadsafe(queue.put_nowait, None)
|
|
|
| with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
| pool.submit(_run)
|
| while True:
|
| item = await queue.get()
|
| if item is None:
|
| break
|
| yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n"
|
| _schedule_mega_upload()
|
| yield "data: [DONE]\n\n"
|
|
|
| return StreamingResponse(
|
| event_gen(),
|
| media_type="text/event-stream",
|
| headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| )
|
|
|
|
|
|
|
|
|
| @app.get("/ragno", response_class=HTMLResponse)
|
| def ragno_page(request: Request):
|
| if not get_current_user(request):
|
| return RedirectResponse("/")
|
|
|
| content = f"""
|
| <h1>Rete Ragno</h1>
|
| <p class="sub">Motore di ricerca puro — il ragno esplora il web autonomamente, saltando di link in link. Nessun intermediario. Ogni pagina visitata costa 1 credito.</p>
|
|
|
| <div class="card">
|
| <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
| <input id="q" placeholder="Cosa vuoi cercare?" style="flex:1;min-width:180px" onkeydown="if(event.key==='Enter')startCrawl()">
|
| <select id="budget" style="width:130px;padding:12px;background:var(--glass);border:1px solid var(--glass-border);color:#fff;border-radius:12px;font-family:Outfit,sans-serif;font-size:14px">
|
| <option value="5">5 pagine</option>
|
| <option value="10" selected>10 pagine</option>
|
| <option value="15">15 pagine</option>
|
| <option value="25">25 pagine</option>
|
| <option value="50">50 pagine</option>
|
| <option value="100">100 pagine</option>
|
| </select>
|
| <button class="btn btn-primary" id="sbtn" onclick="startCrawl()">🕷 Esplora</button>
|
| <button class="btn" id="stopbtn" onclick="stopCrawl()" style="display:none">⏹ Stop</button>
|
| </div>
|
| <div id="credits-bar" style="display:flex;align-items:center;gap:10px;margin-top:10px;font-size:13px">
|
| <span style="color:var(--text-dim)">Crediti:</span>
|
| <div style="flex:1;height:6px;background:rgba(255,255,255,0.08);border-radius:4px;overflow:hidden">
|
| <div id="credits-fill" style="height:100%;background:var(--primary);border-radius:4px;transition:width 0.3s;width:100%"></div>
|
| </div>
|
| <span id="credits-num" style="color:var(--primary);font-weight:600;min-width:60px;text-align:right">...</span>
|
| </div>
|
| <div id="search-status" style="font-size:13px;color:var(--text-dim);margin-top:8px;min-height:18px"></div>
|
| <div id="visit-log" style="margin-top:10px;max-height:160px;overflow-y:auto;font-size:12.5px;
|
| border-top:1px solid var(--glass-border);padding-top:8px;flex-direction:column;gap:4px"></div>
|
| </div>
|
|
|
| <div id="area" style="display:none;margin-top:0">
|
| <div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
|
| <div style="flex:1;min-width:280px">
|
| <div class="card" style="padding:8px;overflow:hidden">
|
| <svg id="web" style="display:block;width:100%;cursor:grab;touch-action:none"></svg>
|
| </div>
|
| </div>
|
| <div id="panel" class="card" style="width:320px;flex-shrink:0;display:none;position:sticky;top:80px;max-height:85vh;overflow-y:auto"></div>
|
| </div>
|
| </div>
|
|
|
| <div id="summary-card" class="card" style="display:none;margin-top:0">
|
| <h2>🧠 Riepilogo AI</h2>
|
| <div id="summary-status" style="font-size:13px;color:var(--text-dim);margin-bottom:8px"></div>
|
| <div id="summary-content" style="font-size:14px;line-height:1.8;color:#e0e0e0;white-space:pre-wrap"></div>
|
| </div>
|
|
|
| <style>
|
| @keyframes nodeIn {{
|
| from {{ opacity:0; transform:scale(0.3); }}
|
| to {{ opacity:1; transform:scale(1); }}
|
| }}
|
| </style>
|
| <script>
|
| var NS = 'http://www.w3.org/2000/svg';
|
| function svgEl(tag){{ return document.createElementNS(NS, tag); }}
|
| var webData = [];
|
| var selIdx = null;
|
| var nodeColors = ['#7c9ef8','#34c759','#ff9f0a','#af52de','#5ac8fa','#ff2d55','#a2d96a','#ffcc02'];
|
| var _reader = null;
|
| var _totalCredits = {DEFAULT_CREDITS};
|
| var _currentUser = null;
|
| var _lastQuery = '';
|
|
|
| // Stato pan/zoom/trascinamento nodi della mappa
|
| var viewTX = 0, viewTY = 0, viewScale = 1;
|
| var nodeOverrides = {{}};
|
| var isPanning = false, panOrigin = {{x:0,y:0}};
|
| var draggingNode = null;
|
|
|
| function applyTransform(){{
|
| var vp = document.getElementById('viewport');
|
| if(vp) vp.setAttribute('transform', 'translate('+viewTX+','+viewTY+') scale('+viewScale+')');
|
| }}
|
|
|
| function resetView(){{
|
| viewTX = 0; viewTY = 0; viewScale = 1; nodeOverrides = {{}};
|
| }}
|
|
|
| function setupMapInteraction(){{
|
| var svg = document.getElementById('web');
|
| if(!svg || svg._interactionReady) return;
|
| svg._interactionReady = true;
|
|
|
| svg.addEventListener('mousedown', function(e){{
|
| if(draggingNode !== null) return;
|
| isPanning = true;
|
| panOrigin = {{x: e.clientX - viewTX, y: e.clientY - viewTY}};
|
| svg.style.cursor = 'grabbing';
|
| }});
|
|
|
| document.addEventListener('mousemove', function(e){{
|
| if(isPanning){{
|
| viewTX = e.clientX - panOrigin.x;
|
| viewTY = e.clientY - panOrigin.y;
|
| applyTransform();
|
| }} else if(draggingNode !== null){{
|
| var rect = svg.getBoundingClientRect();
|
| var mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
| nodeOverrides[draggingNode] = {{x: (mx - viewTX)/viewScale, y: (my - viewTY)/viewScale}};
|
| drawMap(_lastQuery, webData);
|
| }}
|
| }});
|
|
|
| document.addEventListener('mouseup', function(){{
|
| isPanning = false;
|
| draggingNode = null;
|
| svg.style.cursor = 'grab';
|
| }});
|
|
|
| svg.addEventListener('wheel', function(e){{
|
| e.preventDefault();
|
| var rect = svg.getBoundingClientRect();
|
| var mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
| var oldScale = viewScale;
|
| var factor = e.deltaY < 0 ? 1.12 : 1/1.12;
|
| var newScale = Math.max(0.25, Math.min(4, oldScale * factor));
|
| var logicalX = (mx - viewTX) / oldScale;
|
| var logicalY = (my - viewTY) / oldScale;
|
| viewTX = mx - logicalX * newScale;
|
| viewTY = my - logicalY * newScale;
|
| viewScale = newScale;
|
| applyTransform();
|
| }}, {{passive:false}});
|
| }}
|
| setupMapInteraction();
|
|
|
| // Carica utente e crediti all'avvio
|
| (function(){{
|
| fetch('{BASE}/auth/me').then(function(r){{return r.json();}}).then(function(u){{
|
| _currentUser = u;
|
| if(u.logged_in){{
|
| _totalCredits = {DEFAULT_CREDITS};
|
| updateCreditsUI(u.credits);
|
| }} else {{
|
| updateCreditsUI(0);
|
| document.getElementById('credits-num').textContent = 'Accedi';
|
| document.getElementById('credits-fill').style.width = '0%';
|
| }}
|
| }}).catch(function(){{}});
|
| }})();
|
|
|
| function updateCreditsUI(remaining){{
|
| var el = document.getElementById('credits-num');
|
| var fill = document.getElementById('credits-fill');
|
| if(!el) return;
|
| if((remaining||0) >= 1000000){{
|
| el.textContent = '∞ cr';
|
| fill.style.width = '100%';
|
| fill.style.background = 'var(--primary)';
|
| return;
|
| }}
|
| el.textContent = (remaining||0).toLocaleString('it-IT') + ' cr';
|
| var pct = Math.max(0, Math.min(100, ((remaining||0) / _totalCredits) * 100));
|
| fill.style.width = pct + '%';
|
| fill.style.background = pct > 50 ? 'var(--primary)' : pct > 20 ? '#ffcc02' : '#ff3b30';
|
| }}
|
|
|
| function setStatus(msg){{
|
| document.getElementById('search-status').textContent = msg;
|
| }}
|
|
|
| function startCrawl(){{
|
| var q = document.getElementById('q').value.trim();
|
| if(!q) return;
|
|
|
| // Verifica login
|
| if(!_currentUser || !_currentUser.logged_in){{
|
| setStatus('');
|
| document.getElementById('area').style.display = 'block';
|
| document.getElementById('panel').style.display = 'none';
|
| var svg = document.getElementById('web');
|
| svg.innerHTML = '';
|
| svg.setAttribute('height','120');
|
| var fo = document.createElementNS('http://www.w3.org/2000/svg','foreignObject');
|
| fo.setAttribute('x','0'); fo.setAttribute('y','0');
|
| fo.setAttribute('width','100%'); fo.setAttribute('height','120');
|
| fo.innerHTML = '<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:center;justify-content:center;height:120px;gap:16px">'
|
| +'<a href="{BASE}/auth/login" style="display:inline-flex;align-items:center;gap:8px;background:#fff;color:#444;font-size:14px;font-weight:600;padding:10px 20px;border-radius:12px;text-decoration:none">'
|
| +'<svg width="18" height="18" viewBox="0 0 18 18"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/><path fill="#FBBC05" d="M3.964 10.706a5.41 5.41 0 0 1-.282-1.706c0-.593.102-1.17.282-1.706V4.962H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 6.294C4.672 4.167 6.656 3.58 9 3.58z"/></svg>'
|
| +'Accedi con Google per esplorare</a></div>';
|
| svg.appendChild(fo);
|
| return;
|
| }}
|
|
|
| var budget = parseInt(document.getElementById('budget').value) || 10;
|
| webData = []; selIdx = null;
|
| resetView();
|
| document.getElementById('area').style.display = 'block';
|
| document.getElementById('panel').style.display = 'none';
|
| document.getElementById('sbtn').style.display = 'none';
|
| document.getElementById('stopbtn').style.display = '';
|
| var log = document.getElementById('visit-log');
|
| log.innerHTML = '';
|
| log.style.display = 'none';
|
| document.getElementById('summary-card').style.display = 'none';
|
| document.getElementById('summary-content').innerHTML = '';
|
| document.getElementById('summary-status').innerText = '';
|
| setStatus('Il ragno cerca il punto di partenza...');
|
| drawMap(q, []);
|
|
|
| var url = '{BASE}/ragno/crawl?q=' + encodeURIComponent(q) + '&budget=' + budget;
|
| fetch(url).then(function(resp){{
|
| if(resp.status === 401){{
|
| resp.json().then(function(d){{
|
| setStatus('⚠ ' + (d.message || 'Accedi con Google.'));
|
| onDone();
|
| }});
|
| return;
|
| }}
|
| if(resp.status === 402){{
|
| resp.json().then(function(d){{
|
| setStatus('⚠ ' + (d.message || 'Crediti esauriti.'));
|
| onDone();
|
| }});
|
| return;
|
| }}
|
| var reader = resp.body.getReader();
|
| _reader = reader;
|
| var dec = new TextDecoder();
|
| var buf = '';
|
| function read(){{
|
| reader.read().then(function(chunk){{
|
| if(chunk.done){{ onDone(); return; }}
|
| buf += dec.decode(chunk.value, {{stream:true}});
|
| var lines = buf.split('\\n');
|
| buf = lines.pop();
|
| lines.forEach(function(line){{
|
| if(!line.startsWith('data:')) return;
|
| var raw = line.slice(5).trim();
|
| if(raw === '[DONE]'){{ onDone(); return; }}
|
| try{{ handleEvent(JSON.parse(raw)); }}catch(e){{}}
|
| }});
|
| read();
|
| }}).catch(function(){{ onDone(); }});
|
| }}
|
| read();
|
| }}).catch(function(e){{ setStatus('Errore: '+e.message); onDone(); }});
|
| }}
|
|
|
| function stopCrawl(){{
|
| if(_reader){{ try{{ _reader.cancel(); }}catch(e){{}} _reader = null; }}
|
| onDone();
|
| }}
|
|
|
| function onDone(){{
|
| document.getElementById('sbtn').style.display = '';
|
| document.getElementById('stopbtn').style.display = 'none';
|
| if(webData.length > 0){{
|
| setStatus('✓ Esplorazione completata — ' + webData.length + ' pagine trovate.');
|
| generateSummary(document.getElementById('q').value.trim());
|
| }} else {{
|
| setStatus('Nessuna pagina trovata. Riprova con un\\'altra query.');
|
| }}
|
| }}
|
|
|
| function generateSummary(query){{
|
| var card = document.getElementById('summary-card');
|
| var statusEl = document.getElementById('summary-status');
|
| var contentEl = document.getElementById('summary-content');
|
| card.style.display = 'block';
|
| statusEl.innerText = 'Leggo le pagine trovate e preparo un riepilogo...';
|
| contentEl.innerText = '';
|
|
|
| var pages = webData.slice(0, 15).map(function(r){{
|
| var t = (r.text||'').slice(0, 400);
|
| return '[' + (r.title||r.url) + ']\\n' + t;
|
| }}).join('\\n\\n');
|
|
|
| var prompt = 'Il ragno ha esplorato il web per la ricerca "' + query + '" e ha trovato queste pagine:\\n\\n'
|
| + pages
|
| + '\\n\\nScrivi un riepilogo chiaro e discorsivo (max 200 parole, in italiano) di cosa emerge complessivamente da queste pagine, mettendo in evidenza i punti principali e eventuali collegamenti tra le fonti.';
|
|
|
| fetch('{BASE}/ask/stream', {{
|
| method: 'POST',
|
| headers: {{'Content-Type': 'application/json'}},
|
| body: JSON.stringify({{prompt: prompt}})
|
| }}).then(function(r){{
|
| var reader = r.body.getReader(), dec = new TextDecoder();
|
| function read(){{
|
| reader.read().then(function(chunk){{
|
| if(chunk.done) return;
|
| dec.decode(chunk.value, {{stream:true}}).split('\\n').forEach(function(line){{
|
| if(!line.startsWith('data:')) return;
|
| try{{
|
| var d = JSON.parse(line.slice(5));
|
| if(d.type === 'status') statusEl.innerText = d.message;
|
| else if(d.type === 'result'){{
|
| statusEl.innerText = '';
|
| contentEl.innerText = d.message;
|
| }}
|
| }}catch(e){{}}
|
| }});
|
| read();
|
| }}).catch(function(e){{ statusEl.innerText = ''; contentEl.innerText = 'Errore: ' + e.message; }});
|
| }}
|
| read();
|
| }}).catch(function(e){{ statusEl.innerText = ''; contentEl.innerText = 'Errore: ' + e.message; }});
|
| }}
|
|
|
| function logVisit(text, color){{
|
| var log = document.getElementById('visit-log');
|
| log.style.display = 'flex';
|
| var row = document.createElement('div');
|
| row.style.color = color || 'var(--text-dim)';
|
| row.style.whiteSpace = 'nowrap';
|
| row.style.overflow = 'hidden';
|
| row.style.textOverflow = 'ellipsis';
|
| row.textContent = text;
|
| log.appendChild(row);
|
| log.scrollTop = log.scrollHeight;
|
| }}
|
|
|
| function handleEvent(ev){{
|
| if(ev.type === 'visiting'){{
|
| var short = ev.url.replace(/^https?:\\/\\//, '').slice(0, 70);
|
| setStatus('🕷 Visito: ' + short);
|
| logVisit('🕷 ' + short);
|
| }} else if(ev.type === 'visit'){{
|
| webData.push(ev);
|
| updateCreditsUI(ev.credits_remaining);
|
| var q = document.getElementById('q').value;
|
| drawMap(q, webData);
|
| var short = ev.url.replace(/^https?:\\/\\//, '').slice(0, 70);
|
| var icon = ev.is_citation ? '📚' : '📄';
|
| setStatus(icon + ' ' + (ev.title||ev.url).slice(0,60) + ' (+' + webData.length + ')');
|
| logVisit((ev.is_citation ? '📚 Fonte citata: ' : '✓ ') + (ev.title || short), ev.is_citation ? '#7c9ef8' : '#34c759');
|
| }} else if(ev.type === 'done'){{
|
| updateCreditsUI(ev.credits_remaining);
|
| onDone();
|
| }} else if(ev.type === 'error'){{
|
| updateCreditsUI(ev.credits_remaining);
|
| setStatus('⚠ ' + (ev.message||'Errore sconosciuto'));
|
| logVisit('⚠ ' + (ev.message||'Errore sconosciuto'), '#ff3b30');
|
| onDone();
|
| }}
|
| }}
|
|
|
| // ─── Mind Map Layout ──────────────────────────────────────────────────────────
|
| // Ogni ramo (root→figli→nipoti) ha il suo colore.
|
| // Linee bezier organiche, spessore decresce con la profondità.
|
| // Nodi sono punti colorati con label testuale, come nell'esempio.
|
|
|
| function _buildTree(results){{
|
| var nodeMap = {{}};
|
| results.forEach(function(r,i){{ nodeMap[r.title||r.url] = i; }});
|
| var children = results.map(function(){{ return []; }});
|
| var roots = [];
|
| results.forEach(function(r,i){{
|
| var pt = r.parent;
|
| if(!pt || pt==='query' || nodeMap[pt]===undefined) roots.push(i);
|
| else children[nodeMap[pt]].push(i);
|
| }});
|
| return {{nodeMap:nodeMap, children:children, roots:roots}};
|
| }}
|
|
|
| function _assignColors(results, tree){{
|
| var bcolor = new Array(results.length);
|
| tree.roots.forEach(function(ri,i){{
|
| var col = nodeColors[i % nodeColors.length];
|
| function assign(idx){{ bcolor[idx]=col; tree.children[idx].forEach(assign); }}
|
| assign(ri);
|
| }});
|
| results.forEach(function(_,i){{ if(!bcolor[i]) bcolor[i]=nodeColors[i%nodeColors.length]; }});
|
| return bcolor;
|
| }}
|
|
|
| function _computePositions(results, tree, cx, cy, W, H){{
|
| var R1 = Math.min(W,H)*0.29; // distanza centro → root
|
| var R2 = Math.min(W,H)*0.18; // root → figlio
|
| var R3 = Math.min(W,H)*0.13; // figlio → nipote
|
| var pos = new Array(results.length);
|
|
|
| tree.roots.forEach(function(ri,i){{
|
| var ang = (i/Math.max(tree.roots.length,1))*2*Math.PI - Math.PI/2;
|
| pos[ri] = {{x: cx+R1*Math.cos(ang), y: cy+R1*Math.sin(ang), ang:ang}};
|
|
|
| var kids = tree.children[ri];
|
| var spread1 = Math.min(Math.PI*0.7, kids.length*0.45);
|
| kids.forEach(function(ki,j){{
|
| var a = kids.length===1 ? ang
|
| : ang - spread1/2 + j/(kids.length-1)*spread1;
|
| pos[ki] = {{x: pos[ri].x+R2*Math.cos(a), y: pos[ri].y+R2*Math.sin(a), ang:a}};
|
|
|
| var gkids = tree.children[ki];
|
| var spread2 = Math.min(Math.PI*0.5, gkids.length*0.35);
|
| gkids.forEach(function(gi,gj){{
|
| var ga = gkids.length===1 ? a
|
| : a - spread2/2 + gj/(gkids.length-1)*spread2;
|
| pos[gi] = {{x: pos[ki].x+R3*Math.cos(ga), y: pos[ki].y+R3*Math.sin(ga), ang:ga}};
|
| }});
|
| }});
|
| }});
|
| return pos;
|
| }}
|
|
|
| function _bezier(svg, x1,y1,x2,y2, branchAng, col, w){{
|
| var dist = Math.sqrt((x2-x1)*(x2-x1)+(y2-y1)*(y2-y1));
|
| var cp1x = x1+Math.cos(branchAng)*dist*0.5;
|
| var cp1y = y1+Math.sin(branchAng)*dist*0.5;
|
| var cp2x = x2-Math.cos(branchAng)*dist*0.25;
|
| var cp2y = y2-Math.sin(branchAng)*dist*0.25;
|
| var p = svgEl('path');
|
| p.setAttribute('d','M '+x1+' '+y1+' C '+cp1x+' '+cp1y+' '+cp2x+' '+cp2y+' '+x2+' '+y2);
|
| p.setAttribute('stroke',col); p.setAttribute('stroke-width',w);
|
| p.setAttribute('fill','none'); p.setAttribute('stroke-opacity','0.55');
|
| p.setAttribute('stroke-linecap','round');
|
| svg.appendChild(p);
|
| }}
|
|
|
| function drawMap(query, results){{
|
| _lastQuery = query;
|
| var svg = document.getElementById('web');
|
| var W = svg.parentElement.offsetWidth || 700;
|
| var H = Math.max(520, Math.min(W*0.82, 700));
|
| svg.setAttribute('height', H);
|
| svg.innerHTML = '';
|
| var cx=W/2, cy=H/2;
|
|
|
| var vp = svgEl('g'); vp.setAttribute('id','viewport');
|
| svg.appendChild(vp);
|
|
|
| // Nodo centrale
|
| var cc=svgEl('circle'); cc.setAttribute('cx',cx); cc.setAttribute('cy',cy); cc.setAttribute('r','22');
|
| cc.setAttribute('fill','#ff7b00'); cc.style.filter='drop-shadow(0 0 12px rgba(255,123,0,.55))';
|
| var ql=query.length>11?query.slice(0,11)+'…':query;
|
| var ct=svgEl('text'); ct.setAttribute('x',cx); ct.setAttribute('y',cy+4);
|
| ct.setAttribute('text-anchor','middle'); ct.setAttribute('font-size','9'); ct.setAttribute('fill','#fff');
|
| ct.setAttribute('font-weight','700'); ct.setAttribute('font-family','Outfit,sans-serif'); ct.textContent=ql;
|
| vp.appendChild(cc); vp.appendChild(ct);
|
|
|
| if(results.length===0){{
|
| var t=svgEl('text'); t.setAttribute('x',cx); t.setAttribute('y',cy+50);
|
| t.setAttribute('text-anchor','middle'); t.setAttribute('fill','#444'); t.setAttribute('font-size','12');
|
| t.textContent='Il ragno inizia a tessere la rete…'; vp.appendChild(t);
|
| applyTransform();
|
| return;
|
| }}
|
|
|
| var tree = _buildTree(results);
|
| var bcolor = _assignColors(results, tree);
|
| var pos = _computePositions(results, tree, cx, cy, W, H);
|
|
|
| // Applica eventuali posizioni trascinate manualmente
|
| Object.keys(nodeOverrides).forEach(function(k){{
|
| var idx = parseInt(k, 10);
|
| if(pos[idx]) {{ pos[idx].x = nodeOverrides[idx].x; pos[idx].y = nodeOverrides[idx].y; }}
|
| }});
|
|
|
| // Linee: prima dei nodi (sotto)
|
| results.forEach(function(r,i){{
|
| if(!pos[i]) return;
|
| var col=bcolor[i];
|
| var ang=pos[i].ang;
|
| var d=r.depth||0;
|
| var lw = d===0 ? 2.5 : d===1 ? 1.8 : 1.2;
|
| var pt=r.parent;
|
| if(!pt||pt==='query'||tree.nodeMap[pt]===undefined){{
|
| _bezier(vp, cx,cy, pos[i].x,pos[i].y, ang, col, lw+0.8);
|
| }} else {{
|
| var pi=tree.nodeMap[pt];
|
| if(pos[pi]) _bezier(vp, pos[pi].x,pos[pi].y, pos[i].x,pos[i].y, ang, col, lw);
|
| }}
|
| }});
|
|
|
| // Nodi
|
| results.forEach(function(r,i){{
|
| if(!pos[i]) return;
|
| var p=pos[i]; var col=bcolor[i];
|
| var d=r.depth||0; var rs=d===0?13:d===1?10:7;
|
| var g=svgEl('g'); g.style.cursor='grab';
|
| g.style.animation='nodeIn 0.4s ease-out';
|
|
|
| var circ=svgEl('circle'); circ.setAttribute('cx',p.x); circ.setAttribute('cy',p.y);
|
| circ.setAttribute('r',rs); circ.setAttribute('fill',col); circ.setAttribute('opacity','0.92');
|
| circ.style.transition='r 0.15s';
|
|
|
| // Label fuori dal nodo, lungo la direzione del ramo
|
| var lo=rs+5;
|
| var lx=p.x+lo*Math.cos(p.ang); var ly=p.y+lo*Math.sin(p.ang);
|
| var anchor=Math.cos(p.ang)>0.25?'start':Math.cos(p.ang)<-0.25?'end':'middle';
|
| var lbl=svgEl('text'); lbl.setAttribute('x',lx); lbl.setAttribute('y',ly+4);
|
| lbl.setAttribute('text-anchor',anchor); lbl.setAttribute('font-size',d===0?'11':'9');
|
| lbl.setAttribute('fill','rgba(255,255,255,0.75)'); lbl.setAttribute('font-family','Outfit,sans-serif');
|
| var tt=r.title||('Pagina '+(i+1));
|
| lbl.textContent=tt.length>26?tt.slice(0,26)+'…':tt;
|
|
|
| g.appendChild(circ); g.appendChild(lbl);
|
| g.addEventListener('mouseenter',(function(ci,rs2){{ return function(){{ ci.setAttribute('r',rs2+4); ci.setAttribute('opacity','1'); }}; }})(circ,rs));
|
| g.addEventListener('mouseleave',(function(ci,rs2,ii){{ return function(){{ if(selIdx!==ii){{ ci.setAttribute('r',rs2); ci.setAttribute('opacity','0.92'); }} }}; }})(circ,rs,i));
|
| g.addEventListener('click',(function(ii){{ return function(){{ showPanel(ii); }}; }})(i));
|
| g.addEventListener('mousedown',(function(ii){{ return function(e){{ e.stopPropagation(); draggingNode = ii; }}; }})(i));
|
| vp.appendChild(g);
|
| }});
|
|
|
| applyTransform();
|
| }}
|
|
|
| function showPanel(idx){{
|
| selIdx = idx;
|
| var res = webData[idx];
|
| if(!res) return;
|
| var panel = document.getElementById('panel');
|
| panel.style.display = 'block';
|
| var col = nodeColors[idx % nodeColors.length];
|
| var title = res.title||'Pagina';
|
| var text = res.text||'';
|
| var url = res.url||'#';
|
| var depth = res.depth !== undefined ? res.depth : '?';
|
| var rel = Math.round((res.relevance||0)*100);
|
| var prev = text.length>600 ? text.slice(0,600)+'...' : text;
|
|
|
| var h = '';
|
| h += '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px">';
|
| h += '<h2 style="font-size:13px;line-height:1.4;flex:1;color:#fff;margin-right:8px">'+escH(title)+'</h2>';
|
| h += '<button onclick="closePanel()" style="background:none;border:none;color:#888;font-size:18px;cursor:pointer;padding:0;line-height:1">✕</button>';
|
| h += '</div>';
|
| h += '<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px;font-size:11px">';
|
| h += '<span style="background:'+col+'22;color:'+col+';border:1px solid '+col+'44;padding:3px 8px;border-radius:6px">Pagina #'+(idx+1)+'</span>';
|
| h += '<span style="background:rgba(255,255,255,0.05);color:#aaa;padding:3px 8px;border-radius:6px">Profondità '+depth+'</span>';
|
| h += '<span style="background:rgba(255,255,255,0.05);color:#aaa;padding:3px 8px;border-radius:6px">Rilevanza '+rel+'%</span>';
|
| h += '</div>';
|
| h += '<div style="font-size:12px;line-height:1.7;color:#ccc;margin-bottom:12px;max-height:220px;overflow-y:auto;background:rgba(0,0,0,0.25);padding:10px;border-radius:10px">'+escH(prev)+'</div>';
|
| h += '<a href="'+escH(url)+'" target="_blank" rel="noopener" ';
|
| h += 'style="display:inline-flex;align-items:center;gap:6px;color:'+col+';font-size:12px;';
|
| h += 'border:1px solid '+col+'55;padding:6px 12px;border-radius:8px;text-decoration:none;margin-bottom:16px">';
|
| h += '🔗 Vai alla fonte ↗</a>';
|
| h += '<div style="border-top:1px solid var(--glass-border);padding-top:12px">';
|
| h += '<div style="font-size:12px;color:var(--text-dim);margin-bottom:8px">Chiedi alla AI su questa fonte</div>';
|
| h += '<div style="display:flex;gap:8px">';
|
| h += '<input id="ai-q" placeholder="La tua domanda..." style="flex:1;font-size:13px;padding:10px">';
|
| h += '<button class="btn btn-primary" style="padding:10px 14px;font-size:12px" onclick="askAI()">🤖</button>';
|
| h += '</div>';
|
| h += '<div id="ai-out" style="margin-top:10px;font-size:12px;line-height:1.7;color:#e0e0e0;min-height:0"></div>';
|
| h += '</div>';
|
| panel.innerHTML = h;
|
| }}
|
|
|
| function closePanel(){{
|
| document.getElementById('panel').style.display = 'none';
|
| selIdx = null;
|
| }}
|
|
|
| function askAI(){{
|
| var res = webData[selIdx];
|
| if(!res) return;
|
| var q = document.getElementById('ai-q').value.trim();
|
| if(!q) return;
|
| var out = document.getElementById('ai-out');
|
| out.innerHTML = '<span style="color:var(--text-dim)">Il ragno legge la fonte...</span>';
|
| var ctx = (res.text||'').slice(0,500);
|
| fetch('{BASE}/ask/stream', {{
|
| method:'POST', headers:{{'Content-Type':'application/json'}},
|
| body: JSON.stringify({{prompt: q + '\\n\\nContesto dalla pagina "'+res.title+'":\\n' + ctx}})
|
| }}).then(function(r){{
|
| var reader = r.body.getReader(), dec = new TextDecoder();
|
| out.innerHTML = '';
|
| function read(){{
|
| reader.read().then(function(chunk){{
|
| if(chunk.done) return;
|
| dec.decode(chunk.value,{{stream:true}}).split('\\n').forEach(function(line){{
|
| if(!line.startsWith('data:')) return;
|
| try{{
|
| var d = JSON.parse(line.slice(5));
|
| if(d.type==='result') out.innerText = d.message;
|
| }}catch(e){{}}
|
| }});
|
| read();
|
| }}).catch(function(e){{ out.innerHTML='<span style="color:#ff3b30">'+e.message+'</span>'; }});
|
| }}
|
| read();
|
| }}).catch(function(e){{ out.innerHTML='<span style="color:#ff3b30">'+e.message+'</span>'; }});
|
| }}
|
|
|
| function escH(s){{
|
| return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
| }}
|
|
|
| window.addEventListener('resize', function(){{
|
| if(webData.length > 0) drawMap(document.getElementById('q').value, webData);
|
| }});
|
| </script>"""
|
| return HTMLResponse(page("Rete Ragno", content, "/ragno"))
|
|
|
|
|
|
|
| @app.get("/status")
|
| async def cronjob_status():
|
| api_key = os.environ.get("CRONJOB_API_KEY","")
|
| if not api_key:
|
| return {"error": "CRONJOB_API_KEY non impostata"}
|
| def fetch():
|
| hdrs = {"Authorization": f"Bearer {api_key}"}
|
| jobs_r = req.get("https://api.cron-job.org/jobs", headers=hdrs, timeout=8)
|
| if jobs_r.status_code != 200:
|
| return {"error": f"HTTP {jobs_r.status_code}"}
|
| result = []
|
| for job in jobs_r.json().get("jobs",[]):
|
| jid = job.get("jobId")
|
| hist_r = req.get(f"https://api.cron-job.org/jobs/{jid}/history", headers=hdrs, timeout=8)
|
| history = []
|
| if hist_r.status_code == 200:
|
| history = [{"date":e.get("date"),"status":e.get("status"),
|
| "duration":e.get("duration"),"httpStatus":e.get("httpStatus")}
|
| for e in hist_r.json().get("history",[])[:20]]
|
| ok_n = sum(1 for h in history if h["status"]==1)
|
| last = history[0] if history else {}
|
| result.append({"jobId":jid,"title":job.get("title",""),"url":job.get("url",""),
|
| "enabled":job.get("enabled",False),"last_run":last.get("date"),
|
| "last_status":"ok" if last.get("status")==1 else "fail" if last else "unknown",
|
| "last_http":last.get("httpStatus"),
|
| "uptime_pct":round(ok_n/len(history)*100,1) if history else None,
|
| "ok_count":ok_n,"fail_count":len(history)-ok_n,"history":history})
|
| return {"jobs":result,"total":len(result)}
|
| return await asyncio.to_thread(fetch)
|
|
|
| @app.get("/status/ui", response_class=HTMLResponse)
|
| def status_ui():
|
| content = f"""
|
| <h1>📊 Monitoraggio Operativo</h1>
|
| <p class="sub">Analisi in tempo reale delle attività automatiche e della stabilità dei servizi.</p>
|
| <div class="card" id="loading"><p style="color:var(--text-dim)">Sincronizzazione dati in corso...</p></div>
|
| <div id="jobs-container"></div>
|
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
| <script>
|
| let chartInstances = {{}};
|
|
|
| function destroyChart(id) {{
|
| if (chartInstances[id]) {{ chartInstances[id].destroy(); delete chartInstances[id]; }}
|
| }}
|
|
|
| function buildJobCard(job, idx) {{
|
| const sc = job.last_status==='ok'?'#3cb371':job.last_status==='fail'?'#e05555':'#888';
|
| const up = job.uptime_pct!=null ? job.uptime_pct+'%' : '—';
|
| const uc = job.uptime_pct>=95?'#3cb371':job.uptime_pct>=80?'#f0a000':'#e05555';
|
| const last = job.last_run ? new Date(job.last_run*1000).toLocaleString('it-IT') : '—';
|
|
|
| return `<div class="card">
|
| <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:16px">
|
| <div>
|
| <h2 style="font-size:17px">${{job.title||'Senza nome'}}</h2>
|
| <div style="font-size:12px;color:#888;margin-top:4px;font-family:monospace">${{job.url}}</div>
|
| <div style="font-size:12px;color:#888;margin-top:2px">Ultimo controllo: <b style="color:#e0e0e0">${{last}}</b></div>
|
| </div>
|
| <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
| <span class="badge" style="background:#1a1a2a;color:${{sc}};border:1px solid ${{sc}};font-size:13px">
|
| ${{job.last_status==='ok'?'[OK] Funziona':'[X] Errore'}}
|
| </span>
|
| <span class="badge" style="background:#1a1a2a;color:${{uc}};border:1px solid ${{uc}};font-size:13px">
|
| Disponibile ${{up}}
|
| </span>
|
| ${{job.enabled?'<span class="badge green">Attivo</span>':'<span class="badge red">Disattivato</span>'}}
|
| </div>
|
| </div>
|
|
|
| <!-- Metriche -->
|
| <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px;margin-bottom:20px">
|
| <div style="background:#13151f;border-radius:8px;padding:14px;text-align:center">
|
| <div style="font-size:24px;font-weight:700;color:#3cb371">${{job.ok_count}}</div>
|
| <div style="font-size:12px;color:#888;margin-top:4px">Riusciti</div>
|
| </div>
|
| <div style="background:#13151f;border-radius:8px;padding:14px;text-align:center">
|
| <div style="font-size:24px;font-weight:700;color:#e05555">${{job.fail_count}}</div>
|
| <div style="font-size:12px;color:#888;margin-top:4px">Falliti</div>
|
| </div>
|
| <div style="background:#13151f;border-radius:8px;padding:14px;text-align:center">
|
| <div style="font-size:24px;font-weight:700;color:${{uc}}">${{up}}</div>
|
| <div style="font-size:12px;color:#888;margin-top:4px">Disponibilità</div>
|
| </div>
|
| <div style="background:#13151f;border-radius:8px;padding:14px;text-align:center">
|
| <div style="font-size:24px;font-weight:700;color:#7c9ef8">${{job.history.length}}</div>
|
| <div style="font-size:12px;color:#888;margin-top:4px">Esecuzioni tracciate</div>
|
| </div>
|
| </div>
|
|
|
| <!-- Grafico torta: ok vs fail -->
|
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px">
|
| <div style="background:#13151f;border-radius:8px;padding:16px">
|
| <div style="font-size:13px;color:#888;margin-bottom:10px;font-weight:500">Riusciti vs Falliti</div>
|
| <div style="position:relative;height:160px">
|
| <canvas id="pie-${{idx}}"></canvas>
|
| </div>
|
| </div>
|
| <!-- Grafico linea: durata esecuzioni -->
|
| <div style="background:#13151f;border-radius:8px;padding:16px">
|
| <div style="font-size:13px;color:#888;margin-bottom:10px;font-weight:500">Tempo di risposta (ms)</div>
|
| <div style="position:relative;height:160px">
|
| <canvas id="line-${{idx}}"></canvas>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <!-- Barra storia -->
|
| <div style="background:#13151f;border-radius:8px;padding:16px">
|
| <div style="font-size:13px;color:#888;margin-bottom:10px;font-weight:500">
|
| Ultime esecuzioni — <span style="color:#3cb371">- ok</span> <span style="color:#e05555;margin-left:8px">- errore</span>
|
| <span style="color:#888;margin-left:8px;font-size:11px">(passa sopra per i dettagli)</span>
|
| </div>
|
| <div style="display:flex;align-items:flex-end;gap:3px;height:40px">
|
| ${{job.history.slice(0,30).reverse().map(h => {{
|
| const c = h.status===1?'#3cb371':'#e05555';
|
| const dt = new Date(h.date*1000).toLocaleString('it-IT');
|
| const ms = h.duration ? h.duration+'ms' : '';
|
| return `<div title="${{dt}}${{ms?' — '+ms:''}}" style="flex:1;background:${{c}};border-radius:2px;height:100%;cursor:help;transition:.2s" onmouseover="this.style.opacity='.7'" onmouseout="this.style.opacity='1'"></div>`;
|
| }}).join('')}}
|
| </div>
|
| </div>
|
| </div>`;
|
| }}
|
|
|
| async function load() {{
|
| const r = await fetch('{BASE}/status');
|
| const d = await r.json();
|
| document.getElementById('loading').style.display='none';
|
|
|
| if (d.error) {{
|
| document.getElementById('jobs-container').innerHTML =
|
| `<div class="card">
|
| <p style="color:#e05555;font-size:15px">[!] ${{d.error}}</p>
|
| <p style="color:#888;margin-top:12px">Per attivare il monitoraggio:</p>
|
| <ol style="color:#888;margin-top:8px;padding-left:20px;line-height:2">
|
| <li>Vai su <b style="color:#7c9ef8">cron-job.org</b> -> in alto a destra -> API -> copia la chiave</li>
|
| <li>Vai su <b style="color:#7c9ef8">huggingface.co/spaces/amogaddy/GenerAI</b> -> Settings -> Secrets</li>
|
| <li>Aggiungi un segreto chiamato <b>CRONJOB_API_KEY</b> con la chiave che hai copiato</li>
|
| </ol>
|
| </div>`;
|
| return;
|
| }}
|
|
|
| if (!d.jobs || d.jobs.length === 0) {{
|
| document.getElementById('jobs-container').innerHTML =
|
| '<div class="card"><p style="color:#888">Nessun cron job trovato nel tuo account.</p></div>';
|
| return;
|
| }}
|
|
|
| let html = '';
|
| d.jobs.forEach((job, idx) => html += buildJobCard(job, idx));
|
| document.getElementById('jobs-container').innerHTML = html;
|
|
|
| // Disegna grafici dopo che il DOM è pronto
|
| d.jobs.forEach((job, idx) => {{
|
| // Torta ok/fail
|
| destroyChart('pie-'+idx);
|
| chartInstances['pie-'+idx] = new Chart(document.getElementById('pie-'+idx), {{
|
| type: 'doughnut',
|
| data: {{
|
| labels: ['Operativi', 'Falliti'],
|
| datasets: [{{ data: [job.ok_count, job.fail_count],
|
| backgroundColor: ['#34c759','#ff3b30'], borderWidth: 0 }}]
|
| }},
|
| options: {{ responsive:true, maintainAspectRatio:false,
|
| plugins: {{ legend: {{ labels: {{ color:'#9494a3', font:{{size:11, family:'Outfit'}} }} }} }} }}
|
| }});
|
|
|
| // Linea durata nel tempo
|
| const hist = job.history.slice(0,20).reverse();
|
| const labels = hist.map(h => new Date(h.date*1000).toLocaleTimeString('it-IT',{{hour:'2-digit',minute:'2-digit'}}));
|
| const durations = hist.map(h => h.duration || 0);
|
| const pointColors = hist.map(h => h.status===1?'#34c759':'#ff3b30');
|
|
|
| destroyChart('line-'+idx);
|
| chartInstances['line-'+idx] = new Chart(document.getElementById('line-'+idx), {{
|
| type: 'line',
|
| data: {{
|
| labels,
|
| datasets: [{{
|
| label: 'ms',
|
| data: durations,
|
| borderColor: '#ff9f0a',
|
| backgroundColor: 'rgba(255, 159, 10, 0.1)',
|
| pointBackgroundColor: pointColors,
|
| pointRadius: 5,
|
| tension: 0.4,
|
| fill: true,
|
| }}]
|
| }},
|
| options: {{
|
| responsive: true, maintainAspectRatio: false,
|
| plugins: {{ legend: {{ display:false }} }},
|
| scales: {{
|
| x: {{ ticks: {{ color:'#666', font:{{size:10}} }}, grid: {{ color:'rgba(255,255,255,0.05)' }} }},
|
| y: {{ ticks: {{ color:'#666', font:{{size:10}} }}, grid: {{ color:'rgba(255,255,255,0.05)' }} }}
|
| }}
|
| }}
|
| }});
|
| }});
|
| }}
|
|
|
| load();
|
| </script>"""
|
| return HTMLResponse(page("Stato", content, "/status/ui"))
|
|
|
|
|
|
|
| @app.get("/about", response_class=HTMLResponse)
|
| def about_page():
|
| content = """
|
| <h1>Come funziona GenerAI</h1>
|
| <p class="sub">Architettura del sistema e licenze dei componenti usati.</p>
|
|
|
| <div class="card">
|
| <h2>Panoramica</h2>
|
| <p style="line-height:1.8;color:#ccc">GenerAI è un assistente AI in italiano con memoria locale e ricerca web autonoma.
|
| Il backend è scritto in Python (FastAPI). Non usa un motore di ricerca esterno come Google o Bing tramite API a pagamento:
|
| la ricerca web avviene tramite un browser headless (Chromium via Playwright) che naviga direttamente Bing, con Wikipedia
|
| come fallback se non trova risultati utili.</p>
|
| </div>
|
|
|
| <div class="card">
|
| <h2>Componenti principali</h2>
|
| <table>
|
| <tr><th>Componente</th><th>Cosa fa</th></tr>
|
| <tr><td>Cervello (brain.py)</td><td>Decide se rispondere dalla memoria locale, cercare sul web, o usare un modello LLM esterno</td></tr>
|
| <tr><td>Memoria (knowledge_base.py)</td><td>Database vettoriale locale (ChromaDB) che impara dalle domande frequenti</td></tr>
|
| <tr><td>Ricerca web (scraper.py)</td><td>Chromium headless (Playwright) su Bing, con fallback Wikipedia</td></tr>
|
| <tr><td>Rete Ragno (crawler.py)</td><td>Esplorazione autonoma del web salto per salto, a partire da Wikipedia, con crediti per utente</td></tr>
|
| <tr><td>Login (auth.py)</td><td>Autenticazione Google OAuth, crediti per account</td></tr>
|
| <tr><td>Backup (mega_sync.py)</td><td>Sincronizzazione del database su MEGA</td></tr>
|
| <tr><td>World Monitor</td><td>Dashboard di intelligence globale (notizie, mappe, dati geopolitici) integrata come componente separato — vedi licenza sotto</td></tr>
|
| </table>
|
| </div>
|
|
|
| <div class="card">
|
| <h2>Licenze</h2>
|
| <p style="line-height:1.8;color:#ccc;margin-bottom:16px">
|
| Il codice originale di <b>GenerAI</b> (questo assistente: brain, scraper, crawler, knowledge base, interfaccia) è
|
| rilasciato con licenza <b>MIT</b> — puoi vederlo, copiarlo e modificarlo liberamente.
|
| </p>
|
| <p style="line-height:1.8;color:#ccc">
|
| Il componente <b>World Monitor</b>, se presente in questo Space, è un progetto di terze parti di
|
| <a href="https://github.com/koala73/worldmonitor" target="_blank" rel="noopener" style="color:var(--primary)">koala73/worldmonitor</a>,
|
| distribuito con licenza <b>AGPL-3.0-only</b>. L'AGPL richiede che, essendo un servizio consultabile in rete, il codice
|
| sorgente (comprese eventuali modifiche fatte per farlo girare qui) resti disponibile a chi lo usa — il codice sorgente
|
| originale è pubblico al link sopra. GenerAI non rivendica alcuna proprietà su World Monitor: è integrato così com'è,
|
| nel rispetto della sua licenza.
|
| </p>
|
| </div>
|
| """
|
| return HTMLResponse(page("Come funziona", content, "/about"))
|
|
|
|
|
|
|
| @app.get("/commands", response_class=HTMLResponse)
|
| def commands_page():
|
| def cmd_block(bid, label, ps, cmd):
|
| return f"""<div class="card" id="{bid}">
|
| <div style="font-size:14px; color:var(--text-dim); margin-bottom:12px">{label}</div>
|
| <div style="display:flex;gap:8px;margin-bottom:12px">
|
| <span class="tab active" id="{bid}-tab-ps" onclick="setTab('{bid}','ps')">PowerShell</span>
|
| <span class="tab" id="{bid}-tab-cmd" onclick="setTab('{bid}','cmd')">Command Prompt</span>
|
| </div>
|
| <div id="{bid}-ps" class="cmd-text">{ps}</div>
|
| <div id="{bid}-cmd" class="cmd-text" style="display:none">{cmd}</div>
|
| <div style="display:flex;gap:12px; margin-top:16px">
|
| <button class="btn btn-secondary" style="padding:6px 16px; font-size:12px" id="{bid}-btn-ps" onclick="copy('{bid}','ps')">Copia Comando</button>
|
| <button class="btn btn-secondary" style="padding:6px 16px; font-size:12px" id="{bid}-btn-cmd" onclick="copy('{bid}','cmd')" style="display:none">Copia Comando</button>
|
| </div></div>"""
|
|
|
| blocks = {
|
| "Stato": [
|
| cmd_block("b0","Controlla se la AI e online",
|
| f'Invoke-RestMethod "{BASE}/"',
|
| f'curl "{BASE}/"'),
|
| ],
|
| "Chat normale": [
|
| cmd_block("b1","Fai una domanda (risposta completa)",
|
| f'Invoke-RestMethod -Uri "{BASE}/ask" -Method POST -ContentType "application/json" -Body \'{"prompt":"chi ha inventato la lampadina?"}\' | ConvertTo-Json',
|
| f'curl -s -X POST "{BASE}/ask" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"chi ha inventato la lampadina?\\"}}"'),
|
| cmd_block("b2","Domanda con passi in tempo reale (streaming)",
|
| f'curl -N -X POST "{BASE}/ask/stream" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"chi ha inventato la lampadina?\\"}}"',
|
| f'curl -N -X POST "{BASE}/ask/stream" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"chi ha inventato la lampadina?\\"}}"'),
|
| ],
|
| "Feedback": [
|
| cmd_block("b11","Risposta utile",
|
| f'Invoke-RestMethod -Uri "{BASE}/feedback?positive=true" -Method POST',
|
| f'curl -X POST "{BASE}/feedback?positive=true"'),
|
| cmd_block("b12","Risposta non utile",
|
| f'Invoke-RestMethod -Uri "{BASE}/feedback?positive=false" -Method POST',
|
| f'curl -X POST "{BASE}/feedback?positive=false"'),
|
| ],
|
| }
|
|
|
| sections = ""
|
| for title, items in blocks.items():
|
| sections += f'<div style="margin-bottom:32px"><h2>{title}</h2>{"".join(items)}</div>'
|
|
|
| content = f"""
|
| <h1>CMD Comandi e Script</h1>
|
| <p class="sub">Copia i comandi per PowerShell o CMD. Scarica gli script completi con menu interattivo.</p>
|
| <div style="display:flex;gap:16px;margin-bottom:32px;flex-wrap:wrap">
|
| <a href="/scripts/powershell" download="generai.ps1" class="btn btn-primary">[DL] Script PowerShell</a>
|
| <a href="/scripts/batch" download="generai.bat" class="btn btn-secondary">[DL] Script CMD</a>
|
| </div>
|
| {sections}
|
| <script>
|
| function setTab(id,tab){{
|
| ['ps','cmd'].forEach(t=>{{
|
| document.getElementById(id+'-'+t).style.display=t===tab?'block':'none';
|
| const btn = document.getElementById(id+'-btn-'+t);
|
| if(btn) btn.style.display=t===tab?'inline-flex':'none';
|
| document.getElementById(id+'-tab-'+t).classList.toggle('active',t===tab);
|
| }});
|
| }}
|
| function copy(id,tab){{
|
| navigator.clipboard.writeText(document.getElementById(id+'-'+tab).innerText);
|
| const btn=document.getElementById(id+'-btn-'+tab);
|
| const old = btn.innerText;
|
| btn.innerText='Copiato!'; btn.style.borderColor="#34c759";
|
| setTimeout(()=>{{btn.innerText=old; btn.style.borderColor="";}},1500);
|
| }}
|
| </script>"""
|
| return HTMLResponse(page("Comandi", content, "/commands"))
|
|
|
|
|
|
|
| @app.get("/scripts/powershell")
|
| def script_ps():
|
| s = f"""# GenerAI — Script PowerShell
|
| # Come usarlo: apri PowerShell, vai nella cartella dove hai salvato il file, scrivi: .\\generai.ps1
|
|
|
| $BASE = "{BASE}"
|
|
|
| function Ask($d) {{
|
| $body = @{{prompt = $d}} | ConvertTo-Json
|
| $r = Invoke-RestMethod -Uri "$BASE/ask" -Method POST -ContentType "application/json" -Body $body
|
| Write-Host ""
|
| Write-Host "Risposta:" -ForegroundColor Cyan
|
| Write-Host $r.result
|
| Write-Host "(Fonte: $($r.status))" -ForegroundColor DarkGray
|
| }}
|
| function AskStream($d) {{
|
| $body = @{{prompt = $d}} | ConvertTo-Json -Compress
|
| Write-Host "Cosa sta facendo la AI:" -ForegroundColor Yellow
|
| curl -s -N -X POST "$BASE/ask/stream" -H "Content-Type: application/json" -d $body
|
| }}
|
| function Stato {{
|
| $r = Invoke-RestMethod "$BASE/"
|
| Write-Host "AI accesa: $($r.status)" -ForegroundColor Green
|
| Write-Host "Cose in memoria: $($r.kb_size)"
|
| }}
|
| function Feedback($positivo) {{
|
| Invoke-RestMethod -Uri "$BASE/feedback?positive=$positivo" -Method POST | Out-Null
|
| if ($positivo -eq "true") {{ Write-Host "Grazie! La AI imparera' da questa risposta." -ForegroundColor Green }}
|
| else {{ Write-Host "Capito, la AI cerchera' di migliorare." -ForegroundColor Yellow }}
|
| }}
|
|
|
| while ($true) {{
|
| Write-Host ""
|
| Write-Host "========== GENERAI ==========" -ForegroundColor Magenta
|
| Write-Host "1. Fai una domanda"
|
| Write-Host "2. Fai una domanda (vedi i passi in diretta)"
|
| Write-Host "3. Controlla se la AI e' accesa"
|
| Write-Host "4. Questa risposta era utile"
|
| Write-Host "5. Questa risposta non era utile"
|
| Write-Host "0. Esci"
|
| $s = Read-Host "Cosa vuoi fare?"
|
| switch ($s) {{
|
| "1" {{ Ask (Read-Host "Scrivi la tua domanda") }}
|
| "2" {{ AskStream (Read-Host "Scrivi la tua domanda") }}
|
| "3" {{ Stato }}
|
| "4" {{ Feedback "true" }}
|
| "5" {{ Feedback "false" }}
|
| "0" {{ Write-Host "A presto!"; exit }}
|
| default {{ Write-Host "Non ho capito, riprova." -ForegroundColor Red }}
|
| }}
|
| }}
|
| """
|
| return Response(content=s, media_type="text/plain",
|
| headers={"Content-Disposition": "attachment; filename=generai.ps1"})
|
|
|
| @app.get("/scripts/batch")
|
| def script_bat():
|
| s = f"""@echo off
|
| chcp 65001 >nul
|
| set BASE={BASE}
|
|
|
| :menu
|
| echo.
|
| echo ========== GENERAI ==========
|
| echo 1. Fai una domanda
|
| echo 2. Fai una domanda (vedi i passi in diretta)
|
| echo 3. Controlla se la AI e' accesa
|
| echo 0. Esci
|
| set /p s=Cosa vuoi fare?
|
|
|
| if "%s%"=="1" goto ask
|
| if "%s%"=="2" goto stream
|
| if "%s%"=="3" goto stato
|
| if "%s%"=="0" goto fine
|
| echo Non ho capito, riprova.
|
| goto menu
|
|
|
| :ask
|
| set /p d=Scrivi la tua domanda:
|
| curl -s -X POST "%BASE%/ask" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"%d%\\"}}"
|
| echo.
|
| goto menu
|
|
|
| :stream
|
| set /p d=Scrivi la tua domanda:
|
| curl -N -X POST "%BASE%/ask/stream" -H "Content-Type: application/json" -d "{{\\"prompt\\":\\"%d%\\"}}"
|
| echo.
|
| goto menu
|
|
|
| :stato
|
| curl -s "%BASE%/"
|
| echo.
|
| goto menu
|
|
|
| :fine
|
| echo A presto!
|
| exit /b
|
| """
|
| return Response(content=s, media_type="text/plain",
|
| headers={"Content-Disposition": "attachment; filename=generai.bat"})
|
|
|
|
|
|
|
| class AskRequest(BaseModel):
|
| prompt: str
|
|
|
| def _schedule_mega_upload():
|
| email = os.environ.get("MEGA_EMAIL", "")
|
| password = os.environ.get("MEGA_PASSWORD", "")
|
| if email and password:
|
| from mega_sync import upload_db
|
| asyncio.create_task(asyncio.to_thread(upload_db, email, password))
|
|
|
| @app.on_event("startup")
|
| async def startup():
|
| global brain
|
| email = os.environ.get("MEGA_EMAIL", "")
|
| password = os.environ.get("MEGA_PASSWORD", "")
|
| if email and password:
|
| from mega_sync import download_db
|
| log.info("Scarico DB da MEGA...")
|
| await asyncio.to_thread(download_db, email, password)
|
| else:
|
| log.warning("MEGA_EMAIL/MEGA_PASSWORD non impostati — DB solo locale.")
|
| brain = Brain()
|
|
|
| @app.post("/ask")
|
| async def ask(request: AskRequest):
|
| if not brain:
|
| raise HTTPException(status_code=503, detail="AI non ancora pronta")
|
| answer, status = await brain.ask(request.prompt)
|
| if brain._last_doc_id:
|
| _schedule_mega_upload()
|
| return {"result": answer, "status": status}
|
|
|
| @app.post("/ask/stream")
|
| async def ask_stream(request: AskRequest):
|
| if not brain:
|
| raise HTTPException(status_code=503, detail="AI non ancora pronta")
|
| async def generate():
|
| queue: asyncio.Queue = asyncio.Queue()
|
| async def on_status(msg): await queue.put(("status", msg))
|
| async def run():
|
| a, s = await brain.ask(request.prompt, on_status=on_status)
|
| await queue.put(("result", a, s))
|
| if brain._last_doc_id:
|
| _schedule_mega_upload()
|
| task = asyncio.create_task(run())
|
| while True:
|
| item = await queue.get()
|
| if item[0] == "status":
|
| yield f"data: {json.dumps({'type':'status','message':item[1]})}\n\n"
|
| elif item[0] == "result":
|
| yield f"data: {json.dumps({'type':'result','message':item[1],'status':item[2]})}\n\n"
|
| break
|
| await task
|
| return StreamingResponse(generate(), media_type="text/event-stream",
|
| headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no","Connection":"keep-alive"})
|
|
|
| @app.post("/feedback")
|
| def feedback(positive: bool):
|
| if brain: brain.give_feedback(positive)
|
| return {"ok": True}
|
|
|
| @app.post("/search")
|
| async def search_raw(request: AskRequest):
|
| from scraper import search_and_extract
|
| try:
|
| results = await asyncio.to_thread(search_and_extract, request.prompt, max_results=8)
|
| except Exception as e:
|
| log.warning("Search raw fallita: %s", fmt_exc(e))
|
| results = []
|
| return {"query": request.prompt, "results": results}
|
|
|
|
|
|
|
| @app.websocket("/ws/chat")
|
| async def ws_chat(ws: WebSocket):
|
| """
|
| WebSocket per input/output continui con storia della conversazione.
|
| Il client manda: {"prompt": "tua domanda"}
|
| Il server risponde con messaggi di stato e poi la risposta finale.
|
| """
|
| await ws.accept()
|
| history = []
|
|
|
| await ws.send_text(json.dumps({
|
| "type": "connected",
|
| "message": "Connesso a GenerAI. Manda {\"prompt\": \"la tua domanda\"}."
|
| }))
|
|
|
| try:
|
| while True:
|
| raw = await ws.receive_text()
|
| try:
|
| data = json.loads(raw)
|
| except Exception:
|
| await ws.send_text(json.dumps({"type":"error","message":"JSON non valido"}))
|
| continue
|
|
|
| prompt = data.get("prompt", "").strip()
|
| if not prompt:
|
| await ws.send_text(json.dumps({"type":"error","message":"Campo 'prompt' mancante"}))
|
| continue
|
|
|
| if not brain:
|
| await ws.send_text(json.dumps({"type":"error","message":"AI non ancora pronta"}))
|
| continue
|
|
|
|
|
| async def on_status(msg: str):
|
| await ws.send_text(json.dumps({"type":"status","message":msg}))
|
|
|
| answer, status = await brain.ask(prompt, on_status=on_status)
|
|
|
|
|
| history.append({"role":"user","content":prompt})
|
| history.append({"role":"assistant","content":answer})
|
|
|
| await ws.send_text(json.dumps({
|
| "type": "result",
|
| "message": answer,
|
| "status": status,
|
| "history_len": len(history) // 2,
|
| }))
|
|
|
| except WebSocketDisconnect:
|
| pass
|
|
|
| @app.get("/ws/ui", response_class=HTMLResponse)
|
| def ws_ui():
|
| content = f"""
|
| <h1>Sessione continua</h1>
|
| <p class="sub">Connessione persistente — manda quante domande vuoi senza ricaricare la pagina. La sessione ricorda tutto.</p>
|
|
|
| <div class="card" id="conn-card">
|
| <div style="display:flex;align-items:center;gap:12px">
|
| <div id="dot" style="width:12px;height:12px;border-radius:50%;background:#e05555"></div>
|
| <span id="conn-status" style="font-size:14px;color:var(--text-dim)">Non connesso</span>
|
| <button class="btn btn-primary" id="conn-btn" onclick="toggleConn()" style="margin-left:auto">Connetti</button>
|
| </div>
|
| </div>
|
|
|
| <div id="chat-area" style="display:none">
|
| <div class="card" id="messages" style="min-height:300px;max-height:500px;overflow-y:auto;display:flex;flex-direction:column;gap:12px;font-size:15px;line-height:1.7">
|
| <span style="color:var(--text-dim);font-size:13px">La conversazione apparira qui...</span>
|
| </div>
|
|
|
| <div class="card" style="margin-top:0">
|
| <div style="display:flex;gap:10px">
|
| <input id="inp" placeholder="Scrivi e premi Invio..." style="flex:1" onkeydown="if(event.key==='Enter')send()">
|
| <button class="btn btn-primary" onclick="send()">Invia</button>
|
| </div>
|
| <div style="font-size:12px;color:var(--text-dim);margin-top:8px">
|
| Turni: <span id="turn-count">0</span> | Stato: <span id="ws-state">-</span>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <script>
|
| let ws = null;
|
|
|
| function toggleConn(){{
|
| if(ws && ws.readyState===WebSocket.OPEN){{ ws.close(); return; }}
|
| const url = '{BASE}'.replace('https://','wss://').replace('http://','ws://') + '/ws/chat';
|
| ws = new WebSocket(url);
|
|
|
| ws.onopen = function(){{
|
| setDot('green'); setStatus('Connesso');
|
| document.getElementById('conn-btn').innerText = 'Disconnetti';
|
| document.getElementById('chat-area').style.display = 'block';
|
| document.getElementById('ws-state').innerText = 'aperta';
|
| }};
|
|
|
| ws.onclose = function(){{
|
| setDot('red'); setStatus('Disconnesso');
|
| document.getElementById('conn-btn').innerText = 'Connetti';
|
| document.getElementById('ws-state').innerText = 'chiusa';
|
| }};
|
|
|
| ws.onerror = function(){{
|
| setDot('orange'); setStatus('Errore connessione');
|
| }};
|
|
|
| ws.onmessage = function(e){{
|
| const d = JSON.parse(e.data);
|
| if(d.type==='connected') addMsg('system', d.message);
|
| else if(d.type==='status') addMsg('status', d.message);
|
| else if(d.type==='result'){{
|
| addMsg('ai', d.message);
|
| document.getElementById('turn-count').innerText = d.history_len;
|
| }}
|
| else if(d.type==='error') addMsg('error', d.message);
|
| }};
|
| }}
|
|
|
| function send(){{
|
| const inp = document.getElementById('inp');
|
| const q = inp.value.trim(); if(!q) return;
|
| if(!ws||ws.readyState!==WebSocket.OPEN){{ alert('Non connesso!'); return; }}
|
| addMsg('user', q);
|
| ws.send(JSON.stringify({{prompt:q}}));
|
| inp.value='';
|
| }}
|
|
|
| function addMsg(role, text){{
|
| const box = document.getElementById('messages');
|
| const div = document.createElement('div');
|
| const styles = {{
|
| user: 'background:rgba(255,123,0,0.12);border:1px solid rgba(255,123,0,0.2);border-radius:12px;padding:12px 16px;align-self:flex-end;max-width:80%',
|
| ai: 'background:var(--glass-heavy);border:1px solid var(--glass-border);border-radius:12px;padding:12px 16px;align-self:flex-start;max-width:90%',
|
| status: 'font-size:12px;color:var(--text-dim);padding:2px 8px;border-left:2px solid var(--glass-border)',
|
| system: 'font-size:12px;color:#ff9f0a;text-align:center',
|
| error: 'font-size:13px;color:#ff3b30',
|
| }};
|
| div.style.cssText = styles[role] || '';
|
| div.innerText = text;
|
| box.appendChild(div);
|
| box.scrollTop = box.scrollHeight;
|
| }}
|
|
|
| function setDot(color){{
|
| const colors = {{green:'#34c759',red:'#ff3b30',orange:'#ff9f0a'}};
|
| document.getElementById('dot').style.background = colors[color]||color;
|
| }}
|
| function setStatus(t){{ document.getElementById('conn-status').innerText=t; }}
|
| </script>"""
|
| return HTMLResponse(page("Sessione continua", content, "/ws/ui"))
|
|
|
| if __name__ == "__main__":
|
| uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
|