Spaces:
Sleeping
Sleeping
| import { memo, useState, useRef, useEffect } from "react"; | |
| import { getLocalBackendStatus } from "@/lib/providerChain"; | |
| import { Z_INDEX } from "@/lib/zindex"; | |
| interface Props { | |
| onClose: () => void; | |
| onAvailable: () => void; | |
| } | |
| const STEPS = [ | |
| { | |
| n: 1, | |
| title: "Installa Ollama", | |
| desc: "Scarica e installa il runtime AI locale (gratuito, open source)", | |
| cmd: null as string | null, | |
| link: { label: "Scarica Ollama →", url: "https://ollama.com/download" }, | |
| hint: "Disponibile per macOS, Linux, Windows", | |
| }, | |
| { | |
| n: 2, | |
| title: "Avvia Ollama", | |
| desc: "Apri il Terminale ed esegui", | |
| cmd: "ollama serve", | |
| link: null, | |
| hint: "Lascia il terminale aperto", | |
| }, | |
| { | |
| n: 3, | |
| title: "Scarica un modello AI", | |
| desc: "Scegli un modello (la prima volta scarica il file)", | |
| cmd: "ollama pull qwen2.5-coder:7b", | |
| link: null, | |
| hint: "~4GB — oppure phi4-mini (~2GB) per Mac con poca RAM", | |
| }, | |
| { | |
| n: 4, | |
| title: "Avvia il backend", | |
| desc: "Nella cartella backend/ del progetto esegui", | |
| cmd: "bash start.sh", | |
| link: null, | |
| hint: "Installa dipendenze Python e avvia il server locale su porta 8000", | |
| }, | |
| ]; | |
| const BackendSetupModal = memo(function BackendSetupModal({ onClose, onAvailable }: Props) { | |
| const [currentStep, setCurrentStep] = useState(0); | |
| const [checking, setChecking] = useState(false); | |
| const [detected, setDetected] = useState(false); | |
| const [copied, setCopied] = useState<number | null>(null); | |
| const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); | |
| // [S47-bugfix] Stable ref for onAvailable — avoids useEffect re-running every render | |
| // (inline arrow functions from parent create new references each render) | |
| const onAvailableRef = useRef(onAvailable); | |
| onAvailableRef.current = onAvailable; | |
| // Poll for backend while modal is open — empty deps, stable via ref | |
| useEffect(() => { | |
| let mounted = true; | |
| pollRef.current = setInterval(async () => { | |
| try { | |
| const s = await getLocalBackendStatus(); | |
| if (!mounted) return; | |
| if (s.available) { | |
| setDetected(true); | |
| clearInterval(pollRef.current!); | |
| setTimeout(() => onAvailableRef.current(), 1500); | |
| } | |
| } catch { | |
| /* non-fatal — riprova al prossimo tick */ | |
| } | |
| }, 4000); | |
| return () => { mounted = false; clearInterval(pollRef.current!); }; | |
| }, []); // ← stable: onAvailable always current via ref | |
| const checkNow = async () => { | |
| setChecking(true); | |
| const s = await getLocalBackendStatus(); | |
| setChecking(false); | |
| if (s.available) { | |
| setDetected(true); | |
| setTimeout(() => onAvailableRef.current(), 1200); | |
| } | |
| }; | |
| const copyCmd = (cmd: string, idx: number) => { | |
| navigator.clipboard.writeText(cmd).catch(() => {}); | |
| setCopied(idx); | |
| setTimeout(() => setCopied(null), 2000); | |
| }; | |
| if (detected) { | |
| return ( | |
| <div data-modal style={{ | |
| position: "fixed", inset: 0, zIndex: Z_INDEX.CRITICAL_MODAL_OVERLAY, | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| background: "rgba(0,0,0,0.7)", | |
| paddingTop: "var(--safe-top)", paddingBottom: "var(--safe-bottom)", | |
| }}> | |
| <div style={{ | |
| background: "rgba(8,8,22,0.96)", | |
| border: "1px solid rgba(59,130,246,0.4)", | |
| borderRadius: 20, padding: "40px 36px", | |
| textAlign: "center", maxWidth: 380, | |
| boxShadow: "0 0 40px rgba(59,130,246,0.2)", | |
| }}> | |
| <div style={{ width: 56, height: 56, borderRadius: "50%", background: "rgba(59,130,246,0.15)", border: "2px solid rgba(59,130,246,0.5)", display: "flex", alignItems: "center", justifyContent: "center", margin: "0 auto 16px" }}> | |
| <svg width={28} height={28} viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg> | |
| </div> | |
| <h2 style={{ color: "#3b82f6", fontSize: "1.2rem", fontWeight: 700, marginBottom: 8 }}> | |
| Backend locale rilevato! | |
| </h2> | |
| <p style={{ color: "rgba(255,255,255,0.6)", fontSize: "0.88rem" }}> | |
| L'AI locale è attiva. Le prossime chat useranno Ollama invece del cloud. | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div | |
| data-modal | |
| style={{ | |
| position: "fixed", inset: 0, zIndex: Z_INDEX.CRITICAL_MODAL_OVERLAY, | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| background: "rgba(0,0,0,0.75)", | |
| paddingTop: "var(--safe-top)", paddingBottom: "var(--safe-bottom)", | |
| padding: "calc(var(--safe-top) + 16px) 16px calc(var(--safe-bottom) + 16px)", | |
| }} | |
| onClick={e => { if (e.target === e.currentTarget) onClose(); }} | |
| > | |
| <div style={{ | |
| background: "rgba(8,8,22,0.96)", | |
| border: "1px solid rgba(59,130,246,0.25)", | |
| borderRadius: 20, | |
| width: "100%", maxWidth: 480, | |
| maxHeight: "calc(100dvh - var(--safe-top) - var(--safe-bottom) - 32px)", | |
| overflow: "auto", | |
| boxShadow: "0 24px 80px rgba(0,0,0,0.8)", | |
| }}> | |
| {/* Header */} | |
| <div style={{ | |
| display: "flex", alignItems: "center", justifyContent: "space-between", | |
| padding: "20px 24px 16px", | |
| borderBottom: "1px solid rgba(255,255,255,0.06)", | |
| }}> | |
| <div> | |
| <h2 style={{ fontSize: "1.05rem", fontWeight: 700, color: "#fff" }}> | |
| Attiva AI locale | |
| </h2> | |
| <p style={{ fontSize: "0.78rem", color: "rgba(255,255,255,0.45)", marginTop: 3 }}> | |
| Ollama · privato · gratuito · nessun cloud | |
| </p> | |
| </div> | |
| <button | |
| onClick={onClose} | |
| style={{ | |
| background: "rgba(255,255,255,0.07)", border: "none", | |
| color: "rgba(255,255,255,0.6)", cursor: "pointer", | |
| width: 32, height: 32, borderRadius: 8, fontSize: 16, | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| flexShrink: 0, | |
| }} | |
| >✕</button> | |
| </div> | |
| {/* Steps */} | |
| <div style={{ padding: "16px 24px" }}> | |
| <p style={{ fontSize: "0.82rem", color: "rgba(255,255,255,0.5)", marginBottom: 16 }}> | |
| Segui questi passaggi una sola volta — poi l'app lo rileva automaticamente. | |
| </p> | |
| {/* Step tabs */} | |
| <div style={{ display: "flex", gap: 6, marginBottom: 20 }}> | |
| {STEPS.map((s, i) => ( | |
| <button | |
| key={i} | |
| onClick={() => setCurrentStep(i)} | |
| style={{ | |
| flex: 1, height: 4, borderRadius: 6, border: "none", cursor: "pointer", | |
| background: i <= currentStep | |
| ? "linear-gradient(90deg, #3b82f6, #8b5cf6)" | |
| : "rgba(255,255,255,0.1)", | |
| transition: "all 0.2s", | |
| }} | |
| /> | |
| ))} | |
| </div> | |
| {/* Current step */} | |
| {(() => { | |
| const step = STEPS[currentStep]; | |
| return ( | |
| <div style={{ | |
| background: "rgba(59,130,246,0.06)", | |
| border: "1px solid rgba(59,130,246,0.15)", | |
| borderRadius: 14, padding: "18px 20px", | |
| minHeight: 180, | |
| }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}> | |
| <div style={{ | |
| width: 28, height: 28, borderRadius: "50%", | |
| background: "linear-gradient(135deg, #3b82f6, #8b5cf6)", | |
| display: "flex", alignItems: "center", justifyContent: "center", | |
| fontSize: "0.78rem", fontWeight: 700, color: "#fff", flexShrink: 0, | |
| }}> | |
| {step.n} | |
| </div> | |
| <span style={{ fontWeight: 600, color: "#fff", fontSize: "0.95rem" }}> | |
| {step.title} | |
| </span> | |
| </div> | |
| <p style={{ color: "rgba(255,255,255,0.65)", fontSize: "0.85rem", marginBottom: 14 }}> | |
| {step.desc} | |
| </p> | |
| {step.cmd && ( | |
| <div style={{ | |
| background: "#080814", | |
| border: "1px solid rgba(59,130,246,0.2)", | |
| borderRadius: 10, padding: "10px 14px", | |
| display: "flex", alignItems: "center", justifyContent: "space-between", | |
| gap: 10, marginBottom: 10, | |
| }}> | |
| <code style={{ color: "#93c5fd", fontSize: "0.85rem", fontFamily: "ui-monospace,monospace", wordBreak: "break-all" }}> | |
| {step.cmd} | |
| </code> | |
| <button | |
| onClick={() => copyCmd(step.cmd!, currentStep)} | |
| style={{ | |
| background: copied === currentStep ? "rgba(59,130,246,0.2)" : "rgba(59,130,246,0.2)", | |
| border: "none", color: copied === currentStep ? "#3b82f6" : "#93c5fd", | |
| cursor: "pointer", padding: "5px 10px", borderRadius: 8, | |
| fontSize: "0.75rem", fontWeight: 600, flexShrink: 0, transition: "all 0.2s", | |
| whiteSpace: "nowrap", | |
| }} | |
| > | |
| {copied === currentStep ? "Copiato" : "Copia"} | |
| </button> | |
| </div> | |
| )} | |
| {step.link && ( | |
| <a | |
| href={step.link.url} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| style={{ | |
| display: "inline-flex", alignItems: "center", gap: 6, | |
| background: "linear-gradient(135deg, #3b82f6, #8b5cf6)", | |
| color: "#fff", textDecoration: "none", | |
| padding: "8px 16px", borderRadius: 8, | |
| fontSize: "0.85rem", fontWeight: 600, | |
| boxShadow: "0 4px 16px rgba(59,130,246,0.35)", | |
| }} | |
| > | |
| {step.link.label} | |
| </a> | |
| )} | |
| <p style={{ color: "rgba(255,255,255,0.35)", fontSize: "0.76rem", marginTop: 10 }}> | |
| {step.hint} | |
| </p> | |
| </div> | |
| ); | |
| })()} | |
| {/* Navigation */} | |
| <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 16 }}> | |
| <button | |
| onClick={() => setCurrentStep(s => Math.max(0, s - 1))} | |
| disabled={currentStep === 0} | |
| style={{ | |
| background: "rgba(255,255,255,0.07)", border: "1px solid rgba(255,255,255,0.1)", | |
| color: currentStep === 0 ? "rgba(255,255,255,0.25)" : "rgba(255,255,255,0.7)", | |
| cursor: currentStep === 0 ? "not-allowed" : "pointer", | |
| padding: "8px 16px", borderRadius: 8, fontSize: "0.83rem", fontWeight: 500, | |
| }} | |
| >← Indietro</button> | |
| <div style={{ display: "flex", gap: 8 }}> | |
| <button | |
| onClick={checkNow} | |
| disabled={checking} | |
| style={{ | |
| background: checking ? "rgba(59,130,246,0.1)" : "rgba(59,130,246,0.15)", | |
| border: "1px solid rgba(59,130,246,0.3)", | |
| color: "#3b82f6", cursor: checking ? "wait" : "pointer", | |
| padding: "8px 14px", borderRadius: 8, fontSize: "0.83rem", fontWeight: 600, | |
| transition: "all 0.2s", | |
| }} | |
| > | |
| {checking ? "Verifica…" : "Verifica ora"} | |
| </button> | |
| {currentStep < STEPS.length - 1 && ( | |
| <button | |
| onClick={() => setCurrentStep(s => Math.min(STEPS.length - 1, s + 1))} | |
| style={{ | |
| background: "linear-gradient(135deg, #3b82f6, #8b5cf6)", | |
| border: "none", color: "#fff", cursor: "pointer", | |
| padding: "8px 16px", borderRadius: 8, fontSize: "0.83rem", fontWeight: 600, | |
| boxShadow: "0 4px 12px rgba(59,130,246,0.3)", | |
| }} | |
| >Prossimo →</button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Auto-detect notice */} | |
| <div style={{ | |
| padding: "12px 24px 20px", | |
| borderTop: "1px solid rgba(255,255,255,0.05)", | |
| display: "flex", alignItems: "center", gap: 8, | |
| }}> | |
| <div style={{ | |
| width: 7, height: 7, borderRadius: "50%", | |
| background: "#f59e0b", | |
| animation: "pulse-dot 1.5s ease-in-out infinite", | |
| }} /> | |
| <span style={{ fontSize: "0.75rem", color: "rgba(255,255,255,0.35)" }}> | |
| Rilevamento automatico ogni 4 secondi — la finestra si chiuderà da sola | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| ); | |
| export default BackendSetupModal; | |