Spaces:
Running
Running
| <html lang="es"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Agente de Metalurgia</title> | |
| <style> | |
| :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; } | |
| * { box-sizing: border-box; } | |
| body { margin: 0; background: #0c1117; color: #e7edf4; } | |
| main { width: min(900px, 100%); height: 100dvh; margin: auto; display: grid; grid-template-rows: auto auto 1fr auto; padding: 20px; gap: 14px; } | |
| h1 { margin: 0; font-size: 1.35rem; } | |
| header p { margin: 5px 0 0; color: #91a0b2; font-size: .9rem; } | |
| .controls { display: grid; grid-template-columns: 1fr 2fr auto; gap: 10px; } | |
| select, textarea, button { font: inherit; } | |
| select, .new-chat { min-height: 42px; border: 1px solid #2b3949; border-radius: 10px; background: #131b24; color: inherit; padding: 0 12px; } | |
| .new-chat { background: #243243; color: inherit; } | |
| #messages { overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding: 8px 2px; } | |
| .message { max-width: 82%; padding: 12px 14px; border-radius: 14px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; } | |
| .user { align-self: flex-end; background: #176b4d; } | |
| .agent { align-self: flex-start; background: #18212c; border: 1px solid #263444; } | |
| .error { color: #ffb4b4; border-color: #7c3030; } | |
| form { display: grid; grid-template-columns: 1fr auto; gap: 10px; } | |
| textarea { resize: none; min-height: 54px; max-height: 160px; padding: 15px; border: 1px solid #2b3949; border-radius: 13px; background: #131b24; color: inherit; outline: none; } | |
| textarea:focus { border-color: #35a677; } | |
| button { border: 0; border-radius: 13px; padding: 0 22px; background: #2dba83; color: #07130e; font-weight: 700; cursor: pointer; } | |
| button:disabled { opacity: .55; cursor: wait; } | |
| @media (max-width: 560px) { main { padding: 12px; } .controls { grid-template-columns: 1fr; } .message { max-width: 92%; } button { padding: 10px 15px; } } | |
| </style> | |
| </head> | |
| <body> | |
| <main> | |
| <header> | |
| <h1>Agente de Metalurgia</h1> | |
| <p>Conversaciones persistentes con contexto de ASTM E112</p> | |
| </header> | |
| <section class="controls"> | |
| <select id="user" aria-label="Usuario"></select> | |
| <select id="conversation" aria-label="Conversación"></select> | |
| <button id="new-chat" class="new-chat" type="button">Nueva conversación</button> | |
| </section> | |
| <section id="messages" aria-live="polite"></section> | |
| <form id="chat-form"> | |
| <textarea id="prompt" rows="1" placeholder="Escribí tu consulta…" required autofocus></textarea> | |
| <button id="send" type="submit">Enviar</button> | |
| </form> | |
| </main> | |
| <script> | |
| const form = document.querySelector('#chat-form'); | |
| const prompt = document.querySelector('#prompt'); | |
| const send = document.querySelector('#send'); | |
| const messages = document.querySelector('#messages'); | |
| const userSelect = document.querySelector('#user'); | |
| const conversationSelect = document.querySelector('#conversation'); | |
| const newChat = document.querySelector('#new-chat'); | |
| let conversationId = null; | |
| function addMessage(text, type, extra = '') { | |
| const item = document.createElement('div'); | |
| item.className = `message ${type} ${extra}`; | |
| item.textContent = text; | |
| messages.appendChild(item); | |
| messages.scrollTop = messages.scrollHeight; | |
| return item; | |
| } | |
| function resetMessages() { | |
| messages.innerHTML = ''; | |
| addMessage('Hola. ¿En qué puedo ayudarte?', 'agent'); | |
| } | |
| async function loadUsers() { | |
| const response = await fetch('/chat/users'); | |
| if (!response.ok) throw new Error('No se pudieron cargar los usuarios'); | |
| const users = await response.json(); | |
| userSelect.innerHTML = users.map(item => `<option value="${item.id}">${item.username}</option>`).join(''); | |
| if (users.length) await loadConversations(); | |
| } | |
| async function loadConversations(selectId = null) { | |
| const response = await fetch(`/chat/conversations?user_id=${userSelect.value}`); | |
| if (!response.ok) throw new Error('No se pudieron cargar las conversaciones'); | |
| const conversations = await response.json(); | |
| conversationSelect.innerHTML = '<option value="">Nueva conversación</option>' + conversations | |
| .map(item => `<option value="${item.id}">${item.title || `Conversación ${item.id}`}</option>`).join(''); | |
| conversationSelect.value = selectId ? String(selectId) : ''; | |
| conversationId = selectId; | |
| if (selectId) await loadMessages(); else resetMessages(); | |
| } | |
| async function loadMessages() { | |
| if (!conversationId) return resetMessages(); | |
| const response = await fetch(`/chat/conversations/${conversationId}/messages?user_id=${userSelect.value}`); | |
| if (!response.ok) throw new Error('No se pudo cargar el historial'); | |
| const history = await response.json(); | |
| messages.innerHTML = ''; | |
| history.filter(item => item.role === 'user' || item.role === 'assistant') | |
| .forEach(item => addMessage(item.content, item.role === 'user' ? 'user' : 'agent')); | |
| } | |
| prompt.addEventListener('keydown', event => { | |
| if (event.key === 'Enter' && !event.shiftKey) { | |
| event.preventDefault(); | |
| form.requestSubmit(); | |
| } | |
| }); | |
| userSelect.addEventListener('change', () => loadConversations()); | |
| conversationSelect.addEventListener('change', async () => { | |
| conversationId = conversationSelect.value ? Number(conversationSelect.value) : null; | |
| if (conversationId) await loadMessages(); else resetMessages(); | |
| }); | |
| newChat.addEventListener('click', () => { | |
| conversationId = null; | |
| conversationSelect.value = ''; | |
| resetMessages(); | |
| prompt.focus(); | |
| }); | |
| form.addEventListener('submit', async event => { | |
| event.preventDefault(); | |
| const text = prompt.value.trim(); | |
| if (!text || send.disabled || !userSelect.value) return; | |
| addMessage(text, 'user'); | |
| prompt.value = ''; | |
| send.disabled = true; | |
| const pending = addMessage('Pensando…', 'agent'); | |
| try { | |
| const response = await fetch('/chat/stream', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text, user_id: Number(userSelect.value), conversation_id: conversationId }) | |
| }); | |
| if (!response.ok) throw new Error(`Error HTTP ${response.status}`); | |
| if (!response.body) throw new Error('El navegador no recibio el stream.'); | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| let answer = ''; | |
| pending.textContent = 'Conectando con el modelo...'; | |
| while (true) { | |
| const { value, done } = await reader.read(); | |
| buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop() || ''; | |
| for (const line of lines) { | |
| if (!line.trim()) continue; | |
| const data = JSON.parse(line); | |
| if (data.type === 'metadata') { | |
| if (!conversationId) { | |
| conversationId = data.conversation_id; | |
| await loadConversations(conversationId); | |
| } | |
| } else if (data.type === 'status') { | |
| if (!answer) pending.textContent = data.text; | |
| } else if (data.type === 'delta') { | |
| answer += data.text; | |
| pending.textContent = answer; | |
| } else if (data.type === 'error') { | |
| throw new Error(data.detail || 'Error durante la generacion'); | |
| } | |
| } | |
| if (done) break; | |
| } | |
| } catch (error) { | |
| pending.textContent = `No se pudo obtener respuesta: ${error.message}`; | |
| pending.classList.add('error'); | |
| } finally { | |
| send.disabled = false; | |
| prompt.focus(); | |
| } | |
| }); | |
| loadUsers().catch(error => addMessage(error.message, 'agent', 'error')); | |
| </script> | |
| </body> | |
| </html> | |