@danieltmn/tamnora 0.24.0 → 0.24.2
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 +13 -0
- package/harness/packages/server/public/app.js +2 -0
- package/harness/packages/server/public/chat.js +17 -0
- package/harness/packages/server/public/chrome.js +77 -43
- package/harness/packages/server/public/commands.js +2 -0
- 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/public/styles.css +21 -0
- package/harness/packages/server/public/system.js +495 -495
- package/harness/packages/server/test/modelsQuery.test.js +20 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ 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.2] - 2026-10-30
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- feat(consola): el input del chat crece hacia arriba al escribir
|
|
13
|
+
- feat: botón para volver al chat desde las demás páginas
|
|
14
|
+
|
|
15
|
+
## [0.24.1] - 2026-10-28
|
|
16
|
+
|
|
17
|
+
### Cambios
|
|
18
|
+
|
|
19
|
+
- fix(consola): deja de repintar el selector de modelos en cada cambio de pestaña
|
|
20
|
+
|
|
8
21
|
## [0.24.0] - 2026-10-28
|
|
9
22
|
|
|
10
23
|
### Cambios
|
|
@@ -220,6 +220,8 @@ document.addEventListener('keydown', (event) => {
|
|
|
220
220
|
if (event.key === 'Escape' && !moreMenu.hidden) hideMoreMenu();
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
$('#back-chat')?.addEventListener('click', () => activateTab('chat'));
|
|
224
|
+
|
|
223
225
|
if (settingsModal) {
|
|
224
226
|
$('#settings-toggle')?.addEventListener('click', openSettingsModal);
|
|
225
227
|
$('#settings-close')?.addEventListener('click', closeSettingsModal);
|
|
@@ -103,6 +103,16 @@ export const panesEl = $('#panes');
|
|
|
103
103
|
export const tabbarEl = $('#tabbar');
|
|
104
104
|
export const chatForm = $('#chat-form');
|
|
105
105
|
export const chatInput = $('#chat-input');
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Ajusta el alto del textarea al contenido: crece hacia arriba a medida que se
|
|
109
|
+
* escribe, hasta el `max-height` del CSS (donde pasa a scrollear por dentro).
|
|
110
|
+
* Hay que llamarlo tambien al vaciarlo por codigo, o quedaria alto y vacio.
|
|
111
|
+
*/
|
|
112
|
+
export function autoGrowChatInput() {
|
|
113
|
+
chatInput.style.height = 'auto';
|
|
114
|
+
chatInput.style.height = `${chatInput.scrollHeight}px`;
|
|
115
|
+
}
|
|
106
116
|
export const submitBtn = chatForm.querySelector('button[type="submit"]');
|
|
107
117
|
export const stopBtn = $('#stop-btn');
|
|
108
118
|
export const interruptBtn = $('#interrupt-btn');
|
|
@@ -305,6 +315,7 @@ export async function regenerate(message) {
|
|
|
305
315
|
await api(`/api/sessions/${id}/truncate`, { method: 'POST', body: { fromId: lastUser.id } }).catch(() => {});
|
|
306
316
|
await reloadPane(id);
|
|
307
317
|
chatInput.value = userText(lastUser.content);
|
|
318
|
+
autoGrowChatInput();
|
|
308
319
|
await sendMessage();
|
|
309
320
|
}
|
|
310
321
|
|
|
@@ -338,6 +349,7 @@ export async function undoTurn(message) {
|
|
|
338
349
|
|
|
339
350
|
export function editMessage(message) { const id = messageId(message);
|
|
340
351
|
chatInput.value = message.textEl.textContent;
|
|
352
|
+
autoGrowChatInput();
|
|
341
353
|
chatInput.focus();
|
|
342
354
|
state.pendingEdit = id ? { fromId: id } : null;
|
|
343
355
|
}
|
|
@@ -753,6 +765,7 @@ export function addContinueButton(turn, data) {
|
|
|
753
765
|
btn.addEventListener('click', () => {
|
|
754
766
|
if (state.panes.get(turn.sessionKey)?.working) return;
|
|
755
767
|
chatInput.value = 'Continuá donde quedaste.';
|
|
768
|
+
autoGrowChatInput();
|
|
756
769
|
sendMessage();
|
|
757
770
|
});
|
|
758
771
|
turn.assistant().tools.appendChild(btn);
|
|
@@ -1195,6 +1208,7 @@ export async function sendMessage() {
|
|
|
1195
1208
|
chatInput.value = '';
|
|
1196
1209
|
state.attachments = [];
|
|
1197
1210
|
renderAttachments();
|
|
1211
|
+
autoGrowChatInput();
|
|
1198
1212
|
if (command) runShell(command);
|
|
1199
1213
|
return;
|
|
1200
1214
|
}
|
|
@@ -1224,6 +1238,7 @@ export async function sendMessage() {
|
|
|
1224
1238
|
chatInput.value = '';
|
|
1225
1239
|
state.attachments = [];
|
|
1226
1240
|
renderAttachments();
|
|
1241
|
+
autoGrowChatInput();
|
|
1227
1242
|
syncInterruptBtn();
|
|
1228
1243
|
return;
|
|
1229
1244
|
}
|
|
@@ -1240,6 +1255,7 @@ export async function sendMessage() {
|
|
|
1240
1255
|
chatInput.value = '';
|
|
1241
1256
|
state.attachments = [];
|
|
1242
1257
|
renderAttachments();
|
|
1258
|
+
autoGrowChatInput();
|
|
1243
1259
|
await startTurn(state.sessionId, activePane, input, images);
|
|
1244
1260
|
}
|
|
1245
1261
|
|
|
@@ -1275,6 +1291,7 @@ export function sendInterrupt() {
|
|
|
1275
1291
|
chatInput.value = '';
|
|
1276
1292
|
state.attachments = [];
|
|
1277
1293
|
renderAttachments();
|
|
1294
|
+
autoGrowChatInput();
|
|
1278
1295
|
syncInterruptBtn();
|
|
1279
1296
|
interruptAndSend(state.sessionId, input, images);
|
|
1280
1297
|
}
|
|
@@ -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,
|
|
@@ -43,7 +44,8 @@ export function setCwd(value) {
|
|
|
43
44
|
|
|
44
45
|
export const moreTab = $('#more-tab');
|
|
45
46
|
export const moreMenu = $('#more-menu');
|
|
46
|
-
export const topbarSection = $('#topbar-section');
|
|
47
|
+
export const topbarSection = $('#topbar-section');
|
|
48
|
+
export const backChat = $('#back-chat');
|
|
47
49
|
|
|
48
50
|
export const TAB_LABELS = { chat: 'chat', providers: 'proveedores', ajustes: 'ajustes', eval: 'eval', runs: 'runs' };
|
|
49
51
|
|
|
@@ -60,6 +62,7 @@ export function activateTab(name) {
|
|
|
60
62
|
.querySelectorAll('#more-menu button[data-tab]')
|
|
61
63
|
.forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
|
|
62
64
|
if (topbarSection) topbarSection.textContent = TAB_LABELS[name] || name;
|
|
65
|
+
if (backChat) backChat.hidden = name === 'chat';
|
|
63
66
|
moreTab.classList.toggle('active', name !== 'chat');
|
|
64
67
|
document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
|
|
65
68
|
hideMoreMenu();
|
|
@@ -531,52 +534,83 @@ export async function applySessionAgent(record) {
|
|
|
531
534
|
renderModelPill();
|
|
532
535
|
}
|
|
533
536
|
|
|
534
|
-
|
|
537
|
+
/** Firma de la lista dibujada: si no cambia, no hay que repintar el select. */
|
|
538
|
+
function modelsSignature(shown) {
|
|
539
|
+
return shown.map((m) => `${m.id}\u0000${m.label ?? ''}\u0000${m.vision ? 1 : 0}\u0000${m.context ?? ''}`).join('\u0001');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Carga el catalogo del proveedor y dibuja el select.
|
|
544
|
+
*
|
|
545
|
+
* `force` vuelve a pedirlo al server; sin el se reusa el de `state` (son ~374
|
|
546
|
+
* modelos / 591 KB, y esto corre en cada cambio de pestana: refetchearlo hacia
|
|
547
|
+
* parpadear la consola).
|
|
548
|
+
*/
|
|
549
|
+
export async function loadModels({ save = false, prefer = null, force = false } = {}) {
|
|
535
550
|
const provider = providerSelect.value;
|
|
551
|
+
const cached = !force && state.modelCatalogProvider === provider && state.modelCatalog?.length;
|
|
536
552
|
modelSelect.disabled = true;
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
553
|
+
try {
|
|
554
|
+
if (!cached) {
|
|
555
|
+
const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch((error) => ({
|
|
556
|
+
models: [],
|
|
557
|
+
error: error.message,
|
|
558
|
+
}));
|
|
559
|
+
state.modelCatalog = data?.models ?? [];
|
|
560
|
+
state.visibleModels = data?.visibleModels ?? [];
|
|
561
|
+
state.modelCatalogProvider = provider;
|
|
562
|
+
if (!state.modelCatalog.length) {
|
|
563
|
+
clear(modelSelect);
|
|
564
|
+
const option = document.createElement('option');
|
|
565
|
+
option.value = '';
|
|
566
|
+
option.textContent = data?.error ? `sin modelos - ${data.error}` : 'sin modelos (configura el proveedor)';
|
|
567
|
+
modelSelect.appendChild(option);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
546
571
|
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
572
|
+
const catalog = state.modelCatalog;
|
|
573
|
+
const models = pickVisible(catalog, state.visibleModels);
|
|
574
|
+
const activeModel = prefer || state.active.model;
|
|
575
|
+
// Si el modelo activo quedo fuera de la seleccion, lo mostramos igual para no
|
|
576
|
+
// cambiarlo en silencio: sigue siendo el usado aunque no este tildado.
|
|
577
|
+
const hidden = activeModel && catalog.some((m) => m.id === activeModel) && !models.some((m) => m.id === activeModel);
|
|
578
|
+
const shown = hidden ? [catalog.find((m) => m.id === activeModel), ...models] : models;
|
|
579
|
+
if (!shown.length) {
|
|
580
|
+
clear(modelSelect);
|
|
581
|
+
const option = document.createElement('option');
|
|
582
|
+
option.value = '';
|
|
583
|
+
option.textContent = 'sin modelos (configura el proveedor)';
|
|
584
|
+
modelSelect.appendChild(option);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
556
587
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
588
|
+
// Repintar el select con cientos de opciones es lo que se ve como parpadeo:
|
|
589
|
+
// solo se hace cuando la lista realmente cambio.
|
|
590
|
+
const signature = modelsSignature(shown);
|
|
591
|
+
if (signature !== state.modelSignature) {
|
|
592
|
+
state.modelSignature = signature;
|
|
593
|
+
clear(modelSelect);
|
|
594
|
+
for (const model of shown) {
|
|
595
|
+
const option = document.createElement('option');
|
|
596
|
+
option.value = model.id;
|
|
597
|
+
option.textContent = model.label && model.label !== model.id ? model.label : model.id;
|
|
598
|
+
if (model.vision) option.textContent += ' - vision';
|
|
599
|
+
if (model.context) option.dataset.context = String(model.context);
|
|
600
|
+
modelSelect.appendChild(option);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const wanted = prefer || state.active.model;
|
|
605
|
+
if (wanted && shown.some((m) => m.id === wanted)) {
|
|
606
|
+
modelSelect.value = wanted;
|
|
607
|
+
} else {
|
|
608
|
+
modelSelect.value = models[0].id;
|
|
609
|
+
if (save) await saveSettings({ model: modelSelect.value });
|
|
610
|
+
}
|
|
611
|
+
} finally {
|
|
612
|
+
modelSelect.disabled = false;
|
|
576
613
|
}
|
|
577
|
-
modelSelect.disabled = false;
|
|
578
|
-
updateStatus();
|
|
579
|
-
renderTokens();
|
|
580
614
|
}
|
|
581
615
|
|
|
582
616
|
/* --- folder picker -------------------------------------------------------- */
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
activePane,
|
|
4
4
|
addCompactCard,
|
|
5
5
|
addMessage,
|
|
6
|
+
autoGrowChatInput,
|
|
6
7
|
basename,
|
|
7
8
|
chatInput,
|
|
8
9
|
finishCompactCard,
|
|
@@ -398,6 +399,7 @@ export function handleSlashKey(event) {
|
|
|
398
399
|
export function onChatInputChanged() {
|
|
399
400
|
slashIndex = 0;
|
|
400
401
|
updateSlashMenu();
|
|
402
|
+
autoGrowChatInput();
|
|
401
403
|
syncInterruptBtn();
|
|
402
404
|
}
|
|
403
405
|
|
|
@@ -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> <button id="back-chat" type="button" class="back-chat" title="volver al chat" aria-label="volver al chat" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg><span>chat</span></button> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.24.2</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
|
|
|
@@ -571,6 +571,27 @@ html[data-scheme='light'] .brand .logo img { content: url('/icons/logo_light.png
|
|
|
571
571
|
letter-spacing: 0.08em;
|
|
572
572
|
white-space: nowrap;
|
|
573
573
|
}
|
|
574
|
+
/* Botón para volver al chat desde las demás páginas (proveedores, ajustes, eval, runs). */
|
|
575
|
+
.back-chat {
|
|
576
|
+
display: inline-flex;
|
|
577
|
+
align-items: center;
|
|
578
|
+
gap: 3px;
|
|
579
|
+
height: 24px;
|
|
580
|
+
padding: 0 9px 0 5px;
|
|
581
|
+
border: 1px solid var(--border);
|
|
582
|
+
border-radius: 999px;
|
|
583
|
+
background: var(--theirs);
|
|
584
|
+
color: var(--muted);
|
|
585
|
+
cursor: pointer;
|
|
586
|
+
font: 700 10px var(--sans);
|
|
587
|
+
text-transform: uppercase;
|
|
588
|
+
letter-spacing: 0.08em;
|
|
589
|
+
white-space: nowrap;
|
|
590
|
+
transition: 0.15s ease;
|
|
591
|
+
}
|
|
592
|
+
.back-chat[hidden] { display: none; }
|
|
593
|
+
.back-chat:hover { border-color: var(--accent); color: var(--accent); }
|
|
594
|
+
.back-chat svg { width: 15px; height: 15px; display: block; flex: 0 0 auto; }
|
|
574
595
|
.menu { position: relative; display: block; }
|
|
575
596
|
|
|
576
597
|
/* El icono muestra el tema al que se va a cambiar: claro -> luna, oscuro -> sol. */
|