File size: 8,120 Bytes
9a1014e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
<!doctype html>
<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>