| <!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,
|
| };
|
|
|
|
|
|
|
|
|
| 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>';
|
| }
|
|
|
|
|
|
|
|
|
| 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');
|
| }
|
|
|
|
|
|
|
|
|
| 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';
|
|
|
|
|
| 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);
|
|
|
| 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;
|
| }
|
|
|
| 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, '"').replace(/'/g, ''');
|
| }
|
|
|
| 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);
|
| });
|
| }
|
|
|
|
|
|
|
|
|
| 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);
|
| }
|
| }
|
|
|
|
|
|
|
|
|
| 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){}
|
| }
|
|
|
|
|
|
|
|
|
| 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';
|
| }
|
|
|
|
|
|
|
|
|
| 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');
|
| }
|
|
|
|
|
|
|
|
|
| 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> |