@danieltmn/tamnora 0.9.2 → 0.9.4
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/README.md +30 -10
- package/harness/packages/cli/src/tui/app.js +22 -3
- package/harness/packages/cli/src/tui/layout.js +59 -2
- package/harness/packages/cli/test/tui.test.js +30 -0
- package/harness/packages/core/src/agent/modes.js +14 -1
- package/harness/packages/core/src/agent/runAgent.js +23 -2
- package/harness/packages/core/src/browser/index.js +289 -0
- package/harness/packages/core/src/config/home.js +5 -0
- package/harness/packages/core/src/index.js +3 -2
- package/harness/packages/core/src/providers/presets.js +4 -0
- package/harness/packages/core/src/tools/diff.js +70 -1
- package/harness/packages/core/src/tools/executors.js +96 -0
- package/harness/packages/core/src/tools/labels.js +4 -0
- package/harness/packages/core/src/tools/permissions.js +4 -0
- package/harness/packages/core/src/tools/schema.js +47 -0
- package/harness/packages/core/test/agent.test.js +30 -1
- package/harness/packages/core/test/browser.test.js +18 -0
- package/harness/packages/core/test/diff.test.js +24 -1
- package/harness/packages/core/test/tools.test.js +16 -1
- package/harness/packages/server/public/app.js +203 -38
- package/harness/packages/server/public/index.html +2 -1
- package/harness/packages/server/public/landing/index.html +14 -1
- package/harness/packages/server/public/reconcile.js +3 -1
- package/harness/packages/server/public/styles.css +98 -12
- package/harness/packages/server/src/lib/agentRun.js +2 -2
- package/harness/packages/server/src/lib/channels/telegram.js +129 -44
- package/harness/packages/server/src/routes/files.js +25 -1
- package/harness/packages/server/src/routes/sessions.js +55 -33
- package/harness/packages/server/test/server.test.js +84 -14
- package/harness/packages/server/test/telegram-features.test.js +25 -4
- package/harness/packages/server/test/telegram-voz.test.js +3 -5
- package/package.json +1 -1
|
@@ -72,6 +72,10 @@ const ICON_PATHS = {
|
|
|
72
72
|
'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
|
|
73
73
|
trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M10 11v6', 'M14 11v6'],
|
|
74
74
|
'dots-vertical': ['M12 5h.01', 'M12 12h.01', 'M12 19h.01'],
|
|
75
|
+
'dots-horizontal': ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'],
|
|
76
|
+
play: ['m6 4 14 8-14 8z'],
|
|
77
|
+
pause: ['M8 5v14', 'M16 5v14'],
|
|
78
|
+
square: ['M6 6h12v12H6z'],
|
|
75
79
|
copy: ['M8 8V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3', 'M5 8h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2Z'],
|
|
76
80
|
pencil: ['M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z', 'm15 5 4 4'],
|
|
77
81
|
};
|
|
@@ -301,6 +305,8 @@ if (statusbarEl) {
|
|
|
301
305
|
const PALETTE_COMMANDS = [
|
|
302
306
|
{ id: 'new', label: 'Nueva sesión en otra carpeta', hint: '/new', run: () => openFolderPicker() },
|
|
303
307
|
{ id: 'init', label: 'Analizar el proyecto y crear AGENTS.md', hint: '/init', run: () => runSlash('init') },
|
|
308
|
+
{ id: 'resumen', label: 'Resumir la conversación', hint: '/resumen', run: () => runSlash('resumen') },
|
|
309
|
+
{ id: 'audioresumen', label: 'Resumen como audio', hint: '/audioresumen', run: () => runSlash('audioresumen') },
|
|
304
310
|
{ id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
|
|
305
311
|
{ id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
|
|
306
312
|
{ id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
|
|
@@ -330,7 +336,7 @@ function runSlash(name) {
|
|
|
330
336
|
function paletteItems(query) {
|
|
331
337
|
const q = query.trim().toLowerCase();
|
|
332
338
|
const items = PALETTE_COMMANDS.filter(
|
|
333
|
-
(c) => !q || c.label.toLowerCase().includes(q) || c.id.includes(q)
|
|
339
|
+
(c) => !q || c.label.toLowerCase().includes(q) || c.id.includes(q) || (c.hint ?? '').toLowerCase().includes(q)
|
|
334
340
|
).map((c) => ({ label: c.label, hint: c.hint || 'comando', run: c.run }));
|
|
335
341
|
|
|
336
342
|
for (const session of palette.results.sessions) {
|
|
@@ -902,7 +908,7 @@ function addMessageActions(message) {
|
|
|
902
908
|
const actions = el('div', 'msg-actions');
|
|
903
909
|
const trigger = el('button', 'msg-menu-btn');
|
|
904
910
|
trigger.type = 'button';
|
|
905
|
-
trigger.appendChild(icon('dots-vertical', 16));
|
|
911
|
+
trigger.appendChild(icon(window.matchMedia('(min-width: 821px)').matches ? 'dots-horizontal' : 'dots-vertical', 16));
|
|
906
912
|
trigger.title = 'opciones del mensaje';
|
|
907
913
|
trigger.setAttribute('aria-haspopup', 'menu');
|
|
908
914
|
trigger.setAttribute('aria-expanded', 'false');
|
|
@@ -1152,6 +1158,10 @@ function toolSummary(name, args) {
|
|
|
1152
1158
|
return shortText(a.url, 90);
|
|
1153
1159
|
case 'speak':
|
|
1154
1160
|
return shortText(a.texto, 90);
|
|
1161
|
+
case 'screenshot':
|
|
1162
|
+
case 'console_errors':
|
|
1163
|
+
case 'preview_url':
|
|
1164
|
+
return shortText(a.url, 90);
|
|
1155
1165
|
case 'git_diff':
|
|
1156
1166
|
return [a.ref ? `vs ${shortText(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? shortText(a.path, 40) : '']
|
|
1157
1167
|
.filter(Boolean)
|
|
@@ -1183,6 +1193,26 @@ function isFailedToolText(text) {
|
|
|
1183
1193
|
return /^Error|^Permiso denegado/i.test(String(text ?? ''));
|
|
1184
1194
|
}
|
|
1185
1195
|
|
|
1196
|
+
/** Nombre de archivo de una captura, del meta o del texto del resultado. */
|
|
1197
|
+
function screenshotName(data, text) {
|
|
1198
|
+
const fromMeta = data?.meta?.screenshot?.name;
|
|
1199
|
+
if (fromMeta) return String(fromMeta);
|
|
1200
|
+
const match = /capturas\/([a-z0-9][a-z0-9-]*\.png)/i.exec(String(text ?? ''));
|
|
1201
|
+
return match ? match[1] : null;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/** Muestra la captura del tool `screenshot` en su tarjeta (clic para ampliar). */
|
|
1205
|
+
function showScreenshot(entry, name) {
|
|
1206
|
+
if (!entry || !name || entry.shot) return;
|
|
1207
|
+
entry.shot = true;
|
|
1208
|
+
const img = el('img', 'tool-shot');
|
|
1209
|
+
img.loading = 'lazy';
|
|
1210
|
+
img.alt = 'captura de pantalla';
|
|
1211
|
+
img.src = `/api/screenshots/${encodeURIComponent(name)}`;
|
|
1212
|
+
img.addEventListener('click', () => window.open(img.src, '_blank'));
|
|
1213
|
+
entry.card.appendChild(img);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1186
1216
|
/** Cierra la tarjeta de un tool-call (evento tool_result o mensaje persistido). */
|
|
1187
1217
|
function finishToolCard(entry, data) {
|
|
1188
1218
|
if (!entry) return;
|
|
@@ -1197,6 +1227,7 @@ function finishToolCard(entry, data) {
|
|
|
1197
1227
|
entry.truncated = true;
|
|
1198
1228
|
entry.more.hidden = false;
|
|
1199
1229
|
}
|
|
1230
|
+
if (!failed) showScreenshot(entry, screenshotName(data, preview));
|
|
1200
1231
|
}
|
|
1201
1232
|
|
|
1202
1233
|
function previewText(value, maxLines = 24, maxChars = 4000) {
|
|
@@ -1252,18 +1283,13 @@ async function loadFullToolResult(entry, message) {
|
|
|
1252
1283
|
}
|
|
1253
1284
|
}
|
|
1254
1285
|
|
|
1255
|
-
/**
|
|
1256
|
-
function
|
|
1286
|
+
/** Botón del tool `speak`: encola el audio pedido en el reproductor global. */
|
|
1287
|
+
function speakControl(args) {
|
|
1257
1288
|
const texto = String(args?.texto ?? '').trim();
|
|
1258
1289
|
if (!texto) return null;
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
const audio = el('audio', 'tool-audio');
|
|
1263
|
-
audio.controls = true;
|
|
1264
|
-
audio.preload = 'none';
|
|
1265
|
-
audio.src = `/api/tts?${params.toString()}`;
|
|
1266
|
-
return audio;
|
|
1290
|
+
const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
|
|
1291
|
+
btn.append(icon('play', 13), el('span', '', 'escuchar'));
|
|
1292
|
+
return btn;
|
|
1267
1293
|
}
|
|
1268
1294
|
|
|
1269
1295
|
/** Texto de la tarjeta: el server ya manda `summary` calculado; esto es el fallback. */
|
|
@@ -1277,17 +1303,17 @@ function addToolCard(message, name, args, { callId = null, open = null, round =
|
|
|
1277
1303
|
const argsEl = el('span', 'tool-args', summary ?? toolSummary(name, args));
|
|
1278
1304
|
head.append(glyph, el('span', 'tool-name', name), argsEl);
|
|
1279
1305
|
const out = el('div', 'tool-out');
|
|
1280
|
-
const
|
|
1306
|
+
const speak = name === 'speak' ? speakControl(args) : null;
|
|
1281
1307
|
const more = button('ver completo', () => loadFullToolResult(entry, message), 'tool-more');
|
|
1282
1308
|
more.hidden = true;
|
|
1283
1309
|
card.append(head, out);
|
|
1284
|
-
if (
|
|
1310
|
+
if (speak) card.appendChild(speak);
|
|
1285
1311
|
card.appendChild(more);
|
|
1286
1312
|
message.tools.appendChild(card);
|
|
1287
1313
|
ensureToolsToggle(message);
|
|
1288
1314
|
scrollMessages(paneOf(message));
|
|
1289
1315
|
|
|
1290
|
-
const entry = { card, out, glyph, argsEl, more,
|
|
1316
|
+
const entry = { card, out, glyph, argsEl, more, callId, full: false, truncated: false };
|
|
1291
1317
|
card.addEventListener('toggle', () => {
|
|
1292
1318
|
if (card.open && entry.truncated && !entry.full) loadFullToolResult(entry, message);
|
|
1293
1319
|
});
|
|
@@ -2022,8 +2048,12 @@ function handleChatEvent(event, data, turn) {
|
|
|
2022
2048
|
setActivity(target, 'working');
|
|
2023
2049
|
turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
|
|
2024
2050
|
if (turn.live && data.name === 'speak') {
|
|
2025
|
-
const
|
|
2026
|
-
if (
|
|
2051
|
+
const texto = String(data.args?.texto ?? '').trim();
|
|
2052
|
+
if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
|
|
2053
|
+
}
|
|
2054
|
+
if (turn.live && data.name === 'preview_url' && data.args?.url) {
|
|
2055
|
+
setExplorerMode('preview');
|
|
2056
|
+
loadPreview(data.args.url);
|
|
2027
2057
|
}
|
|
2028
2058
|
} else if (event === 'tool_result') {
|
|
2029
2059
|
setActivity(turn.assistant(), 'thinking');
|
|
@@ -2446,8 +2476,8 @@ const micBtn = $('#mic-btn');
|
|
|
2446
2476
|
|
|
2447
2477
|
// La cuenta 3-2-1 le da tiempo al micro a arrancar (si hablás apenas tocás se
|
|
2448
2478
|
// pierden las primeras palabras); la cola al cortar evita perder el final.
|
|
2449
|
-
const REC_COUNT = [3, 2, 1];
|
|
2450
|
-
const REC_COUNT_STEP_MS =
|
|
2479
|
+
const REC_COUNT = [3, 2, 1, 0];
|
|
2480
|
+
const REC_COUNT_STEP_MS = 500;
|
|
2451
2481
|
const REC_TAIL_MS = 500;
|
|
2452
2482
|
|
|
2453
2483
|
/** Estado visual del botón de dictado: cuenta regresiva, grabando (verde) o transcribiendo. */
|
|
@@ -2891,6 +2921,8 @@ const COMMANDS = [
|
|
|
2891
2921
|
['/new', 'nueva sesión (elegís carpeta)'],
|
|
2892
2922
|
['/init', 'analizar el proyecto y crear/actualizar AGENTS.md'],
|
|
2893
2923
|
['/goal <texto>', 'fijar el objetivo de la sesión'],
|
|
2924
|
+
['/resumen', 'resumir la conversación (sin borrarla)'],
|
|
2925
|
+
['/audioresumen', 'el resumen como audio'],
|
|
2894
2926
|
['/compact', 'resumir la conversación y liberar contexto'],
|
|
2895
2927
|
['/clear', 'borrar los mensajes de esta sesión'],
|
|
2896
2928
|
['/cwd', 'mostrar la carpeta de trabajo'],
|
|
@@ -2991,6 +3023,32 @@ async function runCommand(name, arg) {
|
|
|
2991
3023
|
notify(`Objetivo de la sesión: **${arg}**`);
|
|
2992
3024
|
return { handled: true };
|
|
2993
3025
|
}
|
|
3026
|
+
case 'resumen':
|
|
3027
|
+
case 'resume': {
|
|
3028
|
+
if (!state.sessionId) return needSession();
|
|
3029
|
+
notify('Resumiendo la conversación…');
|
|
3030
|
+
const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
|
|
3031
|
+
error: error.message,
|
|
3032
|
+
}));
|
|
3033
|
+
if (out.error) notify(`No se pudo resumir: ${out.error}`, 'notice err');
|
|
3034
|
+
else notify(`**Resumen**\n\n${out.summary}`);
|
|
3035
|
+
return { handled: true };
|
|
3036
|
+
}
|
|
3037
|
+
case 'audioresumen':
|
|
3038
|
+
case 'audio-resumen': {
|
|
3039
|
+
if (!state.sessionId) return needSession();
|
|
3040
|
+
notify('Preparando el resumen…');
|
|
3041
|
+
const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
|
|
3042
|
+
error: error.message,
|
|
3043
|
+
}));
|
|
3044
|
+
if (out.error) {
|
|
3045
|
+
notify(`No se pudo resumir: ${out.error}`, 'notice err');
|
|
3046
|
+
return { handled: true };
|
|
3047
|
+
}
|
|
3048
|
+
notify(`**Resumen**\n\n${out.summary}`);
|
|
3049
|
+
reproducirVoz(out.summary, { label: 'resumen' }).catch((error) => notify(`No se pudo reproducir el audio: ${error.message}`, 'notice err'));
|
|
3050
|
+
return { handled: true };
|
|
3051
|
+
}
|
|
2994
3052
|
case 'compact': {
|
|
2995
3053
|
if (!state.sessionId) return needSession();
|
|
2996
3054
|
notify('Compactando la conversación…');
|
|
@@ -3053,7 +3111,10 @@ function updateSlashMenu() {
|
|
|
3053
3111
|
const value = chatInput.value;
|
|
3054
3112
|
if (!value.startsWith('/') || /\s/.test(value)) return hideSlashMenu();
|
|
3055
3113
|
const query = value.slice(1).toLowerCase();
|
|
3056
|
-
slashMatches = slashCommandEntries().filter(
|
|
3114
|
+
slashMatches = slashCommandEntries().filter(
|
|
3115
|
+
(entry) =>
|
|
3116
|
+
entry.name.slice(1).toLowerCase().startsWith(query) || entry.desc.toLowerCase().includes(query)
|
|
3117
|
+
);
|
|
3057
3118
|
if (!slashMatches.length) return hideSlashMenu();
|
|
3058
3119
|
if (slashIndex >= slashMatches.length) slashIndex = 0;
|
|
3059
3120
|
renderSlashMenu();
|
|
@@ -3896,35 +3957,139 @@ const voz = {
|
|
|
3896
3957
|
rate: '+0%',
|
|
3897
3958
|
announce: { start: true, tools: true, end: true },
|
|
3898
3959
|
},
|
|
3899
|
-
cola: Promise.resolve(),
|
|
3900
3960
|
};
|
|
3901
3961
|
|
|
3902
|
-
/**
|
|
3903
|
-
|
|
3962
|
+
/**
|
|
3963
|
+
* Un único reproductor para TODO el audio (narración, tool `speak`,
|
|
3964
|
+
* `/audioresumen`, prueba de voz): encola y reproduce de a uno, en orden, con
|
|
3965
|
+
* controles visibles para pausar o detener. Antes cada audio creaba su propio
|
|
3966
|
+
* `<audio>` y, si el modelo pedía dos, sonaban encimados y sin forma de pararlos.
|
|
3967
|
+
*/
|
|
3968
|
+
const ap = { el: $('#audio-player'), audio: new Audio(), queue: [], current: null, ready: false };
|
|
3969
|
+
|
|
3970
|
+
function ensurePlayer() {
|
|
3971
|
+
if (!ap.el || ap.ready) return;
|
|
3972
|
+
ap.ready = true;
|
|
3973
|
+
ap.toggle = button('', () => toggleAudio(), 'ap-btn');
|
|
3974
|
+
ap.iconEl = el('span', 'ap-icon');
|
|
3975
|
+
ap.toggle.appendChild(ap.iconEl);
|
|
3976
|
+
ap.labelEl = el('span', 'ap-label');
|
|
3977
|
+
ap.barEl = el('div', 'ap-bar');
|
|
3978
|
+
const progress = el('div', 'ap-progress');
|
|
3979
|
+
progress.appendChild(ap.barEl);
|
|
3980
|
+
const info = el('div', 'ap-info');
|
|
3981
|
+
info.append(ap.labelEl, progress);
|
|
3982
|
+
ap.queueEl = el('span', 'ap-queue');
|
|
3983
|
+
ap.stopBtn = button('', () => stopAudio(), 'ap-btn ap-stop');
|
|
3984
|
+
ap.stopBtn.title = 'detener';
|
|
3985
|
+
ap.stopBtn.setAttribute('aria-label', 'detener audio');
|
|
3986
|
+
ap.stopBtn.appendChild(icon('square', 14));
|
|
3987
|
+
ap.el.append(ap.toggle, info, ap.queueEl, ap.stopBtn);
|
|
3988
|
+
|
|
3989
|
+
ap.audio.addEventListener('ended', () => advanceAudio(ap.current));
|
|
3990
|
+
ap.audio.addEventListener('error', () => {
|
|
3991
|
+
if (ap.current && ap.audio.getAttribute('src')) advanceAudio(ap.current, new Error('No se pudo reproducir el audio.'));
|
|
3992
|
+
});
|
|
3993
|
+
ap.audio.addEventListener('play', renderAudio);
|
|
3994
|
+
ap.audio.addEventListener('pause', renderAudio);
|
|
3995
|
+
ap.audio.addEventListener('timeupdate', () => {
|
|
3996
|
+
const { duration, currentTime } = ap.audio;
|
|
3997
|
+
if (!Number.isFinite(duration) || duration <= 0) return;
|
|
3998
|
+
ap.barEl.style.width = `${Math.min(100, (currentTime / duration) * 100)}%`;
|
|
3999
|
+
});
|
|
4000
|
+
}
|
|
4001
|
+
|
|
4002
|
+
/** Encola un audio. La promesa se resuelve al terminar (o al detenerlo). */
|
|
4003
|
+
function enqueueSpeech({ texto, voice, rate, label = 'audio', maxAgeMs = 0 } = {}) {
|
|
4004
|
+
const frase = String(texto ?? '').trim();
|
|
4005
|
+
if (!frase) return Promise.resolve();
|
|
4006
|
+
ensurePlayer();
|
|
3904
4007
|
return new Promise((resolve, reject) => {
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
rate: rate ?? voz.settings.rate,
|
|
3909
|
-
});
|
|
3910
|
-
const audio = new Audio(`/api/tts?${params}`);
|
|
3911
|
-
audio.onended = () => resolve();
|
|
3912
|
-
audio.onerror = () => reject(new Error('no se pudo reproducir (¿sin conexión?)'));
|
|
3913
|
-
audio.play().catch(() => reject(new Error('el navegador bloqueó el audio: tocá la página y probá de nuevo')));
|
|
4008
|
+
ap.queue.push({ texto: frase, voice, rate, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
|
|
4009
|
+
if (ap.current) renderAudio();
|
|
4010
|
+
else playNextAudio();
|
|
3914
4011
|
});
|
|
3915
4012
|
}
|
|
3916
4013
|
|
|
4014
|
+
function playNextAudio() {
|
|
4015
|
+
const item = ap.queue.shift();
|
|
4016
|
+
if (!item) {
|
|
4017
|
+
ap.current = null;
|
|
4018
|
+
renderAudio();
|
|
4019
|
+
return;
|
|
4020
|
+
}
|
|
4021
|
+
if (item.maxAgeMs && Date.now() - item.createdAt > item.maxAgeMs) {
|
|
4022
|
+
item.resolve?.();
|
|
4023
|
+
return playNextAudio();
|
|
4024
|
+
}
|
|
4025
|
+
ap.current = item;
|
|
4026
|
+
const params = new URLSearchParams({
|
|
4027
|
+
texto: item.texto,
|
|
4028
|
+
voice: item.voice ?? voz.settings.voice,
|
|
4029
|
+
rate: item.rate ?? voz.settings.rate,
|
|
4030
|
+
});
|
|
4031
|
+
ap.audio.src = `/api/tts?${params}`;
|
|
4032
|
+
ap.audio.play().catch(() => {
|
|
4033
|
+
advanceAudio(item, new Error('el navegador bloqueó el audio: tocá la página y probá de nuevo'));
|
|
4034
|
+
});
|
|
4035
|
+
renderAudio();
|
|
4036
|
+
}
|
|
4037
|
+
|
|
4038
|
+
function advanceAudio(item, error) {
|
|
4039
|
+
if (!item || ap.current !== item) return;
|
|
4040
|
+
ap.current = null;
|
|
4041
|
+
if (error) item.reject?.(error);
|
|
4042
|
+
else item.resolve?.();
|
|
4043
|
+
playNextAudio();
|
|
4044
|
+
}
|
|
4045
|
+
|
|
4046
|
+
function toggleAudio() {
|
|
4047
|
+
if (!ap.current) return;
|
|
4048
|
+
if (ap.audio.paused) ap.audio.play().catch(() => {});
|
|
4049
|
+
else ap.audio.pause();
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
/** Detiene lo que suena y vacía lo que espera. */
|
|
4053
|
+
function stopAudio() {
|
|
4054
|
+
for (const item of ap.queue) item.resolve?.();
|
|
4055
|
+
ap.queue = [];
|
|
4056
|
+
const current = ap.current;
|
|
4057
|
+
ap.current = null;
|
|
4058
|
+
current?.resolve?.();
|
|
4059
|
+
ap.audio.pause();
|
|
4060
|
+
ap.audio.removeAttribute('src');
|
|
4061
|
+
ap.audio.load();
|
|
4062
|
+
if (ap.barEl) ap.barEl.style.width = '0%';
|
|
4063
|
+
renderAudio();
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
function renderAudio() {
|
|
4067
|
+
if (!ap.el || !ap.ready) return;
|
|
4068
|
+
const active = Boolean(ap.current) || ap.queue.length > 0;
|
|
4069
|
+
ap.el.hidden = !active;
|
|
4070
|
+
if (!active) return;
|
|
4071
|
+
ap.labelEl.textContent = ap.current?.label ?? 'audio';
|
|
4072
|
+
ap.queueEl.hidden = ap.queue.length === 0;
|
|
4073
|
+
ap.queueEl.textContent = ap.queue.length ? `+${ap.queue.length}` : '';
|
|
4074
|
+
const playing = Boolean(ap.current) && !ap.audio.paused;
|
|
4075
|
+
ap.iconEl.replaceChildren(icon(playing ? 'pause' : 'play', 14));
|
|
4076
|
+
ap.toggle.title = playing ? 'pausar' : 'reproducir';
|
|
4077
|
+
ap.toggle.setAttribute('aria-label', ap.toggle.title);
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
/** Reproduce una frase contra el endpoint de sintesis del server. */
|
|
4081
|
+
function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
|
|
4082
|
+
return enqueueSpeech({ texto: frase, voice, rate, label, maxAgeMs });
|
|
4083
|
+
}
|
|
4084
|
+
|
|
3917
4085
|
/**
|
|
3918
|
-
*
|
|
3919
|
-
*
|
|
4086
|
+
* Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
|
|
4087
|
+
* cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
|
|
3920
4088
|
*/
|
|
3921
4089
|
function hablar(texto) {
|
|
3922
4090
|
const frase = String(texto ?? '').trim();
|
|
3923
4091
|
if (!frase || !voz.settings.enabled) return;
|
|
3924
|
-
|
|
3925
|
-
voz.cola = voz.cola
|
|
3926
|
-
.then(() => (Date.now() - creado > 45000 ? null : reproducirVoz(frase)))
|
|
3927
|
-
.catch(() => {});
|
|
4092
|
+
reproducirVoz(frase, { label: 'narrando', maxAgeMs: 45000 }).catch(() => {});
|
|
3928
4093
|
}
|
|
3929
4094
|
|
|
3930
4095
|
/** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
</head>
|
|
35
35
|
<body>
|
|
36
36
|
<header class="topbar">
|
|
37
|
-
<div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.
|
|
37
|
+
<div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.4</span></div>
|
|
38
38
|
<nav class="tabs">
|
|
39
39
|
<button class="tab active" data-tab="chat">chat</button>
|
|
40
40
|
<button class="tab" data-tab="providers">proveedores</button>
|
|
@@ -141,6 +141,7 @@
|
|
|
141
141
|
<div id="toasts" class="toasts" aria-live="polite"></div>
|
|
142
142
|
<div id="attach-preview" class="attach-preview" hidden></div>
|
|
143
143
|
<div id="chat-queue" class="chat-queue" hidden></div>
|
|
144
|
+
<div id="audio-player" class="audio-player" hidden></div>
|
|
144
145
|
<div class="composer-row">
|
|
145
146
|
<span class="prompt-mark">❯</span>
|
|
146
147
|
<button type="button" id="attach-image" class="btn icon" title="Adjuntar imagen (o pegala / arrastrala)" aria-label="adjuntar imagen">
|
|
@@ -451,7 +451,8 @@
|
|
|
451
451
|
|
|
452
452
|
<p class="hero-copy">
|
|
453
453
|
Un harness local para orquestar agentes de IA, ejecutar tools en tu workspace y mantener el
|
|
454
|
-
control desde tu máquina o el celular (túnel + QR o Telegram).
|
|
454
|
+
control desde tu máquina o el celular (túnel + QR o Telegram). Renderiza páginas con un
|
|
455
|
+
navegador headless, narra su progreso y hace trazable cada corrida.
|
|
455
456
|
</p>
|
|
456
457
|
|
|
457
458
|
<div class="actions">
|
|
@@ -472,6 +473,18 @@
|
|
|
472
473
|
<div class="feature-icon">✓</div>
|
|
473
474
|
<div><b>Control</b><span>Permisos allow/ask/deny y runs trazables.</span></div>
|
|
474
475
|
</div>
|
|
476
|
+
<div class="feature">
|
|
477
|
+
<div class="feature-icon">◎</div>
|
|
478
|
+
<div><b>Navegador</b><span>Screenshot y errores de consola de una página.</span></div>
|
|
479
|
+
</div>
|
|
480
|
+
<div class="feature">
|
|
481
|
+
<div class="feature-icon">♪</div>
|
|
482
|
+
<div><b>Voz</b><span>Narra el progreso y manda audio bajo pedido.</span></div>
|
|
483
|
+
</div>
|
|
484
|
+
<div class="feature">
|
|
485
|
+
<div class="feature-icon">⌘</div>
|
|
486
|
+
<div><b>Multi-modelo</b><span>OpenRouter, OpenAI, Claude, Gemini y Ollama.</span></div>
|
|
487
|
+
</div>
|
|
475
488
|
</div>
|
|
476
489
|
</section>
|
|
477
490
|
|
|
@@ -110,7 +110,9 @@ export function summarizeArgs(name, args, max = 240) {
|
|
|
110
110
|
if ((name === 'write' || name === 'edit' || name === 'read') && a.path) {
|
|
111
111
|
return a.content ? `${a.path} · ${humanSize(a.content)}` : String(a.path);
|
|
112
112
|
}
|
|
113
|
-
if ((name === 'fetch_url' || name === 'preview_url') && a.url)
|
|
113
|
+
if ((name === 'fetch_url' || name === 'preview_url' || name === 'screenshot' || name === 'console_errors') && a.url) {
|
|
114
|
+
return cut(a.url);
|
|
115
|
+
}
|
|
114
116
|
if (name === 'task' && (a.description || a.prompt)) return cut(a.description || a.prompt);
|
|
115
117
|
const keys = Object.keys(a);
|
|
116
118
|
if (keys.length === 1 && typeof a[keys[0]] === 'string') return cut(a[keys[0]]);
|
|
@@ -521,7 +521,7 @@ body.resizing * { cursor: col-resize !important; }
|
|
|
521
521
|
}
|
|
522
522
|
.msg-menu[hidden] { display: none; }
|
|
523
523
|
.msg-menu-sep { height: 1px; margin: 4px 6px; background: var(--border); }
|
|
524
|
-
.msg-
|
|
524
|
+
.msg-menu .msg-act {
|
|
525
525
|
width: 100%;
|
|
526
526
|
display: flex;
|
|
527
527
|
align-items: center;
|
|
@@ -537,11 +537,24 @@ body.resizing * { cursor: col-resize !important; }
|
|
|
537
537
|
}
|
|
538
538
|
.msg-act .mi { flex: 0 0 auto; color: var(--muted); }
|
|
539
539
|
.msg-act .mlabel { min-width: 0; }
|
|
540
|
-
.msg-
|
|
541
|
-
.msg-
|
|
542
|
-
.msg-
|
|
543
|
-
.msg-
|
|
544
|
-
.msg-
|
|
540
|
+
.msg-menu .msg-act:hover { background: var(--theirs); }
|
|
541
|
+
.msg-menu .msg-act:hover .mi { color: var(--accent); }
|
|
542
|
+
.msg-menu .msg-act.danger { color: var(--danger); }
|
|
543
|
+
.msg-menu .msg-act.danger:hover { background: var(--theirs); }
|
|
544
|
+
.msg-menu .msg-act.danger:hover .mi { color: var(--danger); }
|
|
545
|
+
|
|
546
|
+
/* En PC el menú del mensaje va en linea (una fila), con el mismo lenguaje
|
|
547
|
+
visual de la consola. En pantallas chicas queda como lista desplegable. */
|
|
548
|
+
@media (min-width: 821px) {
|
|
549
|
+
.msg-menu {
|
|
550
|
+
flex-direction: row;
|
|
551
|
+
align-items: center;
|
|
552
|
+
min-width: 0;
|
|
553
|
+
gap: 2px;
|
|
554
|
+
}
|
|
555
|
+
.msg-menu .msg-act { width: auto; white-space: nowrap; }
|
|
556
|
+
.msg-menu-sep { width: 1px; height: auto; align-self: stretch; margin: 6px 3px; }
|
|
557
|
+
}
|
|
545
558
|
.bubble .tools { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; font-weight: 700; }
|
|
546
559
|
|
|
547
560
|
.tools-actions { display: flex; align-items: center; gap: 8px; }
|
|
@@ -600,12 +613,84 @@ body.resizing * { cursor: col-resize !important; }
|
|
|
600
613
|
overflow: auto;
|
|
601
614
|
}
|
|
602
615
|
.tool-card > .tool-out:empty { display: none; }
|
|
603
|
-
.tool-card > .tool-audio {
|
|
604
|
-
display:
|
|
605
|
-
|
|
606
|
-
|
|
616
|
+
.tool-card > .tool-audio-btn {
|
|
617
|
+
display: inline-flex;
|
|
618
|
+
align-items: center;
|
|
619
|
+
gap: 6px;
|
|
607
620
|
margin: 4px 0 2px 18px;
|
|
621
|
+
padding: 4px 10px;
|
|
622
|
+
border: 1px solid var(--border);
|
|
623
|
+
border-radius: 999px;
|
|
624
|
+
background: transparent;
|
|
625
|
+
color: var(--accent);
|
|
626
|
+
cursor: pointer;
|
|
627
|
+
font: 600 12px var(--sans);
|
|
628
|
+
}
|
|
629
|
+
.tool-card > .tool-audio-btn:hover { background: var(--theirs); }
|
|
630
|
+
.tool-card > .tool-shot {
|
|
631
|
+
display: block;
|
|
632
|
+
max-width: calc(100% - 28px);
|
|
633
|
+
max-height: 420px;
|
|
634
|
+
width: auto;
|
|
635
|
+
margin: 6px 0 4px 18px;
|
|
636
|
+
border: 1px solid var(--border);
|
|
637
|
+
border-radius: 8px;
|
|
638
|
+
cursor: zoom-in;
|
|
639
|
+
background: #fff;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/* Reproductor único de audio (narración, speak, resumen): visible y detenible. */
|
|
643
|
+
.audio-player {
|
|
644
|
+
display: flex;
|
|
645
|
+
align-items: center;
|
|
646
|
+
gap: 10px;
|
|
647
|
+
padding: 7px 10px;
|
|
648
|
+
margin-bottom: 6px;
|
|
649
|
+
border: 1px solid var(--border);
|
|
650
|
+
border-radius: 10px;
|
|
651
|
+
background: var(--solid, var(--panel));
|
|
652
|
+
box-shadow: 0 6px 18px rgb(0 0 0 / 0.12);
|
|
608
653
|
}
|
|
654
|
+
.audio-player[hidden] { display: none; }
|
|
655
|
+
.audio-player .ap-btn {
|
|
656
|
+
flex: 0 0 auto;
|
|
657
|
+
display: inline-grid;
|
|
658
|
+
place-items: center;
|
|
659
|
+
width: 28px;
|
|
660
|
+
height: 28px;
|
|
661
|
+
padding: 0;
|
|
662
|
+
border: 1px solid var(--border);
|
|
663
|
+
border-radius: 999px;
|
|
664
|
+
background: transparent;
|
|
665
|
+
color: var(--accent);
|
|
666
|
+
cursor: pointer;
|
|
667
|
+
}
|
|
668
|
+
.audio-player .ap-btn:hover { background: var(--theirs); }
|
|
669
|
+
.audio-player .ap-stop { color: var(--danger); }
|
|
670
|
+
.audio-player .ap-info { flex: 1 1 auto; min-width: 0; }
|
|
671
|
+
.audio-player .ap-label {
|
|
672
|
+
display: block;
|
|
673
|
+
color: var(--muted);
|
|
674
|
+
font: 600 11px var(--sans);
|
|
675
|
+
text-transform: uppercase;
|
|
676
|
+
letter-spacing: 0.04em;
|
|
677
|
+
margin-bottom: 4px;
|
|
678
|
+
}
|
|
679
|
+
.audio-player .ap-progress {
|
|
680
|
+
height: 4px;
|
|
681
|
+
border-radius: 999px;
|
|
682
|
+
background: var(--border);
|
|
683
|
+
overflow: hidden;
|
|
684
|
+
}
|
|
685
|
+
.audio-player .ap-bar {
|
|
686
|
+
width: 0%;
|
|
687
|
+
height: 100%;
|
|
688
|
+
border-radius: 999px;
|
|
689
|
+
background: var(--accent);
|
|
690
|
+
transition: width 0.15s linear;
|
|
691
|
+
}
|
|
692
|
+
.audio-player .ap-queue { flex: 0 0 auto; color: var(--muted); font: 600 11px var(--sans); }
|
|
693
|
+
.audio-player .ap-queue[hidden] { display: none; }
|
|
609
694
|
.tool-more {
|
|
610
695
|
display: inline-block;
|
|
611
696
|
margin: 2px 0 2px 18px;
|
|
@@ -946,7 +1031,7 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
|
|
|
946
1031
|
left: 14px;
|
|
947
1032
|
right: 14px;
|
|
948
1033
|
bottom: calc(100% - 6px);
|
|
949
|
-
max-height:
|
|
1034
|
+
max-height: min(60vh, 420px);
|
|
950
1035
|
overflow-y: auto;
|
|
951
1036
|
display: flex;
|
|
952
1037
|
flex-direction: column;
|
|
@@ -954,8 +1039,9 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
|
|
|
954
1039
|
padding: 6px;
|
|
955
1040
|
border: 1px solid var(--border);
|
|
956
1041
|
border-radius: 12px;
|
|
957
|
-
background: var(--panel);
|
|
1042
|
+
background: var(--solid, var(--panel));
|
|
958
1043
|
box-shadow: 0 12px 32px rgb(0 0 0 / 0.22);
|
|
1044
|
+
backdrop-filter: blur(18px);
|
|
959
1045
|
z-index: 30;
|
|
960
1046
|
}
|
|
961
1047
|
.slash-menu[hidden] { display: none; }
|
|
@@ -6,10 +6,10 @@ import { summarizeToolCall, previewToolResult } from './core.js';
|
|
|
6
6
|
|
|
7
7
|
const FAILED_RE = /^Error|^Permiso denegado/i;
|
|
8
8
|
|
|
9
|
-
/** Payload estandar de un tool_result para eventos SSE: preview + failed. */
|
|
9
|
+
/** Payload estandar de un tool_result para eventos SSE: preview + failed (+ meta). */
|
|
10
10
|
export function toolResultPayload(event) {
|
|
11
11
|
const { preview, truncated } = previewToolResult(event.name, event.result);
|
|
12
|
-
return { preview, truncated, failed: FAILED_RE.test(String(event.result)) };
|
|
12
|
+
return { preview, truncated, failed: FAILED_RE.test(String(event.result)), meta: event.meta };
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function toolCallPayload(event) {
|