nvidia / static /index.html
elmarcito's picture
Update static/index.html
8958277 verified
Raw
History Blame Contribute Delete
42.2 kB
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>πŸš€ NVIDIA AI Multi-Model</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>πŸš€</text></svg>">
<style>
:root{--nvidia:#76b900;--bg:#0a0e14;--card:#141a24;--card-hover:#1a2130;--border:#2a3341;--text:#e4e6eb;--text-dim:#8b95a7;--accent:#76b900;--accent-glow:rgba(118,185,0,.15);--error:#f44336;--code:#0d1117;}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);display:grid;grid-template-columns:280px 1fr 320px;grid-template-rows:60px 1fr;grid-template-areas:"header header header" "sidebar main config"}
header{grid-area:header;background:linear-gradient(90deg,#0a0e14,#141a24);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:20px;z-index:10}
.logo{display:flex;align-items:center;gap:10px;font-weight:700;font-size:18px}
.logo-badge{background:var(--nvidia);color:#000;padding:3px 8px;border-radius:4px;font-size:11px;font-weight:800}
.nav-tabs{display:flex;gap:5px;margin-left:20px}
.nav-tab{padding:8px 16px;border-radius:8px;cursor:pointer;color:var(--text-dim);font-size:14px;transition:all .2s;text-decoration:none}
.nav-tab:hover{background:var(--card);color:var(--text)}
.nav-tab.active{background:var(--accent-glow);color:var(--accent)}
.header-right{margin-left:auto;display:flex;gap:12px;align-items:center}
.status{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-dim)}
.status-dot{width:8px;height:8px;border-radius:50%;background:var(--nvidia);box-shadow:0 0 10px var(--nvidia);animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
.sidebar{grid-area:sidebar;background:var(--card);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.sidebar-header{padding:16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.sidebar-title{font-size:12px;text-transform:uppercase;color:var(--text-dim);letter-spacing:1px}
.btn-new{background:var(--accent);color:#000;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;transition:all .2s}
.btn-new:hover{background:#8ad100;transform:translateY(-1px)}
.session-list{flex:1;overflow-y:auto;padding:8px}
.session-item{padding:10px 12px;border-radius:8px;cursor:pointer;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center;transition:all .15s;font-size:13px}
.session-item:hover{background:var(--card-hover)}
.session-item.active{background:var(--accent-glow);color:var(--accent);border-left:3px solid var(--accent)}
.session-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.session-badge{background:var(--border);color:var(--text-dim);padding:2px 6px;border-radius:10px;font-size:10px}
.session-delete{opacity:0;background:none;border:none;color:var(--error);cursor:pointer;font-size:14px;padding:0 4px}
.session-item:hover .session-delete{opacity:1}
.main{grid-area:main;display:flex;flex-direction:column;overflow:hidden}
.chat-area{flex:1;overflow-y:auto;padding:24px 32px;scroll-behavior:smooth}
.welcome{text-align:center;padding:60px 20px;color:var(--text-dim)}
.welcome h1{font-size:32px;background:linear-gradient(135deg,var(--nvidia),#00d4ff);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:12px}
.welcome p{margin-bottom:24px;font-size:15px}
.suggestions{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;max-width:700px;margin:0 auto}
.suggestion{background:var(--card);border:1px solid var(--border);padding:16px;border-radius:12px;cursor:pointer;transition:all .2s;text-align:left}
.suggestion:hover{border-color:var(--accent);background:var(--card-hover);transform:translateY(-2px)}
.suggestion-icon{font-size:24px;margin-bottom:8px}
.suggestion-text{font-size:13px;color:var(--text)}
.message{margin-bottom:20px;display:flex;gap:12px;animation:fadeIn .3s}
@keyframes fadeIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
.msg-avatar{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0;font-size:14px}
.msg-avatar.user{background:linear-gradient(135deg,#3b82f6,#1e40af);color:#fff}
.msg-avatar.assistant{background:linear-gradient(135deg,var(--nvidia),#4a7c00);color:#000}
.msg-content{flex:1;min-width:0}
.msg-role{font-weight:600;font-size:13px;margin-bottom:4px;color:var(--text-dim)}
.msg-text{background:var(--card);padding:14px 16px;border-radius:12px;border:1px solid var(--border);line-height:1.6;word-wrap:break-word;overflow-wrap:break-word}
.msg-text.user{background:transparent;border:none;padding:14px 0}
.msg-text pre{background:var(--code);border:1px solid var(--border);padding:12px;border-radius:8px;overflow-x:auto;margin:8px 0;font-family:'Fira Code','Courier New',monospace;font-size:13px}
.msg-text code{background:var(--code);padding:2px 6px;border-radius:4px;font-family:'Fira Code',monospace;font-size:13px}
.msg-text pre code{background:none;padding:0}
.msg-actions{display:flex;gap:8px;margin-top:6px;opacity:0;transition:opacity .2s}
.message:hover .msg-actions{opacity:1}
.msg-btn{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:12px;padding:2px 6px;border-radius:4px}
.msg-btn:hover{color:var(--accent);background:var(--card)}
.typing-dots{display:inline-flex;gap:4px;align-items:center;padding:8px 0}
.typing-dots span{width:8px;height:8px;border-radius:50%;background:var(--accent);animation:typing 1.4s infinite}
.typing-dots span:nth-child(2){animation-delay:.2s}
.typing-dots span:nth-child(3){animation-delay:.4s}
@keyframes typing{0%,60%,100%{opacity:.3;transform:translateY(0)}30%{opacity:1;transform:translateY(-4px)}}
.error-msg{color:var(--error);padding:10px;background:rgba(244,67,54,.1);border-radius:8px;border:1px solid var(--error)}
.input-area{padding:16px 32px 20px;border-top:1px solid var(--border);background:var(--bg)}
.input-wrapper{display:flex;gap:8px;align-items:flex-end;background:var(--card);border:1px solid var(--border);border-radius:16px;padding:8px;transition:border-color .2s}
.input-wrapper:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
#userInput{flex:1;background:none;border:none;color:var(--text);padding:10px;font-size:15px;font-family:inherit;resize:none;max-height:200px;min-height:24px;outline:none}
.send-btn{background:var(--accent);color:#000;border:none;width:40px;height:40px;border-radius:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:18px;transition:all .2s;flex-shrink:0}
.send-btn:hover:not(:disabled){background:#8ad100;transform:scale(1.05)}
.send-btn:disabled{background:var(--border);color:var(--text-dim);cursor:not-allowed}
.input-footer{display:flex;justify-content:space-between;margin-top:8px;font-size:11px;color:var(--text-dim);padding:0 8px}
.stream-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none}
.stream-toggle input{accent-color:var(--accent)}
.config{grid-area:config;background:var(--card);border-left:1px solid var(--border);overflow-y:auto;padding:20px}
.config-section{margin-bottom:24px}
.config-label{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--text-dim);margin-bottom:8px;display:block;font-weight:600}
.config-select,.config-input,.config-textarea{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:6px;font-size:13px;font-family:inherit;transition:border-color .2s}
.config-select:focus,.config-input:focus,.config-textarea:focus{outline:none;border-color:var(--accent)}
.config-textarea{resize:vertical;min-height:80px}
.slider-row{display:flex;align-items:center;gap:10px}
.slider-row input[type=range]{flex:1;accent-color:var(--accent)}
.slider-value{background:var(--bg);padding:4px 8px;border-radius:4px;font-size:12px;font-family:monospace;min-width:50px;text-align:center;color:var(--accent)}
.model-info{background:var(--bg);padding:10px;border-radius:6px;font-size:11px;color:var(--text-dim);margin-top:6px;line-height:1.5}
.model-info b{color:var(--text)}
.checkbox-row{display:flex;align-items:center;gap:8px;padding:8px;background:var(--bg);border-radius:6px;cursor:pointer}
.checkbox-row input{accent-color:var(--accent)}
.stats-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.stat{background:var(--bg);padding:10px;border-radius:6px;text-align:center}
.stat-val{font-size:18px;font-weight:700;color:var(--accent)}
.stat-lbl{font-size:10px;color:var(--text-dim);text-transform:uppercase;margin-top:2px}
.btn-icon{background:var(--card);border:1px solid var(--border);color:var(--text);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:13px;transition:all .2s;text-decoration:none;display:inline-flex;align-items:center;gap:6px}
.btn-icon:hover{border-color:var(--accent);color:var(--accent)}
.btn-test{background:var(--accent);color:#000;border:none;padding:8px 12px;border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;width:100%;margin-top:8px}
.btn-test:hover{background:#8ad100}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);display:none;align-items:center;justify-content:center;z-index:100;padding:20px}
.modal-overlay.active{display:flex}
.modal{background:var(--card);border-radius:16px;max-width:900px;width:100%;max-height:85vh;overflow:hidden;display:flex;flex-direction:column;border:1px solid var(--border)}
.modal-header{padding:20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.modal-title{font-size:18px;font-weight:700}
.modal-close{background:none;border:none;color:var(--text);font-size:24px;cursor:pointer}
.modal-body{padding:20px;overflow-y:auto;flex:1}
.code-block{background:var(--code);border:1px solid var(--border);border-radius:8px;padding:12px;font-family:'Fira Code',monospace;font-size:12px;color:#7ec8e3;overflow-x:auto;line-height:1.6;position:relative;margin:8px 0;white-space:pre-wrap}
.copy-btn{position:absolute;top:8px;right:8px;background:var(--border);border:none;color:var(--text);padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px}
.copy-btn:hover{background:var(--accent);color:#000}
.endpoint-table{width:100%;border-collapse:collapse;margin:8px 0;font-size:13px}
.endpoint-table th,.endpoint-table td{padding:8px 10px;border:1px solid var(--border);text-align:left}
.endpoint-table th{background:var(--code);color:var(--accent)}
.test-loading{text-align:center;padding:40px;color:var(--text-dim)}
.test-loading .typing-dots{justify-content:center}
.toast{position:fixed;bottom:20px;right:20px;background:var(--card);border:1px solid var(--accent);color:var(--text);padding:12px 20px;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.5);z-index:200;animation:slideIn .3s;display:none;max-width:400px}
.toast.show{display:block}
.toast.error{border-color:var(--error);color:var(--error)}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-track{background:var(--bg)}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
::-webkit-scrollbar-thumb:hover{background:var(--accent)}
@media(max-width:1100px){body{grid-template-columns:0 1fr 0}.sidebar,.config{display:none}}
</style>
</head>
<body>
<header>
<div class="logo"><span>πŸš€</span><span>NVIDIA AI</span><span class="logo-badge">NIM</span></div>
<div class="nav-tabs">
<div class="nav-tab active">πŸ’¬ Chat</div>
<div class="nav-tab" onclick="openModal('models')">πŸ€– Modelli</div>
<div class="nav-tab" onclick="openModal('test')">πŸš€ Test Parallelo</div>
<div class="nav-tab" onclick="openModal('api')">πŸ“– API</div>
<div class="nav-tab" onclick="openModal('mcp')">πŸ”Œ MCP</div>
<a class="nav-tab" href="/ui" target="_blank">🎨 Gradio</a>
<a class="nav-tab" href="/docs" target="_blank">πŸ“š Swagger</a>
</div>
<div class="header-right">
<div class="status"><div class="status-dot"></div><span>Connesso</span></div>
</div>
</header>
<aside class="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">Sessioni</span>
<button class="btn-new" onclick="newSession()">+ Nuova</button>
</div>
<div class="session-list" id="sessionList"></div>
</aside>
<main class="main">
<div class="chat-area" id="chatArea"></div>
<div class="input-area">
<div class="input-wrapper">
<textarea id="userInput" placeholder="Scrivi un messaggio... (Shift+Enter per nuova riga)" rows="1"></textarea>
<button class="send-btn" id="sendBtn" onclick="sendMessage()">➀</button>
</div>
<div class="input-footer">
<label class="stream-toggle">
<input type="checkbox" id="streamMode" checked>
<span>πŸ“‘ Streaming SSE</span>
</label>
<span id="tokenCount">0 caratteri</span>
</div>
</div>
</main>
<aside class="config">
<div class="config-section">
<label class="config-label">πŸ€– Modello</label>
<select class="config-select" id="modelSelect"></select>
<div class="model-info" id="modelInfo"></div>
<button class="btn-test" onclick="testCurrentModel()">πŸ§ͺ Testa modello corrente</button>
</div>
<div class="config-section">
<label class="config-label">πŸ“ System Prompt</label>
<textarea class="config-textarea" id="systemPrompt">Sei un assistente AI utile. Rispondi in italiano se l'utente scrive in italiano.</textarea>
</div>
<div class="config-section">
<label class="config-label">🌑️ Temperature</label>
<div class="slider-row">
<input type="range" id="temperature" min="0" max="2" step="0.1" value="0.7" oninput="temperatureVal.textContent=this.value">
<span class="slider-value" id="temperatureVal">0.7</span>
</div>
</div>
<div class="config-section">
<label class="config-label">πŸ“ Max Tokens</label>
<div class="slider-row">
<input type="range" id="maxTokens" min="256" max="16384" step="256" value="4096" oninput="maxTokensVal.textContent=this.value">
<span class="slider-value" id="maxTokensVal">4096</span>
</div>
</div>
<div class="config-section">
<label class="config-label">🧠 Memoria</label>
<label class="checkbox-row">
<input type="checkbox" id="useMemory" checked>
<span>Attiva memoria breve/lunga</span>
</label>
</div>
<div class="config-section">
<label class="config-label">πŸ“Š Statistiche</label>
<div class="stats-grid">
<div class="stat"><div class="stat-val" id="statShort">0</div><div class="stat-lbl">Breve</div></div>
<div class="stat"><div class="stat-val" id="statLong">0</div><div class="stat-lbl">Lunga</div></div>
<div class="stat"><div class="stat-val" id="statTokens">0</div><div class="stat-lbl">Token</div></div>
<div class="stat"><div class="stat-val" id="statTotal">0</div><div class="stat-lbl">Totali</div></div>
</div>
</div>
<div class="config-section">
<button class="btn-new" style="width:100%;margin-bottom:8px" onclick="clearCurrentSession()">πŸ—‘οΈ Pulisci Chat</button>
<button class="btn-icon" style="width:100%;justify-content:center" onclick="exportChat()">πŸ’Ύ Esporta</button>
</div>
</aside>
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="modalTitle">Info</div>
<button class="modal-close" onclick="closeModal()">Γ—</button>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
'use strict';
const DEFAULT_MODEL = 'meta/llama-3.1-8b-instruct';
const state = {
currentSession: localStorage.getItem('currentSession') || 'default',
sessions: JSON.parse(localStorage.getItem('sessions') || '{}'),
models: {},
defaultModel: DEFAULT_MODEL,
isGenerating: false,
currentEventSource: null,
};
// ═══════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════
async function init(){
await loadModels();
loadSessions();
loadCurrentChat();
await updateStats();
setupInputAutoResize();
}
async function loadModels(){
try{
const r = await fetch('/v1/models');
const data = await r.json();
state.models = data.models;
state.defaultModel = data.default || DEFAULT_MODEL;
const sel = document.getElementById('modelSelect');
sel.innerHTML = '';
const grouped = {};
for(const [id,info] of Object.entries(data.models)){
const cat = info.category;
if(!grouped[cat]) grouped[cat] = [];
grouped[cat].push({id, ...info});
}
for(const cat of Object.keys(grouped).sort()){
const og = document.createElement('optgroup');
og.label = cat;
for(const m of grouped[cat]){
const o = document.createElement('option');
o.value = m.id;
o.textContent = m.name;
og.appendChild(o);
}
sel.appendChild(og);
}
const stored = localStorage.getItem('selectedModel');
if(stored && state.models[stored]){
sel.value = stored;
} else {
sel.value = state.defaultModel;
localStorage.setItem('selectedModel', state.defaultModel);
}
sel.addEventListener('change', ()=>{
localStorage.setItem('selectedModel', sel.value);
updateModelInfo();
showToast('βœ… Modello: ' + state.models[sel.value].name);
});
updateModelInfo();
console.log('βœ… Modelli:', Object.keys(data.models).length, '| Default:', sel.value);
}catch(e){
console.error('❌', e);
showToast('❌ Errore modelli', true);
}
}
function updateModelInfo(){
const id = document.getElementById('modelSelect').value;
const m = state.models[id];
if(!m) return;
document.getElementById('modelInfo').innerHTML =
'<b>ID:</b> ' + escapeHtml(id) + '<br>' +
'<b>Categoria:</b> ' + escapeHtml(m.category) + '<br>' +
'<b>Context:</b> ' + m.context.toLocaleString() + ' token<br>' +
'<b>' + escapeHtml(m.description) + '</b>';
}
// ═══════════════════════════════════════════════════════
// SESSIONI
// ═══════════════════════════════════════════════════════
function loadSessions(){
if(!state.sessions[state.currentSession]){
state.sessions[state.currentSession] = {name: state.currentSession, messages: []};
saveSessions();
}
renderSessions();
}
function renderSessions(){
const list = document.getElementById('sessionList');
list.innerHTML = '';
for(const [id, s] of Object.entries(state.sessions)){
const div = document.createElement('div');
div.className = 'session-item' + (id === state.currentSession ? ' active' : '');
div.innerHTML =
'<span class="session-name">πŸ’¬ ' + escapeHtml(s.name) + '</span>' +
'<span class="session-badge">' + s.messages.length + '</span>' +
'<button class="session-delete" data-sid="' + escapeAttr(id) + '">πŸ—‘οΈ</button>';
div.querySelector('.session-delete').addEventListener('click', (e)=>{
e.stopPropagation();
deleteSession(id);
});
div.addEventListener('click', ()=>switchSession(id));
list.appendChild(div);
}
}
function newSession(){
const name = prompt('Nome sessione:', 'chat-' + Date.now().toString(36).slice(-5));
if(!name) return;
state.sessions[name] = {name, messages: []};
state.currentSession = name;
saveSessions();
localStorage.setItem('currentSession', name);
renderSessions();
loadCurrentChat();
updateStats();
}
function switchSession(id){
state.currentSession = id;
localStorage.setItem('currentSession', id);
renderSessions();
loadCurrentChat();
updateStats();
}
async function deleteSession(id){
if(!confirm('Eliminare sessione "' + id + '"?')) return;
delete state.sessions[id];
try{ await fetch('/v1/sessions/' + encodeURIComponent(id), {method:'DELETE'}); }catch(e){}
if(id === state.currentSession){
const remaining = Object.keys(state.sessions);
state.currentSession = remaining[0] || 'default';
if(!state.sessions[state.currentSession]){
state.sessions[state.currentSession] = {name: state.currentSession, messages: []};
}
localStorage.setItem('currentSession', state.currentSession);
}
saveSessions();
renderSessions();
loadCurrentChat();
updateStats();
}
function saveSessions(){
try{ localStorage.setItem('sessions', JSON.stringify(state.sessions)); }
catch(e){ console.warn('LocalStorage full'); }
}
async function clearCurrentSession(){
if(!confirm('Pulire questa chat?')) return;
state.sessions[state.currentSession].messages = [];
try{
await fetch('/v1/memory', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({session_id: state.currentSession, action:'clear_all'})
});
}catch(e){}
saveSessions();
loadCurrentChat();
updateStats();
renderSessions();
showToast('πŸ—‘οΈ Pulita');
}
// ═══════════════════════════════════════════════════════
// CHAT (FIX: rendering separato dal contenuto)
// ═══════════════════════════════════════════════════════
function loadCurrentChat(){
const area = document.getElementById('chatArea');
const msgs = state.sessions[state.currentSession].messages;
area.innerHTML = '';
if(msgs.length === 0){
area.innerHTML =
'<div class="welcome">' +
'<h1>πŸš€ NVIDIA AI Multi-Model</h1>' +
'<p>Sessione: <b>' + escapeHtml(state.currentSession) + '</b></p>' +
'<div class="suggestions">' +
'<div class="suggestion" onclick="sendSuggestion(\'Ciao! Chi sei?\')"><div class="suggestion-icon">πŸ‘‹</div><div class="suggestion-text">Ciao! Chi sei?</div></div>' +
'<div class="suggestion" onclick="sendSuggestion(\'Scrivi codice Python per il quicksort\')"><div class="suggestion-icon">πŸ’»</div><div class="suggestion-text">Codice Python quicksort</div></div>' +
'<div class="suggestion" onclick="sendSuggestion(\'Spiegami la meccanica quantistica\')"><div class="suggestion-icon">πŸ”¬</div><div class="suggestion-text">Meccanica quantistica</div></div>' +
'<div class="suggestion" onclick="sendSuggestion(\'Scrivi una poesia in italiano\')"><div class="suggestion-icon">πŸ“</div><div class="suggestion-text">Scrivi una poesia</div></div>' +
'</div></div>';
return;
}
msgs.forEach(m => appendMessage(m.role, m.content, false));
}
function appendMessage(role, content, save=true){
const area = document.getElementById('chatArea');
const welcome = area.querySelector('.welcome');
if(welcome) welcome.remove();
const div = document.createElement('div');
div.className = 'message';
const isUser = role === 'user';
const currentModel = document.getElementById('modelSelect').value;
const modelName = state.models[currentModel]?.name || 'Assistente';
// FIX: contenuto renderizzato con innerHTML MA solo dopo escape+markdown
const avatar = document.createElement('div');
avatar.className = 'msg-avatar ' + role;
avatar.textContent = isUser ? 'πŸ‘€' : 'πŸ€–';
const contentDiv = document.createElement('div');
contentDiv.className = 'msg-content';
const roleDiv = document.createElement('div');
roleDiv.className = 'msg-role';
roleDiv.textContent = isUser ? 'Tu' : modelName;
const textDiv = document.createElement('div');
textDiv.className = 'msg-text ' + role;
textDiv.innerHTML = renderMarkdown(content); // safe: renderMarkdown fa giΓ  escape
const actionsDiv = document.createElement('div');
actionsDiv.className = 'msg-actions';
const copyBtn = document.createElement('button');
copyBtn.className = 'msg-btn';
copyBtn.textContent = 'πŸ“‹ Copia';
copyBtn.onclick = ()=>{
navigator.clipboard.writeText(textDiv.innerText);
showToast('βœ… Copiato');
};
actionsDiv.appendChild(copyBtn);
contentDiv.appendChild(roleDiv);
contentDiv.appendChild(textDiv);
contentDiv.appendChild(actionsDiv);
div.appendChild(avatar);
div.appendChild(contentDiv);
area.appendChild(div);
area.scrollTop = area.scrollHeight;
if(save){
state.sessions[state.currentSession].messages.push({role, content, timestamp: Date.now()});
saveSessions();
renderSessions();
}
return textDiv; // ritorna l'elemento DOM per streaming
}
function showTypingIndicator(textDiv){
textDiv.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div>';
}
function escapeHtml(text){
const div = document.createElement('div');
div.textContent = String(text ?? '');
return div.innerHTML;
}
function escapeAttr(text){
return String(text ?? '').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function renderMarkdown(text){
if(!text) return '';
text = escapeHtml(text);
text = text.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_,lang,code)=>
'<pre><code class="lang-' + (lang||'') + '">' + code.trim() + '</code></pre>');
text = text.replace(/`([^`\n]+)`/g, '<code>$1</code>');
text = text.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
text = text.replace(/\*([^*\n]+)\*/g, '<i>$1</i>');
text = text.replace(/\n/g, '<br>');
return text;
}
function sendSuggestion(text){
document.getElementById('userInput').value = text;
sendMessage();
}
async function sendMessage(){
const input = document.getElementById('userInput');
const message = input.value.trim();
if(!message || state.isGenerating) return;
const modelSel = document.getElementById('modelSelect');
let model = modelSel.value;
if(!model || model.trim() === ''){
model = localStorage.getItem('selectedModel') || state.defaultModel || DEFAULT_MODEL;
modelSel.value = model;
}
console.log('πŸ“€ model:', model, '| session:', state.currentSession);
const systemPrompt = document.getElementById('systemPrompt').value || 'Sei un assistente utile.';
const temperature = parseFloat(document.getElementById('temperature').value);
const maxTokens = parseInt(document.getElementById('maxTokens').value);
const useMemory = document.getElementById('useMemory').checked;
const streamMode = document.getElementById('streamMode').checked;
input.value = '';
input.style.height = 'auto';
updateTokenCount();
appendMessage('user', message);
state.isGenerating = true;
document.getElementById('sendBtn').disabled = true;
const textDiv = appendMessage('assistant', '', false);
showTypingIndicator(textDiv);
let fullText = '';
try{
if(streamMode){
await streamChat({
message, model,
session_id: state.currentSession,
system_prompt: systemPrompt,
temperature, max_tokens: maxTokens,
use_memory: useMemory
}, (chunk)=>{
fullText += chunk;
textDiv.innerHTML = renderMarkdown(fullText);
document.getElementById('chatArea').scrollTop = document.getElementById('chatArea').scrollHeight;
});
textDiv.innerHTML = renderMarkdown(fullText);
} else {
const r = await fetch('/v1/chat', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({
message, model,
session_id: state.currentSession,
system_prompt: systemPrompt,
temperature, max_tokens: maxTokens,
use_memory: useMemory, stream: false
})
});
const data = await r.json();
if(!r.ok) throw new Error(data.detail || data.error || 'Errore server');
fullText = data.response || '';
textDiv.innerHTML = renderMarkdown(fullText);
}
if(fullText){
state.sessions[state.currentSession].messages.push({
role:'assistant', content: fullText, timestamp: Date.now()
});
saveSessions();
renderSessions();
updateStats();
}
}catch(e){
console.error('❌', e);
textDiv.innerHTML = '<div class="error-msg">❌ ' + escapeHtml(e.message) + '<br><br>πŸ’‘ Prova un altro modello (LLaMA 3.1 8B, Mistral 7B)</div>';
} finally {
state.isGenerating = false;
document.getElementById('sendBtn').disabled = false;
input.focus();
}
}
async function streamChat(params, onChunk){
return new Promise((resolve, reject)=>{
const model = params.model || localStorage.getItem('selectedModel') || DEFAULT_MODEL;
console.log('πŸš€ SSE model:', model);
const qs = new URLSearchParams({
message: String(params.message || ''),
model: String(model),
session_id: String(params.session_id || 'default'),
system_prompt: String(params.system_prompt || ''),
temperature: String(params.temperature || 0.7),
max_tokens: String(params.max_tokens || 4096),
use_memory: String(params.use_memory !== false)
});
const es = new EventSource('/v1/chat/stream?' + qs.toString());
state.currentEventSource = es;
let received = false;
let hadError = false;
es.addEventListener('start', (e)=>console.log('▢️', e.data));
es.addEventListener('token', (e)=>{
try{
const d = JSON.parse(e.data);
if(d.token){ received = true; onChunk(d.token); }
}catch(err){}
});
es.addEventListener('done', (e)=>{
console.log('βœ… done');
es.close();
state.currentEventSource = null;
resolve();
});
es.addEventListener('error', (e)=>{
console.error('❌ SSE error:', e.data);
hadError = true;
es.close();
state.currentEventSource = null;
if(e.data){
try{ reject(new Error(JSON.parse(e.data).error || 'Stream error')); }
catch{ reject(new Error('Stream error')); }
} else if(received){
resolve();
} else {
reject(new Error('Connessione SSE fallita per modello ' + model));
}
});
setTimeout(()=>{
if(!received && !hadError){
es.close();
reject(new Error('Timeout 60s'));
}
}, 60000);
});
}
// ═══════════════════════════════════════════════════════
// TEST MODELLO CORRENTE
// ═══════════════════════════════════════════════════════
async function testCurrentModel(){
const model = document.getElementById('modelSelect').value;
showToast('πŸ§ͺ Test in corso...');
try{
const r = await fetch('/v1/debug/test-model?model=' + encodeURIComponent(model));
const data = await r.json();
if(data.status === 'βœ… OK'){
showToast('βœ… ' + model + ' OK (' + data.elapsed_ms + 'ms)');
} else {
showToast('❌ ' + model + ' non funziona', true);
}
}catch(e){
showToast('❌ Errore test', true);
}
}
// ═══════════════════════════════════════════════════════
// STATS
// ═══════════════════════════════════════════════════════
async function updateStats(){
try{
const r = await fetch('/v1/memory/' + encodeURIComponent(state.currentSession));
const s = await r.json();
document.getElementById('statShort').textContent = s.short_memory?.messages || 0;
document.getElementById('statLong').textContent = s.long_memory?.messages || 0;
document.getElementById('statTokens').textContent = ((s.total_tokens || 0) / 1000).toFixed(1) + 'k';
document.getElementById('statTotal').textContent = s.total_messages || 0;
}catch(e){}
}
// ═══════════════════════════════════════════════════════
// INPUT
// ═══════════════════════════════════════════════════════
function setupInputAutoResize(){
const input = document.getElementById('userInput');
input.addEventListener('input', ()=>{
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 200) + 'px';
updateTokenCount();
});
input.addEventListener('keydown', (e)=>{
if(e.key === 'Enter' && !e.shiftKey){
e.preventDefault();
sendMessage();
}
});
}
function updateTokenCount(){
const v = document.getElementById('userInput').value;
document.getElementById('tokenCount').textContent = v.length + ' caratteri';
}
// ═══════════════════════════════════════════════════════
// EXPORT
// ═══════════════════════════════════════════════════════
function exportChat(){
const s = state.sessions[state.currentSession];
const blob = new Blob([JSON.stringify(s, null, 2)], {type:'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'chat-' + state.currentSession + '-' + Date.now() + '.json';
a.click();
URL.revokeObjectURL(url);
showToast('βœ… Esportata');
}
// ═══════════════════════════════════════════════════════
// MODAL
// ═══════════════════════════════════════════════════════
function openModal(type){
document.getElementById('modal').classList.add('active');
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
const base = location.origin;
if(type === 'models'){
title.textContent = 'πŸ€– Catalogo Modelli';
let html = '<p style="color:var(--text-dim);margin-bottom:12px">Totale: <b>' + Object.keys(state.models).length + '</b></p>';
html += '<table class="endpoint-table"><tr><th>Cat</th><th>Nome</th><th>ID</th><th>Ctx</th></tr>';
for(const [id,m] of Object.entries(state.models)){
html += '<tr><td>' + escapeHtml(m.category) + '</td><td>' + escapeHtml(m.name) + '</td><td><code>' + escapeHtml(id) + '</code></td><td>' + m.context.toLocaleString() + '</td></tr>';
}
html += '</table>';
body.innerHTML = html;
}
else if(type === 'test'){
title.textContent = 'πŸš€ Test Parallelo Async';
body.innerHTML =
'<p>Testa <b>tutti i modelli in parallelo</b> con 15 richieste simultanee.<br>' +
'Impiega solo ~5-10 secondi invece di 60+ seriali.</p><br>' +
'<button class="btn-new" onclick="runParallelTest()">πŸš€ Avvia Test Parallelo</button> ' +
'<button class="btn-new" onclick="runAutoClean()">🧹 Auto-Clean Catalogo</button>' +
'<div id="testResults" style="margin-top:16px"></div>';
}
else if(type === 'api'){
title.textContent = 'πŸ“– API Reference';
body.innerHTML =
'<h3>Chat GET</h3><div class="code-block"><button class="copy-btn" onclick="copyBlock(this)">Copia</button>curl "' + base + '/v1/chat/get?message=Ciao&model=meta/llama-3.1-8b-instruct&session_id=test"</div>' +
'<h3>Chat POST</h3><div class="code-block"><button class="copy-btn" onclick="copyBlock(this)">Copia</button>curl -X POST ' + base + '/v1/chat \\\n -H "Content-Type: application/json" \\\n -d \'{"message":"Ciao!","model":"meta/llama-3.1-8b-instruct"}\'</div>' +
'<h3>SSE Streaming</h3><div class="code-block"><button class="copy-btn" onclick="copyBlock(this)">Copia</button>curl -N "' + base + '/v1/chat/stream?message=Ciao&model=meta/llama-3.1-8b-instruct"</div>' +
'<h3>πŸš€ Test Parallelo Ultra-veloce</h3><div class="code-block"><button class="copy-btn" onclick="copyBlock(this)">Copia</button>curl "' + base + '/v1/debug/test-all-parallel?concurrency=15"</div>' +
'<h3>🧹 Auto-Clean Catalogo</h3><div class="code-block"><button class="copy-btn" onclick="copyBlock(this)">Copia</button>curl "' + base + '/v1/debug/auto-clean"</div>';
}
else if(type === 'mcp'){
title.textContent = 'πŸ”Œ MCP Server';
body.innerHTML =
'<h3>Config Claude Desktop / Cursor</h3>' +
'<div class="code-block"><button class="copy-btn" onclick="copyBlock(this)">Copia</button>{\n "mcpServers": {\n "nvidia-ai": {\n "url": "' + base + '/v1/mcp",\n "transport": "http"\n }\n }\n}</div>' +
'<h3>Tools disponibili</h3>' +
'<ul style="line-height:1.8">' +
'<li><code>nvidia_chat</code> β€” Chat con memoria</li>' +
'<li><code>multi_model_compare</code> β€” πŸ”₯ Confronta piΓΉ modelli in PARALLELO</li>' +
'<li><code>list_models</code> β€” Lista modelli</li>' +
'<li><code>memory_stats</code> β€” Statistiche memoria</li>' +
'<li><code>memory_clear</code> β€” Pulisci memoria</li>' +
'<li><code>get_conversation_history</code> β€” Cronologia</li>' +
'</ul>' +
'<h3>Test</h3>' +
'<div class="code-block"><button class="copy-btn" onclick="copyBlock(this)">Copia</button>curl -X POST ' + base + '/v1/mcp \\\n -H "Content-Type: application/json" \\\n -d \'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\'</div>';
}
}
async function runParallelTest(){
const results = document.getElementById('testResults');
results.innerHTML = '<div class="test-loading"><div class="typing-dots"><span></span><span></span><span></span></div><p>Test in corso... (~5-10 sec)</p></div>';
try{
const r = await fetch('/v1/debug/test-all-parallel?concurrency=15');
const data = await r.json();
let html = '<h3>⏱️ Completato in ' + data.summary.total_time_seconds + 's</h3>';
html += '<p>βœ… Funzionanti: <b>' + data.summary.working + '</b> / ' + data.summary.total_tested + '</p>';
html += '<p>❌ Rotti: <b>' + data.summary.broken + '</b></p>';
html += '<h4>βœ… Modelli funzionanti (ordinati per velocitΓ ):</h4>';
html += '<table class="endpoint-table"><tr><th>Model</th><th>ms</th></tr>';
for(const w of data.working_models_by_speed){
html += '<tr><td><code>' + escapeHtml(w.model) + '</code></td><td>' + w.elapsed_ms + '</td></tr>';
}
html += '</table>';
if(data.broken_models.length > 0){
html += '<h4>❌ Modelli non funzionanti:</h4>';
html += '<div class="code-block">' + data.broken_ids_only.map(escapeHtml).join('\n') + '</div>';
}
results.innerHTML = html;
showToast('βœ… Test completato in ' + data.summary.total_time_seconds + 's');
}catch(e){
results.innerHTML = '<div class="error-msg">❌ ' + escapeHtml(e.message) + '</div>';
}
}
async function runAutoClean(){
const results = document.getElementById('testResults');
results.innerHTML = '<div class="test-loading"><div class="typing-dots"><span></span><span></span><span></span></div><p>Auto-clean in corso...</p></div>';
try{
const r = await fetch('/v1/debug/auto-clean');
const data = await r.json();
let html = '<h3>🧹 Catalogo Pulito</h3>';
html += '<p>Da <b>' + data.summary.original_count + '</b> a <b>' + data.summary.clean_catalog_size + '</b> modelli in ' + data.summary.total_time_seconds + 's</p>';
html += '<h4>Snippet Python da copiare in app.py:</h4>';
html += '<div class="code-block"><button class="copy-btn" onclick="copyBlock(this)">Copia</button>' + escapeHtml(data.python_snippet) + '</div>';
results.innerHTML = html;
showToast('βœ… Auto-clean completato');
}catch(e){
results.innerHTML = '<div class="error-msg">❌ ' + escapeHtml(e.message) + '</div>';
}
}
function copyBlock(btn){
const text = btn.parentElement.innerText.replace(/^Copia\s*/,'').trim();
navigator.clipboard.writeText(text);
btn.textContent = 'βœ…';
setTimeout(()=>btn.textContent='Copia', 1500);
}
function closeModal(){
document.getElementById('modal').classList.remove('active');
}
document.getElementById('modal').addEventListener('click', (e)=>{
if(e.target.id === 'modal') closeModal();
});
function showToast(msg, isError=false){
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast show' + (isError ? ' error' : '');
setTimeout(()=>t.classList.remove('show'), 3000);
}
init();
</script>
</body>
</html>