Spaces:
Running
Running
| <html lang="pt"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>COGNILINE OMNI</title> | |
| <!-- Bibliotecas Estáveis --> | |
| <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script> | |
| <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/lucide@latest"></script> | |
| <!-- KaTeX --> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"> | |
| <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <style> | |
| body { background: #09090b; color: #e4e4e7; font-family: 'Segoe UI', sans-serif; height: 100vh; overflow: hidden; margin: 0; } | |
| .sidebar-anim { transition: width 0.4s ease, opacity 0.3s; } | |
| .cloud-zone { border: 2px dashed #52525b; background: #27272a; cursor: pointer; transition: 0.3s; } | |
| .cloud-zone:hover { border-color: #a1a1aa; background: #3f3f46; } | |
| .chat-text { overflow-wrap: break-word; } | |
| .chat-text > *:last-child { margin-bottom: 0 ; } | |
| p:empty { display: none ; } | |
| .katex-display { background: #18181b; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0.8rem 0; border: 1px solid #3f3f46; } | |
| .chat-text .katex-display:last-child { margin-bottom: 0 ; } | |
| .mic-active { animation: pulse 1.5s infinite; background: #ef4444 ; color: #fff ; border-color: #ef4444 ; } | |
| @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(239,68,68,0.7); } 70% { box-shadow: 0 0 0 10px rgba(239,68,68,0); } 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); } } | |
| .img-prev { width: 32px; height: 32px; border-radius: 6px; object-fit: cover; border: 1px solid #3f3f46; } | |
| .msg-img { max-width: 200px; border-radius: 10px; margin-bottom: 10px; border: 1px solid #ffffff20; } | |
| ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| ::-webkit-scrollbar-thumb { background: #52525b; border-radius: 10px; } | |
| ::-webkit-scrollbar-track { background: #18181b; } | |
| #loader { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: #09090b; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 50; } | |
| #error-msg { display: none; color: #ef4444; margin-top: 20px; font-family: monospace; max-width: 80%; text-align: center; } | |
| .btn-reset { margin-top: 15px; padding: 10px 20px; background: #2563eb; color: white; border: none; border-radius: 6px; cursor: pointer; display: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="loader"> | |
| <h2 style="font-size: 24px; font-weight: bold; color: white; letter-spacing: 2px;">COGNILINE</h2> | |
| <p id="loading-text" style="color: #71717a; margin-top: 10px;">A iniciar interface...</p> | |
| <div id="error-msg"></div> | |
| <button id="reset-btn" class="btn-reset" onclick="localStorage.clear(); window.location.reload()">⚠️ LIMPAR DADOS E REINICIAR</button> | |
| </div> | |
| <div id="root" class="h-screen" style="display:none"></div> | |
| <script> | |
| window.onerror = function(msg, src, line, col, error) { | |
| document.getElementById('loading-text').innerText = "Erro Crítico Detetado."; | |
| document.getElementById('error-msg').style.display = 'block'; | |
| document.getElementById('error-msg').innerHTML = `${msg} (Linha ${line})`; | |
| document.getElementById('reset-btn').style.display = 'block'; | |
| }; | |
| setTimeout(() => { | |
| if (document.getElementById('root').style.display === 'none') { | |
| document.getElementById('loading-text').innerText = "O carregamento está lento."; | |
| document.getElementById('reset-btn').style.display = 'block'; | |
| } | |
| }, 8000); | |
| </script> | |
| <script type="text/babel"> | |
| // Mostra o app e esconde o loader assim que o React inicia | |
| document.getElementById('root').style.display = 'block'; | |
| document.getElementById('loader').style.display = 'none'; | |
| const { useState, useEffect, useRef } = React; | |
| const LOGO = "LOGO_B64"; | |
| const Msg = ({ t, role, img }) => { | |
| const r = useRef(); | |
| useEffect(() => { | |
| if (r.current) { | |
| try { | |
| let txt = t || ""; | |
| // Limpeza segura de delimitadores | |
| txt = txt.split("\\[").join("$$").split("\\]").join("$$").split("\\(").join("$").split("\\)").join("$"); | |
| r.current.innerHTML = marked.parse(txt); | |
| renderMathInElement(r.current, { delimiters: [{ left: "$$", right: "$$", display: true }, { left: "$", right: "$", display: false }], throwOnError: false }); | |
| } catch(e) { r.current.innerText = t; } | |
| } | |
| }, [t]); | |
| const speak = () => { | |
| window.speechSynthesis.cancel(); | |
| const u = new SpeechSynthesisUtterance(t.replace(/[\$\*#]/g, '')); | |
| const vs = window.speechSynthesis.getVoices(); | |
| const m = vs.find(v => (v.name.includes("Daniel") || v.name.includes("Male") || v.name.includes("Google")) && v.lang.includes("pt")); | |
| if (m) u.voice = m; | |
| u.lang = 'pt-PT'; u.rate = 1.1; | |
| window.speechSynthesis.speak(u); | |
| }; | |
| return ( | |
| <div className="msg-box"> | |
| {img && <img src={`data:image/jpeg;base64,${img}`} className="msg-img" />} | |
| <div ref={r} className="chat-text space-y-2" /> | |
| {role === 'ai' && ( | |
| <div className="mt-2 pt-2 border-t border-white/10 opacity-70 hover:opacity-100 transition flex gap-2"> | |
| <button onClick={speak} className="text-[10px] flex items-center gap-1 cursor-pointer bg-white/5 px-2 py-1 rounded hover:bg-white/10"> | |
| <i data-lucide="volume-2" className="w-3 h-3"></i> Ouvir | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| function App() { | |
| // Histórico seguro | |
| const [hist, setHist] = useState(() => { | |
| try { return JSON.parse(localStorage.getItem("cgl_v106") || "[]") } catch { return [] } | |
| }); | |
| const [cur, setCur] = useState(null); | |
| const [mode, setMode] = useState("OFFLINE"); | |
| const [load, setLoad] = useState(false); | |
| const [chat, setChat] = useState([]); | |
| // --- CORREÇÃO: Nome da variável padronizado para 'input' --- | |
| const [input, setInput] = useState(""); | |
| const [files, setFiles] = useState(null); | |
| const [sb, setSb] = useState(true); | |
| const [mic, setMic] = useState(false); | |
| const [web, setWeb] = useState(false); | |
| const [img, setImg] = useState(null); | |
| const [prev, setPrev] = useState(null); | |
| const box = useRef(null); | |
| useEffect(() => { localStorage.setItem("cgl_v106", JSON.stringify(hist)) }, [hist]); | |
| useEffect(() => { | |
| if (window.lucide) window.lucide.createIcons(); | |
| if (box.current) box.current.scrollTop = box.current.scrollHeight; | |
| }, [chat, load, sb]); | |
| useEffect(() => { window.speechSynthesis.getVoices() }, []); | |
| const togMic = () => { | |
| if (!('webkitSpeechRecognition' in window)) return alert("Sem voz."); | |
| if (mic) { window.rec.stop(); setMic(false); return; } | |
| const R = window.webkitSpeechRecognition; window.rec = new R(); window.rec.lang = 'pt-BR'; | |
| window.rec.onstart = () => setMic(true); | |
| // CORREÇÃO: Usando 'setInput' | |
| window.rec.onresult = (e) => setInput(p => (p ? p + " " + e.results[0][0].transcript : e.results[0][0].transcript)); | |
| window.rec.onend = () => setMic(false); window.rec.start(); | |
| }; | |
| const handleImg = (e) => { | |
| const f = e.target.files[0]; | |
| if (f) { setImg(f); const r = new FileReader(); r.onloadend = () => { setPrev(r.result) }; r.readAsDataURL(f); } | |
| }; | |
| const boot = async (uF) => { | |
| setLoad(true); const fd = new FormData(); | |
| if (uF && files) for (let f of files) fd.append("files", f); | |
| try { | |
| const r = await fetch("/api/init", { method: "POST", body: fd }); | |
| const d = await r.json(); | |
| if (d.status === "ok") { | |
| setMode(d.mode); setChat([{ role: 'ai', t: d.message }]); | |
| const id = Date.now(); | |
| const nC = { id, title: uF ? "Docs" : "Novo Chat", messages: [{ role: 'ai', t: d.message }], mode: d.mode, time: new Date().toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' }) }; | |
| setHist([nC, ...hist]); setCur(id); setFiles(null); | |
| } else alert(d.message); | |
| } catch (e) { alert("Erro Rede"); } setLoad(false); | |
| }; | |
| const loadChat = (id) => { const t = hist.find(c => c.id === id); if (t) { setCur(id); setChat(t.messages); setMode(t.mode); } }; | |
| const send = async () => { | |
| // CORREÇÃO: Usando 'input' | |
| if ((!input.trim() && !img) || load || mode === "OFFLINE") return; | |
| const ut = input; setInput(""); | |
| const nCh = [...chat, { role: 'user', t: ut + (img ? " [Imagem]" : ""), img: prev ? prev.split(',')[1] : null }]; | |
| setChat(nCh); setLoad(true); | |
| let b64 = null; if (img) b64 = prev.split(',')[1]; | |
| setImg(null); setPrev(null); | |
| try { | |
| const r = await fetch("/api/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: ut, web_search: web, image_base64: b64, generate_title: nCh.filter(x => x.role === 'user').length === 1 }) }); | |
| const d = await r.json(); | |
| const aiM = { role: 'ai', t: d.ans }; | |
| const final = [...nCh, aiM]; | |
| setChat(final); | |
| if (cur) setHist(p => p.map(c => c.id === cur ? { ...c, messages: final, title: d.title || c.title, time: new Date().toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' }) } : c)); | |
| } catch (e) { setChat(p => [...p, { role: 'ai', t: "Erro Rede" }]); } setLoad(false); | |
| }; | |
| return ( | |
| <div className="flex h-full w-full bg-zinc-950"> | |
| <aside className={`sidebar-anim ${sb ? 'w-80' : 'w-0'} bg-zinc-900 border-r border-zinc-800 flex flex-col overflow-hidden z-20`}> | |
| <div className="p-6 flex flex-col gap-6 min-w-[320px]"> | |
| <div className="flex items-center gap-3"><img src={LOGO} className="h-10 w-10 rounded-full border-2 border-zinc-500 shadow-lg grayscale" onError={(e) => e.target.style.display = 'none'} /><h1 className="font-bold text-white text-lg">COGNILINE</h1></div> | |
| <div className="flex flex-col gap-4"> | |
| <button onClick={() => boot(false)} className="w-full py-3 bg-zinc-800 hover:bg-zinc-700 rounded-full text-xs font-bold text-white border border-zinc-700 flex justify-center gap-2">Chat Livre (Nova Sessão)</button> | |
| <div className="cloud-zone rounded-[2rem] p-5 flex flex-col items-center gap-2 relative"> | |
| <input type="file" multiple onChange={e => setFiles(e.target.files)} className="absolute inset-0 opacity-0 cursor-pointer" /> | |
| <i data-lucide="cloud-upload" className="w-7 h-7 text-zinc-400"></i><span className="text-[9px] font-bold text-zinc-500">{files ? files.length + " PDFs" : "Carregar Manuais"}</span> | |
| </div> | |
| <button onClick={() => boot(true)} disabled={!files} className="w-full py-2.5 bg-zinc-100 hover:bg-white text-black rounded-full text-xs font-bold shadow-lg disabled:opacity-30">ATIVAR BASE</button> | |
| </div> | |
| <div className="flex-1 overflow-y-auto flex flex-col gap-2"><h2 className="text-[9px] font-black text-zinc-600 uppercase border-b border-zinc-800 pb-1">Histórico</h2>{hist.map(c => (<div key={c.id} onClick={() => loadChat(c.id)} className={`p-3 rounded-2xl border cursor-pointer ${c.id === cur ? 'bg-zinc-800 border-zinc-600' : 'bg-zinc-900/50 border-zinc-800'}`}><div className="flex justify-between"><h3 className="text-[10px] font-bold text-zinc-200 truncate flex-1">{c.title}</h3><span className="text-[7px] text-zinc-500 font-bold">{c.mode}</span></div><div className="mt-1 text-[8px] text-zinc-500">{c.time}</div></div>))}</div> | |
| </div> | |
| </aside> | |
| <main className="flex-1 flex flex-col relative h-full bg-zinc-950"> | |
| <button onClick={() => setSb(!sb)} className="absolute top-6 left-6 z-30 p-2 bg-zinc-800 text-zinc-400 rounded-xl shadow-xl border border-zinc-700 hover:text-white"><i data-lucide={sb ? "panel-left-close" : "panel-left-open"} className="w-5 h-5"></i></button> | |
| <div ref={box} className="absolute inset-0 overflow-y-auto p-8 pt-20 pb-40 space-y-8 scroll-smooth"> | |
| {chat.length === 0 && <div className="h-full flex flex-col items-center justify-center opacity-10 grayscale"><i data-lucide="cpu" className="w-32 h-32 mb-4"></i><p className="font-black text-4xl text-zinc-600">COGNILINE OMNI</p></div>} | |
| {chat.map((m, i) => (<div key={i} className={`flex w-full ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}><div className={`max-w-[85%] p-6 text-sm shadow-2xl rounded-[2rem] ${m.role === 'user' ? 'bg-zinc-700 text-white border border-zinc-600' : 'bg-zinc-900 text-zinc-300 border border-zinc-800'}`}><Msg t={m.t} role={m.role} img={m.img} /></div></div>))} | |
| {load && <div className="ml-10 text-zinc-500 text-[10px] font-black animate-pulse flex items-center gap-2">PROCESSANDO...</div>} | |
| </div> | |
| <div className="absolute bottom-0 left-0 w-full p-8 bg-zinc-950/95 border-t border-zinc-900 backdrop-blur-md z-20"> | |
| <div className="max-w-4xl mx-auto flex gap-3 items-center bg-zinc-900 border border-zinc-800 p-3 pl-5 pr-5 rounded-full shadow-2xl focus-within:ring-1 ring-zinc-500/50 transition-all"> | |
| <button onClick={() => setWeb(!web)} className={`w-8 h-8 rounded-full flex items-center justify-center transition ${web ? 'text-blue-400' : 'text-zinc-600 hover:text-white'}`} title="Web"><i data-lucide="globe" className="w-4 h-4"></i></button> | |
| <div className="relative group"> | |
| <input type="file" accept="image/*" onChange={handleImg} className="absolute inset-0 w-8 h-8 opacity-0 cursor-pointer" /> | |
| <button className={`w-8 h-8 rounded-full flex items-center justify-center transition ${prev ? 'text-blue-400' : 'text-zinc-600 hover:text-white'}`}><i data-lucide="image" className="w-4 h-4"></i></button> | |
| </div> | |
| <button onClick={togMic} className={`w-10 h-10 rounded-full flex items-center justify-center transition ${mic ? 'mic-active' : 'text-zinc-500 hover:text-white hover:bg-zinc-800'}`}><i data-lucide={mic ? "mic-off" : "mic"} className="w-5 h-5"></i></button> | |
| {/* CORREÇÃO AQUI: Usando 'input' e 'setInput' consistentemente */} | |
| <input | |
| value={input} | |
| onChange={e => setInput(e.target.value)} | |
| onKeyDown={e => e.key === 'Enter' && send()} | |
| disabled={mode === "OFFLINE" || load} | |
| className="flex-1 bg-transparent text-white outline-none placeholder-zinc-600" | |
| placeholder={web ? "Pesquisar na Web..." : "Escreva ou fale..."} | |
| /> | |
| <button onClick={send} className="bg-zinc-100 hover:bg-white text-black w-10 h-10 rounded-full flex items-center justify-center shadow-lg active:scale-95 transition-all"><i data-lucide="send-horizontal" className="w-5 h-5"></i></button> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| ); | |
| } | |
| ReactDOM.createRoot(document.getElementById('root')).render(<App />); | |
| </script> | |
| </body> | |
| </html> | |