|
|
{% extends "base.html" %} |
|
|
{% block body %} |
|
|
<div class="card"> |
|
|
<h3>Chat — Matrix System 1.0</h3> |
|
|
<div id="messages" style="margin-top:14px; display:flex; flex-direction:column; gap:10px; max-height:60vh; overflow:auto;"></div> |
|
|
<form id="chatForm" style="display:grid; gap:12px; margin-top:14px;"> |
|
|
<textarea id="question" rows="4" placeholder="Ask anything about Matrix EcoSystem, Guardian, or Hub..."></textarea> |
|
|
<div style="display:flex; gap:10px; align-items:center;"> |
|
|
<button id="sendBtn" type="submit">Send</button> |
|
|
<button id="clearBtn" type="button" style="background:#0c1d13; color:#7ef7a7; box-shadow:none; border:1px solid var(--border);">Clear</button> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
|
|
|
<style> |
|
|
.bubble { |
|
|
max-width: 80%; |
|
|
border: 1px solid var(--border); |
|
|
border-radius: 14px; |
|
|
padding: 10px 12px; |
|
|
line-height: 1.45; |
|
|
white-space: pre-wrap; |
|
|
word-break: break-word; |
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25), 0 0 0 1px rgba(0,255,156,0.05); |
|
|
font-family: "Share Tech Mono", monospace; |
|
|
} |
|
|
.user { align-self: flex-end; background: #062013; color: var(--text); border-color: #0e2e1a; } |
|
|
.bot { align-self: flex-start; background: #05140c; color: var(--text); border-color: #0c2416; } |
|
|
.meta { font-size: 11px; opacity: .6; margin-top: 2px; } |
|
|
.caret { |
|
|
display: inline-block; |
|
|
width: 8px; height: 1em; vertical-align: bottom; |
|
|
background: var(--matrix); |
|
|
margin-left: 2px; |
|
|
box-shadow: 0 0 6px rgba(0,255,156,0.5); |
|
|
animation: blink 1s steps(1) infinite; |
|
|
} |
|
|
@keyframes blink { 0%, 49% {opacity: 1;} 50%, 100% {opacity: 0;} } |
|
|
</style> |
|
|
|
|
|
<script> |
|
|
(function () { |
|
|
const KEY = 'matrix_ai_chat_history'; |
|
|
const messagesEl = document.getElementById('messages'); |
|
|
const form = document.getElementById('chatForm'); |
|
|
const input = document.getElementById('question'); |
|
|
const sendBtn = document.getElementById('sendBtn'); |
|
|
const clearBtn = document.getElementById('clearBtn'); |
|
|
|
|
|
|
|
|
input.addEventListener('keydown', (e) => { |
|
|
if (e.key === 'Enter' && !e.shiftKey) { |
|
|
e.preventDefault(); |
|
|
sendBtn.click(); |
|
|
} |
|
|
}); |
|
|
|
|
|
function loadHistory(){ try{ return JSON.parse(localStorage.getItem(KEY)||'[]'); }catch{ return []; } } |
|
|
function saveHistory(h){ localStorage.setItem(KEY, JSON.stringify(h.slice(-100))); } |
|
|
|
|
|
function block(role, text, ts){ |
|
|
const wrap = document.createElement('div'); |
|
|
wrap.style.display='flex'; wrap.style.flexDirection='column'; wrap.style.gap='2px'; |
|
|
const b = document.createElement('div'); b.className='bubble ' + (role==='user'?'user':'bot'); b.textContent=text; |
|
|
const meta = document.createElement('div'); meta.className='meta'; meta.textContent=new Date(ts).toLocaleString(); |
|
|
wrap.appendChild(b); wrap.appendChild(meta); |
|
|
return {wrap,bubble:b,meta}; |
|
|
} |
|
|
function render(hist){ |
|
|
messagesEl.innerHTML=''; hist.forEach(m=>messagesEl.appendChild(block(m.role,m.text,m.ts).wrap)); |
|
|
messagesEl.scrollTop = messagesEl.scrollHeight; |
|
|
} |
|
|
function append(role,text){ |
|
|
const el = block(role,text,Date.now()); messagesEl.appendChild(el.wrap); |
|
|
messagesEl.scrollTop = messagesEl.scrollHeight; return el; |
|
|
} |
|
|
|
|
|
let history = loadHistory(); |
|
|
if(history.length===0){ |
|
|
history.push({role:'bot',text:'Welcome to MATRIX-AI. Ask me about Matrix System 1.0, Guardian, or the Hub.',ts:Date.now()}); |
|
|
saveHistory(history); |
|
|
} |
|
|
render(history); |
|
|
|
|
|
clearBtn.addEventListener('click', ()=>{ |
|
|
history = []; saveHistory(history); render(history); |
|
|
}); |
|
|
|
|
|
form.addEventListener('submit', async (e)=>{ |
|
|
e.preventDefault(); |
|
|
const q = (input.value||'').trim(); if(!q) return; |
|
|
input.value=''; sendBtn.disabled=true; |
|
|
|
|
|
|
|
|
history.push({role:'user',text:q,ts:Date.now()}); saveHistory(history); render(history); |
|
|
|
|
|
|
|
|
const live = append('bot',''); |
|
|
const caret = document.createElement('span'); caret.className='caret'; live.bubble.appendChild(caret); |
|
|
|
|
|
let gotChunk = false, finished = false, streamed = ''; |
|
|
|
|
|
function onChunk(delta){ |
|
|
gotChunk = true; |
|
|
streamed += delta || ''; |
|
|
try { live.bubble.removeChild(caret); } catch {} |
|
|
live.bubble.textContent = streamed; |
|
|
live.bubble.appendChild(caret); |
|
|
messagesEl.scrollTop = messagesEl.scrollHeight; |
|
|
} |
|
|
function finalize(text){ |
|
|
finished = true; |
|
|
try { live.bubble.removeChild(caret); } catch {} |
|
|
live.bubble.textContent = text; |
|
|
history.push({role:'bot',text,ts:Date.now()}); saveHistory(history); render(history); |
|
|
sendBtn.disabled=false; |
|
|
} |
|
|
|
|
|
|
|
|
const sseUrl = window.location.origin.replace(/\/+$/,'') + '/v1/chat/stream?ts=' + Date.now() + '&query=' + encodeURIComponent(q); |
|
|
let es = null; |
|
|
let fallbackTimer = null; |
|
|
|
|
|
function closeES(){ try { es && es.close(); } catch {} es = null; } |
|
|
|
|
|
async function doNonStream() { |
|
|
try{ |
|
|
const r = await fetch('/v1/chat', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({query:q})}); |
|
|
let answer = '(no answer)'; |
|
|
if(r.ok){ const data=await r.json(); answer = (data && (data.answer||data.response||JSON.stringify(data)))||answer; } |
|
|
else { answer = `HTTP ${r.status}`; } |
|
|
streamed = answer; |
|
|
}catch(err){ |
|
|
streamed = 'Error: ' + (err && err.message ? err.message : String(err)); |
|
|
}finally{ |
|
|
finalize(streamed); |
|
|
} |
|
|
} |
|
|
|
|
|
async function doFetchStream() { |
|
|
try { |
|
|
const resp = await fetch('/v1/chat/stream', { |
|
|
method: 'POST', |
|
|
headers: {'content-type': 'application/json'}, |
|
|
body: JSON.stringify({query:q}) |
|
|
}); |
|
|
if (!resp.ok || !resp.body) throw new Error('stream HTTP ' + resp.status); |
|
|
const reader = resp.body.getReader(); |
|
|
const decoder = new TextDecoder('utf-8'); |
|
|
let buf = ''; |
|
|
for (;;) { |
|
|
const { value, done } = await reader.read(); |
|
|
if (done) break; |
|
|
buf += decoder.decode(value, { stream: true }); |
|
|
const lines = buf.split(/\r?\n/); |
|
|
buf = lines.pop() || ''; |
|
|
for (const line of lines) { |
|
|
if (!line.startsWith('data:')) continue; |
|
|
const data = line.slice(5).trim(); |
|
|
if (data === '[DONE]') { finalize(streamed); return; } |
|
|
try { |
|
|
const obj = JSON.parse(data); |
|
|
if (obj.error) { finalize('Error: ' + obj.error); return; } |
|
|
if ('delta' in obj) onChunk(obj.delta || ''); |
|
|
} catch { |
|
|
onChunk(data); |
|
|
} |
|
|
} |
|
|
} |
|
|
finalize(streamed); |
|
|
} catch (err) { |
|
|
|
|
|
await doNonStream(); |
|
|
} |
|
|
} |
|
|
|
|
|
function startSSE() { |
|
|
if (!window.EventSource) { doFetchStream(); return; } |
|
|
es = new EventSource(sseUrl); |
|
|
fallbackTimer = setTimeout(() => { |
|
|
if (!gotChunk && !finished) { |
|
|
closeES(); |
|
|
doFetchStream(); |
|
|
} |
|
|
}, 1500); |
|
|
|
|
|
es.onmessage = (ev) => { |
|
|
if (!ev.data) return; |
|
|
if (ev.data === "[DONE]") { |
|
|
clearTimeout(fallbackTimer); closeES(); finalize(streamed); return; |
|
|
} |
|
|
try { |
|
|
const obj = JSON.parse(ev.data); |
|
|
if (obj.error) { clearTimeout(fallbackTimer); closeES(); finalize('Error: ' + obj.error); return; } |
|
|
if ('delta' in obj) onChunk(obj.delta || ''); |
|
|
} catch { |
|
|
onChunk(ev.data); |
|
|
} |
|
|
}; |
|
|
es.onerror = () => { |
|
|
if (!gotChunk && !finished) { |
|
|
clearTimeout(fallbackTimer); |
|
|
closeES(); |
|
|
doFetchStream(); |
|
|
} |
|
|
}; |
|
|
} |
|
|
startSSE(); |
|
|
}); |
|
|
})(); |
|
|
</script> |
|
|
{% endblock %} |