@danieltmn/tamnora 0.23.1 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.24.1] - 2026-10-28
9
+
10
+ ### Cambios
11
+
12
+ - fix(consola): deja de repintar el selector de modelos en cada cambio de pestaña
13
+
14
+ ## [0.24.0] - 2026-10-28
15
+
16
+ ### Cambios
17
+
18
+ - feat(modelos): catalogo completo de OpenRouter con busqueda y seleccion de modelos
19
+
8
20
  ## [0.23.1] - 2026-10-28
9
21
 
10
22
  ### Cambios
@@ -28,6 +28,7 @@ export {
28
28
  createCustomProvider,
29
29
  resolveProviderRuntime,
30
30
  listProviderModels,
31
+ selectVisibleModels,
31
32
  testProviderConnection,
32
33
  streamChat,
33
34
  } from './providers/index.js';
@@ -83,6 +83,18 @@ function manualModel(model) {
83
83
  return { id, label: model.label ?? id, vision: Boolean(model.vision) };
84
84
  }
85
85
 
86
+ /**
87
+ * Acota un catalogo a la seleccion del usuario. `visible` vacio o ausente
88
+ * significa "sin seleccion": se muestra todo. Es solo de presentacion, no
89
+ * cambia la resolucion del provider.
90
+ */
91
+ export function selectVisibleModels(models = [], visible = null) {
92
+ if (!Array.isArray(models) || !models.length) return [];
93
+ if (!Array.isArray(visible) || !visible.length) return models;
94
+ const wanted = new Set(visible.map(String));
95
+ return models.filter((m) => wanted.has(String(m?.id)));
96
+ }
97
+
86
98
  export async function listProviderModels(settings = {}) {
87
99
  const { provider, config } = resolveProviderRuntime(settings);
88
100
  const manual = settings.providerModels?.[provider.id];
@@ -14,8 +14,10 @@ function openrouterHeaders({ apiKey, siteUrl, siteName, headers = {} }) {
14
14
  }
15
15
 
16
16
  /**
17
- * OpenRouter adapter. Only exposes free models, matching the app's original
18
- * behavior (`:free` suffix or zero prompt/completion pricing).
17
+ * OpenRouter adapter. Expone el catalogo completo (pagos y gratis), sin los
18
+ * alias `~familia-latest` ni las variantes `:batch` (mismo modelo, cola
19
+ * diferida), que son ruido de seleccion. Los gratis salen primero para que el
20
+ * modelo por defecto siga siendo uno sin costo.
19
21
  */
20
22
  export const openrouterProvider = {
21
23
  id: 'openrouter',
@@ -33,14 +35,10 @@ export const openrouterProvider = {
33
35
  const { data } = await res.json();
34
36
  return sortModels(
35
37
  (data ?? [])
36
- .filter(
37
- (m) =>
38
- m.id?.endsWith(':free') ||
39
- (m.pricing?.prompt === '0' && m.pricing?.completion === '0')
40
- )
38
+ .filter((m) => !m.id?.startsWith('~') && !m.id?.endsWith(':batch'))
41
39
  .map(normalizeModel)
42
40
  .filter((m) => m.id)
43
- );
41
+ ).sort((a, b) => Number(b.free) - Number(a.free));
44
42
  },
45
43
 
46
44
  async streamChat(ctx, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
@@ -91,6 +91,12 @@ export async function providerError(response, { providerLabel, model, hasTools }
91
91
  return new Error(`${providerLabel} error ${response.status}${suffix}`);
92
92
  }
93
93
 
94
+ /** USD por 1M de tokens, que es como se comparan los modelos. */
95
+ const perMillion = (value) => {
96
+ const n = Number(value);
97
+ return Number.isFinite(n) ? Math.round(n * 1e6 * 1e4) / 1e4 : null;
98
+ };
99
+
94
100
  export function normalizeModel(model) {
95
101
  const id = model.id ?? model.name ?? model.model ?? '';
96
102
  const label = model.name ?? model.label ?? model.model ?? id;
@@ -107,7 +113,10 @@ export function normalizeModel(model) {
107
113
  model.context ??
108
114
  0
109
115
  ) || null;
110
- return { id, label, vision, context };
116
+ const prompt = perMillion(model.pricing?.prompt);
117
+ const completion = perMillion(model.pricing?.completion);
118
+ const free = id.endsWith(':free') || (prompt === 0 && completion === 0);
119
+ return { id, label, vision, context, free, pricing: { prompt, completion } };
111
120
  }
112
121
 
113
122
  export function sortModels(models) {
@@ -105,6 +105,7 @@ CREATE TABLE IF NOT EXISTS providers (
105
105
  base_url TEXT,
106
106
  api_key TEXT,
107
107
  models TEXT,
108
+ visible_models TEXT,
108
109
  requires_key INTEGER,
109
110
  custom INTEGER,
110
111
  enabled INTEGER,
@@ -145,9 +146,12 @@ export function openDatabase(file) {
145
146
  }
146
147
 
147
148
  function migrate(db) {
148
- const columns = db.prepare('PRAGMA table_info(messages)').all().map((c) => c.name);
149
- if (!columns.includes('mode')) db.exec('ALTER TABLE messages ADD COLUMN mode TEXT');
150
- if (!columns.includes('agent_id')) db.exec('ALTER TABLE messages ADD COLUMN agent_id TEXT');
149
+ const messages = db.prepare('PRAGMA table_info(messages)').all().map((c) => c.name);
150
+ if (!messages.includes('mode')) db.exec('ALTER TABLE messages ADD COLUMN mode TEXT');
151
+ if (!messages.includes('agent_id')) db.exec('ALTER TABLE messages ADD COLUMN agent_id TEXT');
152
+
153
+ const providers = db.prepare('PRAGMA table_info(providers)').all().map((c) => c.name);
154
+ if (!providers.includes('visible_models')) db.exec('ALTER TABLE providers ADD COLUMN visible_models TEXT');
151
155
  }
152
156
 
153
157
  const now = () => Date.now();
@@ -523,11 +527,11 @@ export class Store {
523
527
  saveProvider(provider) {
524
528
  this.db
525
529
  .prepare(
526
- `INSERT INTO providers (id, label, base_url, api_key, models, requires_key, custom, enabled, updated_at)
527
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
530
+ `INSERT INTO providers (id, label, base_url, api_key, models, visible_models, requires_key, custom, enabled, updated_at)
531
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
528
532
  ON CONFLICT(id) DO UPDATE SET
529
533
  label = excluded.label, base_url = excluded.base_url, api_key = excluded.api_key,
530
- models = excluded.models, requires_key = excluded.requires_key,
534
+ models = excluded.models, visible_models = excluded.visible_models, requires_key = excluded.requires_key,
531
535
  custom = excluded.custom, enabled = excluded.enabled, updated_at = excluded.updated_at`
532
536
  )
533
537
  .run(
@@ -536,6 +540,7 @@ export class Store {
536
540
  provider.baseUrl ?? null,
537
541
  provider.apiKey ?? null,
538
542
  json(provider.models ?? []),
543
+ json(provider.visibleModels ?? []),
539
544
  provider.requiresKey === undefined ? null : provider.requiresKey ? 1 : 0,
540
545
  provider.custom ? 1 : 0,
541
546
  provider.enabled === false ? 0 : 1,
@@ -676,6 +681,7 @@ function toProvider(row) {
676
681
  baseUrl: row.base_url ?? '',
677
682
  apiKey: row.api_key ?? '',
678
683
  models: parseJson(row.models) ?? [],
684
+ visibleModels: parseJson(row.visible_models) ?? [],
679
685
  requiresKey: row.requires_key === null ? undefined : Boolean(row.requires_key),
680
686
  custom: Boolean(row.custom),
681
687
  enabled: row.enabled === 0 ? false : true,
@@ -0,0 +1,90 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { openrouterProvider } from '../src/providers/openrouter.js';
4
+ import { selectVisibleModels } from '../src/providers/index.js';
5
+
6
+ // Catalogo con los casos que filtra el adapter: uno pago, dos gratis (por
7
+ // sufijo `:free` y por precio cero), un alias `~familia-latest` y un `:batch`.
8
+ const CATALOGA = {
9
+ data: [
10
+ { id: 'prov/paid-model', name: 'Prov: Paid Model', pricing: { prompt: '0.000002', completion: '0.00001' } },
11
+ { id: 'prov/free-suffix:free', name: 'Prov: Free Suffix', pricing: { prompt: '0.000002', completion: '0.00001' } },
12
+ { id: 'prov/zero-price', name: 'Prov: Zero Price', pricing: { prompt: '0', completion: '0' } },
13
+ { id: '~prov/alias-latest', name: 'Prov: Alias', pricing: { prompt: '0', completion: '0' } },
14
+ { id: 'prov/paid-model:batch', name: 'Prov: Paid Batch', pricing: { prompt: '0', completion: '0' } },
15
+ ],
16
+ };
17
+
18
+ async function listarConFetchFalso() {
19
+ const original = globalThis.fetch;
20
+ globalThis.fetch = async () =>
21
+ new Response(JSON.stringify(CATALOGA), {
22
+ status: 200,
23
+ headers: { 'content-type': 'application/json' },
24
+ });
25
+ try {
26
+ return await openrouterProvider.listModels({ apiKey: 'k' });
27
+ } finally {
28
+ globalThis.fetch = original;
29
+ }
30
+ }
31
+
32
+ test('openrouter.listModels expone el catalogo completo (pagos y gratis)', async () => {
33
+ const models = await listarConFetchFalso();
34
+ const ids = models.map((m) => m.id);
35
+ assert.ok(ids.includes('prov/paid-model'), 'el modelo pago debe estar');
36
+ assert.ok(ids.includes('prov/free-suffix:free'), 'el gratis por sufijo debe estar');
37
+ assert.ok(ids.includes('prov/zero-price'), 'el gratis por precio cero debe estar');
38
+ assert.equal(models.length, 3);
39
+ });
40
+
41
+ test('openrouter.listModels excluye alias ~ y variantes :batch', async () => {
42
+ const models = await listarConFetchFalso();
43
+ const ids = models.map((m) => m.id);
44
+ assert.ok(!ids.some((id) => id.startsWith('~')), 'sin alias ~');
45
+ assert.ok(!ids.some((id) => id.endsWith(':batch')), 'sin variantes :batch');
46
+ });
47
+
48
+ test('openrouter.listModels ordena los gratis primero', async () => {
49
+ const models = await listarConFetchFalso();
50
+ const primerPago = models.findIndex((m) => !m.free);
51
+ const ultimoGratis = models.map((m) => m.free).lastIndexOf(true);
52
+ assert.notEqual(primerPago, -1, 'debe haber al menos un pago');
53
+ assert.ok(ultimoGratis < primerPago, 'todos los gratis antes que los pagos');
54
+ });
55
+
56
+ test('normalizeModel marca free y convierte pricing a USD por 1M', async () => {
57
+ const models = await listarConFetchFalso();
58
+ const paid = models.find((m) => m.id === 'prov/paid-model');
59
+ const zero = models.find((m) => m.id === 'prov/zero-price');
60
+ const suffix = models.find((m) => m.id === 'prov/free-suffix:free');
61
+
62
+ assert.equal(paid.free, false);
63
+ assert.equal(paid.pricing.prompt, 2);
64
+ assert.equal(paid.pricing.completion, 10);
65
+
66
+ assert.equal(zero.free, true);
67
+ assert.equal(zero.pricing.prompt, 0);
68
+ // gratis por sufijo, aunque el precio no sea cero
69
+ assert.equal(suffix.free, true);
70
+ });
71
+
72
+ test('selectVisibleModels sin seleccion devuelve todo', () => {
73
+ const models = [{ id: 'a' }, { id: 'b' }];
74
+ assert.deepEqual(selectVisibleModels(models, []), models);
75
+ assert.deepEqual(selectVisibleModels(models, null), models);
76
+ assert.deepEqual(selectVisibleModels(models, undefined), models);
77
+ });
78
+
79
+ test('selectVisibleModels acota a los ids seleccionados', () => {
80
+ const models = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
81
+ assert.deepEqual(selectVisibleModels(models, ['a', 'c']).map((m) => m.id), ['a', 'c']);
82
+ // ids desconocidos se ignoran; no rompen
83
+ assert.deepEqual(selectVisibleModels(models, ['a', 'zzz']).map((m) => m.id), ['a']);
84
+ });
85
+
86
+ test('selectVisibleModels con catalogo vacio o no-array → []', () => {
87
+ assert.deepEqual(selectVisibleModels([], ['a']), []);
88
+ assert.deepEqual(selectVisibleModels(null, ['a']), []);
89
+ assert.deepEqual(selectVisibleModels(undefined, ['a']), []);
90
+ });
@@ -109,7 +109,8 @@ import {
109
109
  setTunnelUi,
110
110
  syncPreviewTunnel,
111
111
  } from './explorer.js';
112
- import { renderMarkdown } from './markdown.js';
112
+ import { renderMarkdown } from './markdown.js';
113
+ import { wireModelPicker } from './models.js';
113
114
  import { restoreTabs, startNewSession } from './sessions.js';
114
115
  import {
115
116
  activateAjustesPanel,
@@ -795,7 +796,8 @@ if (brandVerEl) {
795
796
 
796
797
  /* --- boot ---------------------------------------------------------------- */
797
798
 
798
- async function boot() {
799
+ async function boot() {
800
+ wireModelPicker();
799
801
  const auth = await api('/api/auth/me').catch(() => null);
800
802
  if (auth?.enabled && !auth.authenticated) {
801
803
  location.replace('/landing');
@@ -11,7 +11,8 @@ import {
11
11
  } from './appearance.js';
12
12
  import { basename, chatInput, scheduleScrollNav, sendMessage } from './chat.js';
13
13
  import { $, api, clear, el, state, storage } from './core.js';
14
- import { refreshExplorerMode } from './explorer.js';
14
+ import { refreshExplorerMode } from './explorer.js';
15
+ import { pickVisible } from './modelsQuery.js';
15
16
  import { activateSession, registerSession } from './sessions.js';
16
17
  import {
17
18
  activateAjustesPanel,
@@ -531,44 +532,83 @@ export async function applySessionAgent(record) {
531
532
  renderModelPill();
532
533
  }
533
534
 
534
- export async function loadModels({ save = false, prefer = null } = {}) {
535
+ /** Firma de la lista dibujada: si no cambia, no hay que repintar el select. */
536
+ function modelsSignature(shown) {
537
+ return shown.map((m) => `${m.id}\u0000${m.label ?? ''}\u0000${m.vision ? 1 : 0}\u0000${m.context ?? ''}`).join('\u0001');
538
+ }
539
+
540
+ /**
541
+ * Carga el catalogo del proveedor y dibuja el select.
542
+ *
543
+ * `force` vuelve a pedirlo al server; sin el se reusa el de `state` (son ~374
544
+ * modelos / 591 KB, y esto corre en cada cambio de pestana: refetchearlo hacia
545
+ * parpadear la consola).
546
+ */
547
+ export async function loadModels({ save = false, prefer = null, force = false } = {}) {
535
548
  const provider = providerSelect.value;
549
+ const cached = !force && state.modelCatalogProvider === provider && state.modelCatalog?.length;
536
550
  modelSelect.disabled = true;
537
- const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch((error) => ({
538
- models: [],
539
- error: error.message,
540
- }));
541
- const models = data?.models ?? [];
542
- clear(modelSelect);
551
+ try {
552
+ if (!cached) {
553
+ const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch((error) => ({
554
+ models: [],
555
+ error: error.message,
556
+ }));
557
+ state.modelCatalog = data?.models ?? [];
558
+ state.visibleModels = data?.visibleModels ?? [];
559
+ state.modelCatalogProvider = provider;
560
+ if (!state.modelCatalog.length) {
561
+ clear(modelSelect);
562
+ const option = document.createElement('option');
563
+ option.value = '';
564
+ option.textContent = data?.error ? `sin modelos - ${data.error}` : 'sin modelos (configura el proveedor)';
565
+ modelSelect.appendChild(option);
566
+ return;
567
+ }
568
+ }
543
569
 
544
- if (!models.length) {
545
- const option = document.createElement('option');
546
- option.value = '';
547
- option.textContent = data?.error ? `sin modelos — ${data.error}` : 'sin modelos (configurá el proveedor)';
548
- modelSelect.appendChild(option);
549
- updateStatus();
550
- renderTokens();
551
- return;
552
- }
570
+ const catalog = state.modelCatalog;
571
+ const models = pickVisible(catalog, state.visibleModels);
572
+ const activeModel = prefer || state.active.model;
573
+ // Si el modelo activo quedo fuera de la seleccion, lo mostramos igual para no
574
+ // cambiarlo en silencio: sigue siendo el usado aunque no este tildado.
575
+ const hidden = activeModel && catalog.some((m) => m.id === activeModel) && !models.some((m) => m.id === activeModel);
576
+ const shown = hidden ? [catalog.find((m) => m.id === activeModel), ...models] : models;
577
+ if (!shown.length) {
578
+ clear(modelSelect);
579
+ const option = document.createElement('option');
580
+ option.value = '';
581
+ option.textContent = 'sin modelos (configura el proveedor)';
582
+ modelSelect.appendChild(option);
583
+ return;
584
+ }
553
585
 
554
- for (const model of models) {
555
- const option = document.createElement('option');
556
- option.value = model.id;
557
- option.textContent = model.label && model.label !== model.id ? model.label : model.id;
558
- if (model.vision) option.textContent += ' · visión';
559
- if (model.context) option.dataset.context = String(model.context);
560
- modelSelect.appendChild(option);
561
- }
562
- const wanted = prefer || state.active.model;
563
- if (wanted && models.some((m) => m.id === wanted)) {
564
- modelSelect.value = wanted;
565
- } else {
566
- modelSelect.value = models[0].id;
567
- if (save) await saveSettings({ model: modelSelect.value });
586
+ // Repintar el select con cientos de opciones es lo que se ve como parpadeo:
587
+ // solo se hace cuando la lista realmente cambio.
588
+ const signature = modelsSignature(shown);
589
+ if (signature !== state.modelSignature) {
590
+ state.modelSignature = signature;
591
+ clear(modelSelect);
592
+ for (const model of shown) {
593
+ const option = document.createElement('option');
594
+ option.value = model.id;
595
+ option.textContent = model.label && model.label !== model.id ? model.label : model.id;
596
+ if (model.vision) option.textContent += ' - vision';
597
+ if (model.context) option.dataset.context = String(model.context);
598
+ modelSelect.appendChild(option);
599
+ }
600
+ }
601
+
602
+ const wanted = prefer || state.active.model;
603
+ if (wanted && shown.some((m) => m.id === wanted)) {
604
+ modelSelect.value = wanted;
605
+ } else {
606
+ modelSelect.value = models[0].id;
607
+ if (save) await saveSettings({ model: modelSelect.value });
608
+ }
609
+ } finally {
610
+ modelSelect.disabled = false;
568
611
  }
569
- modelSelect.disabled = false;
570
- updateStatus();
571
- renderTokens();
572
612
  }
573
613
 
574
614
  /* --- folder picker -------------------------------------------------------- */
@@ -14,6 +14,13 @@ export const state = {
14
14
  streaming: false,
15
15
  providers: [],
16
16
  active: { provider: '', model: '' },
17
+ // Catalogo de modelos del proveedor y su seleccion. Se cachea porque el
18
+ // catalogo son cientos de modelos (~591 KB) y `loadModels` corre en cada
19
+ // cambio de pestana: refetchearlo hacia parpadear la consola.
20
+ modelCatalog: [],
21
+ modelCatalogProvider: null,
22
+ modelSignature: null,
23
+ visibleModels: [],
17
24
  pendingInput: null,
18
25
  permissionCards: new Map(),
19
26
  questionCards: new Map(),
@@ -75,7 +75,7 @@
75
75
  </head>
76
76
  <body>
77
77
  <header class="topbar">
78
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.23.1</span></div>
78
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.24.1</span></div>
79
79
  <div class="settings">
80
80
  <button id="new-session-top" type="button" class="btn topbar-new" title="nueva sesión" aria-label="nueva sesión">
81
81
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
@@ -538,7 +538,10 @@
538
538
  <label for="provider">Proveedor</label>
539
539
  <select id="provider" title="proveedor"></select>
540
540
  <label for="model">Modelo</label>
541
- <select id="model" title="modelo"></select>
541
+ <div class="model-row">
542
+ <select id="model" title="modelo"></select>
543
+ <button type="button" id="model-pick" class="btn" title="elegir qué modelos mostrar">elegir</button>
544
+ </div>
542
545
  <label class="check" title="aprobar automáticamente tools con permiso ask">
543
546
  <input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
544
547
  </label>
@@ -1228,6 +1231,36 @@
1228
1231
  </div>
1229
1232
  </dialog>
1230
1233
 
1231
- <script type="module" src="app.js"></script>
1234
+ <dialog id="model-modal" class="modal model-modal">
1235
+ <div class="modal-card">
1236
+ <div class="row between">
1237
+ <h3>elegir modelos</h3>
1238
+ <button type="button" id="model-close" class="btn icon" title="cerrar" aria-label="cerrar">
1239
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1240
+ <path d="M18 6 6 18M6 6l12 12" />
1241
+ </svg>
1242
+ </button>
1243
+ </div>
1244
+ <div class="model-search">
1245
+ <input id="model-query" aria-label="buscar modelos" type="text" placeholder="buscar por nombre o id…" spellcheck="false" autocomplete="off" />
1246
+ </div>
1247
+ <div class="model-actions">
1248
+ <button type="button" id="model-all" class="btn">todos</button>
1249
+ <button type="button" id="model-none" class="btn">ninguno</button>
1250
+ <button type="button" id="model-free" class="btn">solo gratis</button>
1251
+ <span class="spacer"></span>
1252
+ <span id="model-count" class="summary"></span>
1253
+ </div>
1254
+ <ul id="model-list" class="model-list"></ul>
1255
+ <div class="row between settings-actions">
1256
+ <span id="model-status" class="summary"></span>
1257
+ <span class="spacer"></span>
1258
+ <button type="button" id="model-cancel" class="btn">cancelar</button>
1259
+ <button type="button" id="model-save" class="btn primary">guardar</button>
1260
+ </div>
1261
+ </div>
1262
+ </dialog>
1263
+
1264
+ <script type="module" src="app.js"></script>
1232
1265
  </body>
1233
1266
  </html>
@@ -0,0 +1,147 @@
1
+ // Selector de modelos: buscador + seleccion de qué modelos mostrar/usar.
2
+ // La logica de busqueda vive en `modelsQuery.js` (pura, testeada); esto es el
3
+ // DOM del modal `#model-modal`. La seleccion se guarda como `visibleModels` del
4
+ // proveedor: vacio = "mostrar todo"; un subconjunto = "mostrar estos".
5
+ import { $, api, clear, el, state } from './core.js';
6
+ import { loadModels, providerSelect } from './chrome.js';
7
+ import { filterModels } from './modelsQuery.js';
8
+
9
+ const picker = { catalog: [], checked: new Set(), query: '' };
10
+
11
+ function fmtPrice(n) {
12
+ return n == null ? '?' : Number.isInteger(n) ? String(n) : n.toFixed(2);
13
+ }
14
+
15
+ function priceEl(model) {
16
+ if (model.free) return el('span', 'model-item-free', 'gratis');
17
+ const p = model.pricing;
18
+ if (!p || (p.prompt == null && p.completion == null)) return el('span', 'model-item-price', '');
19
+ return el('span', 'model-item-price', `$${fmtPrice(p.prompt)}/$${fmtPrice(p.completion)}`);
20
+ }
21
+
22
+ function checkedCount() {
23
+ return picker.catalog.filter((m) => picker.checked.has(m.id)).length;
24
+ }
25
+
26
+ function updateModelCount() {
27
+ const count = $('#model-count');
28
+ if (count) count.textContent = `${checkedCount()} / ${picker.catalog.length} seleccionados`;
29
+ }
30
+
31
+ function setModelStatus(text, cls = '') {
32
+ const status = $('#model-status');
33
+ if (!status) return;
34
+ status.textContent = text;
35
+ status.className = `summary${cls ? ` ${cls}` : ''}`;
36
+ }
37
+
38
+ function renderModelPicker() {
39
+ const list = $('#model-list');
40
+ if (!list) return;
41
+ clear(list);
42
+ const shown = filterModels(picker.query, picker.catalog);
43
+
44
+ if (!shown.length) {
45
+ list.appendChild(el('li', 'model-empty', picker.catalog.length ? 'sin coincidencias' : 'sin modelos'));
46
+ updateModelCount();
47
+ return;
48
+ }
49
+
50
+ for (const model of shown) {
51
+ const li = el('li');
52
+ const label = el('label', 'model-item');
53
+ const cb = el('input');
54
+ cb.type = 'checkbox';
55
+ cb.checked = picker.checked.has(model.id);
56
+ cb.addEventListener('change', () => {
57
+ if (cb.checked) picker.checked.add(model.id);
58
+ else picker.checked.delete(model.id);
59
+ updateModelCount();
60
+ });
61
+
62
+ label.appendChild(cb);
63
+ label.appendChild(
64
+ el('span', 'model-item-name', model.label && model.label !== model.id ? model.label : model.id)
65
+ );
66
+ label.appendChild(el('span', 'model-item-id', model.id));
67
+ label.appendChild(priceEl(model));
68
+ li.appendChild(label);
69
+ list.appendChild(li);
70
+ }
71
+ updateModelCount();
72
+ }
73
+
74
+ function setDisplayed(predicate) {
75
+ for (const m of filterModels(picker.query, picker.catalog)) {
76
+ if (predicate(m)) picker.checked.add(m.id);
77
+ else picker.checked.delete(m.id);
78
+ }
79
+ renderModelPicker();
80
+ }
81
+
82
+ export async function openModelPicker() {
83
+ const provider = providerSelect.value;
84
+ let catalog = state.modelCatalog;
85
+ if (!Array.isArray(catalog) || !catalog.length) {
86
+ const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch(() => null);
87
+ catalog = data?.models ?? [];
88
+ state.modelCatalog = catalog;
89
+ state.visibleModels = data?.visibleModels ?? [];
90
+ }
91
+ picker.catalog = catalog;
92
+
93
+ // Sin seleccion guardada = "mostrar todo": todo tildado. Con seleccion, esa.
94
+ const selected = Array.isArray(state.visibleModels) ? state.visibleModels : [];
95
+ picker.checked = new Set(selected.length ? selected : catalog.map((m) => m.id));
96
+ picker.query = '';
97
+
98
+ const queryInput = $('#model-query');
99
+ if (queryInput) queryInput.value = '';
100
+ setModelStatus('');
101
+ renderModelPicker();
102
+ $('#model-modal')?.showModal();
103
+ }
104
+
105
+ export function closeModelPicker() {
106
+ $('#model-modal')?.close();
107
+ }
108
+
109
+ export async function saveModelPicker() {
110
+ const provider = providerSelect.value;
111
+ const catalog = picker.catalog;
112
+ const checkedIds = catalog.filter((m) => picker.checked.has(m.id)).map((m) => m.id);
113
+ if (!checkedIds.length) {
114
+ setModelStatus('marcá al menos un modelo', 'err');
115
+ return;
116
+ }
117
+ // Todo el catálogo tildado se guarda como [] = "mostrar todo" (sin filtro).
118
+ const visibleModels = checkedIds.length === catalog.length ? [] : checkedIds;
119
+ setModelStatus('guardando…');
120
+ try {
121
+ await api(`/api/providers/${encodeURIComponent(provider)}`, { method: 'PUT', body: { visibleModels } });
122
+ setModelStatus('guardado', 'ok');
123
+ $('#model-modal')?.close();
124
+ // La seleccion ya la tenemos aca: se aplica al estado y se repinta sin
125
+ // volver a pedir el catalogo (591 KB) al server.
126
+ state.visibleModels = visibleModels;
127
+ await loadModels();
128
+ } catch (error) {
129
+ setModelStatus(error?.message ?? 'no se pudo guardar', 'err');
130
+ }
131
+ }
132
+
133
+ export function wireModelPicker() {
134
+ $('#model-pick')?.addEventListener('click', () => {
135
+ openModelPicker();
136
+ });
137
+ $('#model-close')?.addEventListener('click', closeModelPicker);
138
+ $('#model-cancel')?.addEventListener('click', closeModelPicker);
139
+ $('#model-save')?.addEventListener('click', saveModelPicker);
140
+ $('#model-query')?.addEventListener('input', (event) => {
141
+ picker.query = event.target.value;
142
+ renderModelPicker();
143
+ });
144
+ $('#model-all')?.addEventListener('click', () => setDisplayed(() => true));
145
+ $('#model-none')?.addEventListener('click', () => setDisplayed(() => false));
146
+ $('#model-free')?.addEventListener('click', () => setDisplayed((m) => Boolean(m.free)));
147
+ }
@@ -0,0 +1,32 @@
1
+ // Busqueda de modelos: logica pura, sin DOM, para poder testearla en Node.
2
+ // Vive separada del picker (`models.js`) porque ese importa `chrome.js`, que
3
+ // toca el DOM al cargarse; este modulo no importa nada.
4
+
5
+ /**
6
+ * ¿El modelo coincide con la consulta? La consulta se parte en términos y
7
+ * todos deben aparecer (AND) en el id o el label, sin distinguir mayúsculas.
8
+ * Vacío coincide con todo.
9
+ */
10
+ export function matchModel(query, model) {
11
+ const q = String(query ?? '').trim().toLowerCase();
12
+ if (!q) return true;
13
+ const hay = `${model?.id ?? ''} ${model?.label ?? ''}`.toLowerCase();
14
+ return q.split(/\s+/).every((term) => hay.includes(term));
15
+ }
16
+
17
+ /** Filtra un catálogo por consulta. `models` ausente o no-array → []. */
18
+ export function filterModels(query, models) {
19
+ return Array.isArray(models) ? models.filter((m) => matchModel(query, m)) : [];
20
+ }
21
+
22
+ /**
23
+ * Aplica la seleccion guardada a un catalogo. Sin seleccion devuelve todo.
24
+ * Espeja `selectVisibleModels` del core para poder recalcular en el cliente sin
25
+ * volver a pedir el catalogo (591 KB) cada vez que cambia de pestaña.
26
+ */
27
+ export function pickVisible(catalog, selected) {
28
+ if (!Array.isArray(catalog) || !catalog.length) return [];
29
+ if (!Array.isArray(selected) || !selected.length) return catalog;
30
+ const wanted = new Set(selected.map(String));
31
+ return catalog.filter((m) => wanted.has(String(m?.id)));
32
+ }
@@ -93,7 +93,7 @@ export function renderProviderList() {
93
93
 
94
94
  export async function useProvider(id) {
95
95
  providerSelect.value = id;
96
- await loadModels({ save: false });
96
+ await loadModels({ save: false, force: true });
97
97
  await persistAgent({ provider: id, model: modelSelect.value || undefined });
98
98
  await refreshProviders();
99
99
  }
@@ -135,7 +135,7 @@ export async function submitProvider(event) {
135
135
  await api(`/api/providers/${encodeURIComponent(id)}`, { method: 'PUT', body });
136
136
  closeProviderForm();
137
137
  await refreshProviders();
138
- await loadModels();
138
+ await loadModels({ force: true });
139
139
  } catch (error) {
140
140
  setProviderStatus(error.message, 'err');
141
141
  }
@@ -144,7 +144,7 @@ export async function submitProvider(event) {
144
144
  export async function removeProvider(id) {
145
145
  await deleteEntity(`/api/providers/${encodeURIComponent(id)}`, {
146
146
  confirmText: `¿Borrar la configuración de "${id}"?`,
147
- after: loadModels,
147
+ after: () => loadModels({ force: true }),
148
148
  refresh: refreshProviders,
149
149
  });
150
150
  }
@@ -165,7 +165,7 @@ export async function testProvider() {
165
165
  $('#pf-models').value = ids.join(', ');
166
166
  note = ' · modelos autocompletados';
167
167
  }
168
- if (id === state.active.provider) await loadModels();
168
+ if (id === state.active.provider) await loadModels({ force: true });
169
169
  setProviderStatus(`ok · ${out.count} modelos (${out.source})${note}`, 'ok');
170
170
  }
171
171
 
@@ -2426,7 +2426,56 @@ dialog.modal {
2426
2426
  dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: blur(3px); }
2427
2427
  .modal-card { display: flex; flex-direction: column; gap: 12px; padding: 20px; }
2428
2428
  .modal-card h3 { margin: 0; font: 700 17px var(--serif); letter-spacing: -0.02em; }
2429
- .confirm-modal .modal-card { max-width: min(420px, 92vw); }
2429
+ .confirm-modal .modal-card { max-width: min(420px, 92vw); }
2430
+
2431
+ /* --- selector de modelos (busqueda + seleccion) -------------------------- */
2432
+ .model-row { display: flex; align-items: center; gap: 6px; }
2433
+ .model-row select { flex: 1; min-width: 0; }
2434
+ .model-modal { width: min(680px, calc(100vw - 40px)); }
2435
+ .model-search input {
2436
+ width: 100%;
2437
+ padding: 9px 11px;
2438
+ border: 1px solid var(--border);
2439
+ border-radius: 9px;
2440
+ background: var(--theirs);
2441
+ color: var(--text);
2442
+ font: 500 13px var(--sans);
2443
+ }
2444
+ .model-actions { display: flex; align-items: center; gap: 7px; }
2445
+ .model-list {
2446
+ list-style: none;
2447
+ margin: 0;
2448
+ padding: 2px;
2449
+ display: flex;
2450
+ flex-direction: column;
2451
+ gap: 3px;
2452
+ max-height: min(52vh, 420px);
2453
+ overflow-y: auto;
2454
+ border: 1px solid var(--border);
2455
+ border-radius: 9px;
2456
+ background: var(--panel);
2457
+ }
2458
+ .model-item {
2459
+ display: flex;
2460
+ align-items: center;
2461
+ gap: 9px;
2462
+ padding: 7px 9px;
2463
+ border-radius: 7px;
2464
+ cursor: pointer;
2465
+ }
2466
+ .model-item:hover { background: var(--theirs); }
2467
+ .model-item input { flex: 0 0 auto; }
2468
+ .model-item-name {
2469
+ flex: 1;
2470
+ min-width: 0;
2471
+ overflow: hidden;
2472
+ text-overflow: ellipsis;
2473
+ white-space: nowrap;
2474
+ }
2475
+ .model-item-id { color: var(--muted); font: 500 11px var(--mono); flex: 0 0 auto; }
2476
+ .model-item-price { color: var(--muted); font: 500 11px var(--mono); flex: 0 0 auto; }
2477
+ .model-item-free { color: var(--ok); font: 600 10px var(--sans); flex: 0 0 auto; }
2478
+ .model-empty { padding: 16px; color: var(--muted); text-align: center; }
2430
2479
  .confirm-text { margin: 0; color: var(--muted, inherit); line-height: 1.5; }
2431
2480
  .confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
2432
2481
  .row .spacer { flex: 1; }
@@ -1,4 +1,4 @@
1
- import { listProviderModels } from '../lib/core.js';
1
+ import { listProviderModels, selectVisibleModels } from '../lib/core.js';
2
2
  import { sendJson } from '../lib/http.js';
3
3
  import { createModelCache } from '../lib/modelCache.js';
4
4
 
@@ -13,14 +13,19 @@ export function registerModels(route, ctx) {
13
13
 
14
14
  try {
15
15
  const { models, cached, stale } = await modelCache.get(key, () => listProviderModels(settings));
16
+ // `models` es el catalogo completo (para buscar); `visible` es la seleccion
17
+ // del usuario para el selector. Sin seleccion, `visible` = todo.
18
+ const visibleModels = ctx.store.getProvider(providerId)?.visibleModels ?? [];
16
19
  sendJson(res, 200, {
17
20
  provider: providerId,
18
21
  models,
22
+ visible: selectVisibleModels(models, visibleModels),
23
+ visibleModels,
19
24
  cached: Boolean(cached),
20
25
  ...(stale ? { stale } : {}),
21
26
  });
22
27
  } catch (error) {
23
- sendJson(res, 200, { provider: providerId, models: [], error: error?.message ?? String(error) });
28
+ sendJson(res, 200, { provider: providerId, models: [], visible: [], error: error?.message ?? String(error) });
24
29
  }
25
30
  });
26
31
  }
@@ -32,6 +32,7 @@ export function providerList(ctx) {
32
32
  hasKey: Boolean(row?.apiKey),
33
33
  apiKeyHint: maskSecret(row?.apiKey),
34
34
  models: row?.models ?? [],
35
+ visibleModels: row?.visibleModels ?? [],
35
36
  });
36
37
  }
37
38
 
@@ -49,6 +50,7 @@ export function providerList(ctx) {
49
50
  hasKey: Boolean(row.apiKey),
50
51
  apiKeyHint: maskSecret(row.apiKey),
51
52
  models: row.models,
53
+ visibleModels: row.visibleModels ?? [],
52
54
  });
53
55
  }
54
56
  }
@@ -140,6 +142,9 @@ export function registerProviders(route, ctx) {
140
142
  models: Array.isArray(body.models)
141
143
  ? body.models.map((m) => String(m).trim()).filter(Boolean)
142
144
  : existing.models ?? [],
145
+ visibleModels: Array.isArray(body.visibleModels)
146
+ ? body.visibleModels.map((m) => String(m).trim()).filter(Boolean)
147
+ : existing.visibleModels ?? [],
143
148
  requiresKey: body.requiresKey ?? existing.requiresKey ?? builtin?.requiresKey,
144
149
  custom: builtin ? Boolean(body.custom ?? existing.custom ?? false) : true,
145
150
  enabled: body.enabled ?? existing.enabled ?? true,
@@ -0,0 +1,76 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { matchModel, filterModels, pickVisible } from '../public/modelsQuery.js';
4
+
5
+ const CATALOG = [
6
+ { id: 'z-ai/glm-5.3', label: 'Z.ai: GLM 5.3' },
7
+ { id: 'z-ai/glm-5.3-flash', label: 'Z.ai: GLM 5.3 Flash' },
8
+ { id: 'deepseek/deepseek-v4.1-flash-1017', label: 'DeepSeek: DeepSeek V4.1 Flash 1017' },
9
+ { id: 'anthropic/claude-haiku-5.5', label: 'Anthropic: Claude Haiku 5.5' },
10
+ ];
11
+
12
+ test('matchModel: la consulta vacía coincide con todo', () => {
13
+ for (const model of CATALOG) {
14
+ assert.equal(matchModel('', model), true);
15
+ assert.equal(matchModel(' ', model), true);
16
+ assert.equal(matchModel(undefined, model), true);
17
+ }
18
+ });
19
+
20
+ test('matchModel: busca por id y por label, sin distinguir mayúsculas', () => {
21
+ assert.equal(matchModel('glm', CATALOG[0]), true);
22
+ assert.equal(matchModel('GLM', CATALOG[0]), true);
23
+ assert.equal(matchModel('z.ai', CATALOG[0]), true);
24
+ assert.equal(matchModel('deepseek v4', CATALOG[2]), true);
25
+ assert.equal(matchModel('haiku', CATALOG[3]), true);
26
+ });
27
+
28
+ test('matchModel: todos los términos deben aparecer (AND)', () => {
29
+ assert.equal(matchModel('glm 5.3 flash', CATALOG[1]), true);
30
+ assert.equal(matchModel('glm flash', CATALOG[1]), true);
31
+ // "flash" no está en el label del glm-5.3 base
32
+ assert.equal(matchModel('glm flash', CATALOG[0]), false);
33
+ });
34
+
35
+ test('matchModel: sin coincidencia devuelve false', () => {
36
+ assert.equal(matchModel('gpt', CATALOG[0]), false);
37
+ assert.equal(matchModel('no-existe', CATALOG[2]), false);
38
+ });
39
+
40
+ test('matchModel: tolera modelos sin id ni label', () => {
41
+ assert.equal(matchModel('', {}), true);
42
+ assert.equal(matchModel('algo', {}), false);
43
+ assert.equal(matchModel('algo', null), false);
44
+ });
45
+
46
+ test('filterModels: filtra el catálogo por consulta', () => {
47
+ assert.equal(filterModels('glm', CATALOG).length, 2);
48
+ assert.equal(filterModels('flash', CATALOG).length, 2);
49
+ assert.equal(filterModels('deepseek', CATALOG).length, 1);
50
+ assert.equal(filterModels('', CATALOG).length, 4);
51
+ });
52
+
53
+ test('filterModels: no-array o ausente → []', () => {
54
+ assert.deepEqual(filterModels('x', null), []);
55
+ assert.deepEqual(filterModels('x', undefined), []);
56
+ assert.deepEqual(filterModels('x', 'no soy una lista'), []);
57
+ });
58
+
59
+ test('pickVisible: sin seleccion devuelve el catalogo entero', () => {
60
+ assert.deepEqual(pickVisible(CATALOG, []), CATALOG);
61
+ assert.deepEqual(pickVisible(CATALOG, null), CATALOG);
62
+ assert.deepEqual(pickVisible(CATALOG, undefined), CATALOG);
63
+ });
64
+
65
+ test('pickVisible: acota a los ids seleccionados, en orden del catalogo', () => {
66
+ const ids = pickVisible(CATALOG, ['anthropic/claude-haiku-5.5', 'z-ai/glm-5.3']).map((m) => m.id);
67
+ assert.deepEqual(ids, ['z-ai/glm-5.3', 'anthropic/claude-haiku-5.5']);
68
+ // ids que ya no estan en el catalogo se ignoran sin romper
69
+ assert.deepEqual(pickVisible(CATALOG, ['z-ai/glm-5.3', 'no/existe']).map((m) => m.id), ['z-ai/glm-5.3']);
70
+ });
71
+
72
+ test('pickVisible: catalogo vacio o no-array → []', () => {
73
+ assert.deepEqual(pickVisible([], ['a']), []);
74
+ assert.deepEqual(pickVisible(null, ['a']), []);
75
+ assert.deepEqual(pickVisible(undefined, ['a']), []);
76
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.23.1",
3
+ "version": "0.24.1",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",