File size: 8,693 Bytes
2235a2a
1bfeffc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2235a2a
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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Minerva Chat</title>
<style>
  :root {
    --fondo: #f7f5fb; --panel: #ffffff; --texto: #1f1a2e; --suave: #6b6480; --borde: #e3def0;
    --yo: #5b3fc4; --yo-texto: #ffffff; --ella: #f0ecfa; --acento: #d4a017;
  }
  @media (prefers-color-scheme: dark) {
    :root {
      --fondo: #15121d; --panel: #1e1a29; --texto: #ece8f6; --suave: #a39cb8; --borde: #322c44;
      --yo: #7b5ff0; --yo-texto: #ffffff; --ella: #2a2438; --acento: #e8b931;
    }
  }
  * { box-sizing: border-box; }
  body { margin: 0; background: var(--fondo); color: var(--texto);
         font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
  main { max-width: 760px; margin: 0 auto; padding: 16px; min-height: 100vh; display: flex; flex-direction: column; gap: 12px; }
  header h1 { margin: 0; font-size: 1.5rem; }
  header p { margin: 4px 0 0; color: var(--suave); font-size: .92rem; }
  header a { color: var(--yo); }
  #estado { background: var(--panel); border: 1px solid var(--borde); border-radius: 12px; padding: 12px; font-size: .92rem; }
  #barra { height: 6px; background: var(--borde); border-radius: 3px; margin-top: 8px; overflow: hidden; }
  #barra div { height: 100%; width: 0; background: var(--yo); transition: width .2s; }
  #chat { flex: 1; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
  .msg { max-width: 85%; padding: 10px 14px; border-radius: 16px; white-space: pre-wrap; word-wrap: break-word; }
  .yo { align-self: flex-end; background: var(--yo); color: var(--yo-texto); border-bottom-right-radius: 4px; }
  .ella { align-self: flex-start; background: var(--ella); border-bottom-left-radius: 4px; }
  .ella::before { content: "🦉 Minerva"; display: block; font-size: .75rem; font-weight: 600; color: var(--suave); margin-bottom: 2px; }
  #ejemplos { display: flex; flex-wrap: wrap; gap: 8px; }
  #ejemplos button { background: var(--panel); color: var(--texto); border: 1px solid var(--borde); border-radius: 999px;
                     padding: 6px 12px; cursor: pointer; font: inherit; font-size: .88rem; }
  form { display: flex; gap: 8px; }
  input[type=text] { flex: 1; min-width: 0; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--borde);
                     background: var(--panel); color: var(--texto); font: inherit; }
  button.principal { padding: 12px 18px; border: 0; border-radius: 12px; background: var(--yo); color: var(--yo-texto);
                     font: inherit; font-weight: 600; cursor: pointer; }
  button:disabled { opacity: .5; cursor: default; }
  .ajustes { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; color: var(--suave); font-size: .88rem; }
  .ajustes button { background: none; border: 0; color: var(--yo); cursor: pointer; font: inherit; padding: 0; }
  footer { color: var(--suave); font-size: .8rem; text-align: center; }
</style>
</head>
<body>
<main>
  <header>
    <h1>🦉 Minerva v0.1</h1>
    <p>Un modelo de lenguaje en español de 30 millones de parámetros, entrenado desde cero en un portátil.
       Habla bien, pero <strong>se inventa casi todo</strong>: es un modelo de juguete.
       <a href="https://huggingface.co/AxoneSystem/Minerva-v0.1" target="_blank" rel="noopener">Ver el modelo</a></p>
  </header>

  <div id="estado">Preparando…<div id="barra"><div></div></div></div>

  <div id="chat"></div>

  <div id="ejemplos">
    <button>¿Quién eres?</button>
    <button>¿Por qué te llamas Minerva?</button>
    <button>¿Qué es la paella?</button>
    <button>Cuéntame un cuento corto.</button>
  </div>

  <form id="formulario">
    <input id="entrada" type="text" placeholder="Escribe a Minerva…" autocomplete="off" disabled>
    <button class="principal" id="enviar" disabled>Enviar</button>
  </form>

  <div class="ajustes">
    <label>Temperatura <input id="temp" type="range" min="0.1" max="1.5" step="0.1" value="0.7">
      <span id="tempValor">0.7</span> <small>(más alta = más loca)</small></label>
    <button id="nueva" type="button">Nueva conversación</button>
  </div>

  <footer>Funciona entero en tu navegador (llama.cpp en WebAssembly): la primera vez descarga ~60 MB.</footer>
</main>

<script type="module">
import { Wllama } from 'https://cdn.jsdelivr.net/npm/@wllama/wllama@3.6.1/esm/index.js';

const USUARIO = '<|usuario|>', ASISTENTE = '<|asistente|>', FIN = '<|fin|>';
const MEMORIA = 1200;   // caracteres de historial que recuerda (su contexto es de solo 512 tokens)
const $ = (id) => document.getElementById(id);
const estado = $('estado'), barra = document.querySelector('#barra div');
const chat = $('chat'), entrada = $('entrada'), enviar = $('enviar');
let historial = [];     // [{yo, ella}]
let ocupada = true;

$('temp').addEventListener('input', (e) => { $('tempValor').textContent = e.target.value; });
$('nueva').addEventListener('click', () => { historial = []; chat.innerHTML = ''; entrada.focus(); });
document.querySelectorAll('#ejemplos button').forEach((b) =>
  b.addEventListener('click', () => { if (!ocupada) preguntar(b.textContent); }));
$('formulario').addEventListener('submit', (e) => {
  e.preventDefault();
  const q = entrada.value.trim();
  if (q && !ocupada) { entrada.value = ''; preguntar(q); }
});

function burbuja(clase, texto) {
  const d = document.createElement('div');
  d.className = 'msg ' + clase;
  d.textContent = texto;
  chat.appendChild(d);
  d.scrollIntoView({ block: 'end' });
  return d;
}

function prompt(pregunta) {
  // Mismo formato con el que se entrenó: <|usuario|>…<|asistente|>…<|fin|>
  let previo = '';
  for (let i = historial.length - 1; i >= 0; i--) {
    const turno = USUARIO + historial[i].yo + ASISTENTE + historial[i].ella + FIN;
    if (previo.length + turno.length > MEMORIA) break;
    previo = turno + previo;
  }
  return previo + USUARIO + pregunta + ASISTENTE;
}

function bloquear(si) {
  ocupada = si;
  entrada.disabled = enviar.disabled = si;
  if (!si) entrada.focus();
}

const URL_MODELO = 'https://huggingface.co/AxoneSystem/Minerva-v0.1/resolve/main/Minerva-v0.1-F16.gguf';
const PARAMS = { n_ctx: 512, n_gpu_layers: 0 };
// Con almacenamiento del navegador (OPFS), wllama guarda el modelo y la próxima visita es instantánea.
// Sin él (algunos navegadores o vistas previas), lo descargamos nosotros y lo cargamos desde memoria.
const hayOPFS = !!(navigator.storage && navigator.storage.getDirectory);
const wllama = new Wllama(
  { default: 'https://cdn.jsdelivr.net/npm/@wllama/wllama@3.6.1/esm/wasm/wllama.wasm' },
  hayOPFS ? {} : { cacheManager: {}, modelManager: {} },
);

function progreso(loaded, total) {
  const pct = total ? Math.round((loaded / total) * 100) : 0;
  barra.style.width = pct + '%';
  estado.firstChild.textContent = `Descargando a Minerva… ${pct}%`;
}

async function descargarEnMemoria() {
  const r = await fetch(URL_MODELO);
  if (!r.ok) throw new Error('HTTP ' + r.status);
  const total = Number(r.headers.get('content-length')) || 0;
  const lector = r.body.getReader();
  const trozos = [];
  let cargado = 0;
  for (;;) {
    const { done, value } = await lector.read();
    if (done) break;
    trozos.push(value);
    cargado += value.length;
    progreso(cargado, total);
  }
  return new Blob(trozos);
}

async function preguntar(q) {
  bloquear(true);
  burbuja('yo', q);
  const b = burbuja('ella', '…');
  let texto = '';
  try {
    await wllama.createCompletion({
      prompt: prompt(q),
      max_tokens: 150,
      temperature: parseFloat($('temp').value),
      top_k: 30,
      stop: [FIN, USUARIO],
      stream: true,
      onData: (trozo) => {
        texto += trozo.choices?.[0]?.text ?? '';
        b.textContent = texto.trim() || '…';
        b.scrollIntoView({ block: 'end' });
      },
    });
    texto = texto.trim() || '(se ha quedado sin palabras)';
    b.textContent = texto;
    historial.push({ yo: q, ella: texto });
  } catch (err) {
    b.textContent = 'Error: ' + (err?.message || err);
  }
  bloquear(false);
}

try {
  estado.firstChild.textContent = 'Descargando a Minerva (~60 MB)…';
  if (hayOPFS) {
    await wllama.loadModelFromHF(
      { repo: 'AxoneSystem/Minerva-v0.1', file: 'Minerva-v0.1-F16.gguf' },
      { ...PARAMS, progressCallback: ({ loaded, total }) => progreso(loaded, total) },
    );
  } else {
    await wllama.loadModel([await descargarEnMemoria()], PARAMS);
  }
  estado.style.display = 'none';
  bloquear(false);
} catch (err) {
  estado.firstChild.textContent = 'No se pudo cargar el modelo: ' + (err?.message || err);
}
</script>
</body>
</html>