@danieltmn/tamnora 0.24.0 → 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 +6 -0
- package/harness/packages/server/public/chrome.js +74 -42
- package/harness/packages/server/public/core.js +7 -0
- package/harness/packages/server/public/index.html +1 -1
- package/harness/packages/server/public/models.js +3 -0
- package/harness/packages/server/public/modelsQuery.js +12 -0
- package/harness/packages/server/public/settings.js +4 -4
- package/harness/packages/server/test/modelsQuery.test.js +20 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ 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
|
+
|
|
8
14
|
## [0.24.0] - 2026-10-28
|
|
9
15
|
|
|
10
16
|
### Cambios
|
|
@@ -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,52 +532,83 @@ export async function applySessionAgent(record) {
|
|
|
531
532
|
renderModelPill();
|
|
532
533
|
}
|
|
533
534
|
|
|
534
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
+
}
|
|
546
569
|
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
+
}
|
|
556
585
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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;
|
|
576
611
|
}
|
|
577
|
-
modelSelect.disabled = false;
|
|
578
|
-
updateStatus();
|
|
579
|
-
renderTokens();
|
|
580
612
|
}
|
|
581
613
|
|
|
582
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.24.
|
|
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">
|
|
@@ -121,6 +121,9 @@ export async function saveModelPicker() {
|
|
|
121
121
|
await api(`/api/providers/${encodeURIComponent(provider)}`, { method: 'PUT', body: { visibleModels } });
|
|
122
122
|
setModelStatus('guardado', 'ok');
|
|
123
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;
|
|
124
127
|
await loadModels();
|
|
125
128
|
} catch (error) {
|
|
126
129
|
setModelStatus(error?.message ?? 'no se pudo guardar', 'err');
|
|
@@ -18,3 +18,15 @@ export function matchModel(query, model) {
|
|
|
18
18
|
export function filterModels(query, models) {
|
|
19
19
|
return Array.isArray(models) ? models.filter((m) => matchModel(query, m)) : [];
|
|
20
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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { matchModel, filterModels } from '../public/modelsQuery.js';
|
|
3
|
+
import { matchModel, filterModels, pickVisible } from '../public/modelsQuery.js';
|
|
4
4
|
|
|
5
5
|
const CATALOG = [
|
|
6
6
|
{ id: 'z-ai/glm-5.3', label: 'Z.ai: GLM 5.3' },
|
|
@@ -55,3 +55,22 @@ test('filterModels: no-array o ausente → []', () => {
|
|
|
55
55
|
assert.deepEqual(filterModels('x', undefined), []);
|
|
56
56
|
assert.deepEqual(filterModels('x', 'no soy una lista'), []);
|
|
57
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