@danieltmn/tamnora 0.9.2 → 0.9.3
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 +7 -0
- 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/index.js +1 -1
- package/harness/packages/core/src/providers/presets.js +4 -0
- package/harness/packages/core/src/tools/diff.js +70 -1
- package/harness/packages/core/test/diff.test.js +24 -1
- package/harness/packages/server/public/app.js +174 -38
- package/harness/packages/server/public/index.html +2 -1
- package/harness/packages/server/public/styles.css +87 -12
- package/harness/packages/server/src/lib/channels/telegram.js +1 -21
- package/harness/packages/server/src/routes/sessions.js +55 -33
- package/harness/packages/server/test/server.test.js +34 -0
- package/harness/packages/server/test/telegram-voz.test.js +3 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ 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.9.3] - 2026-10-07
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- feat(telegram): audio solo cuando el usuario lo pide
|
|
13
|
+
- feat: diff lado a lado en TUI, proveedores Anthropic/Gemini y mejoras de consola
|
|
14
|
+
|
|
8
15
|
## [0.9.2] - 2026-10-07
|
|
9
16
|
|
|
10
17
|
### Cambios
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Full-screen terminal console (opencode/codex-like), zero dependencies.
|
|
2
2
|
// Alt-screen + raw input + incremental rendering; reuses the harness engine.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { createTools, diffRows, resolveInRoot } from '../lib/core.js';
|
|
5
6
|
import { runTurn } from '../lib/runtime.js';
|
|
6
7
|
import { parseKeys } from './keys.js';
|
|
7
8
|
import { render } from './layout.js';
|
|
@@ -67,6 +68,15 @@ export function runTui({
|
|
|
67
68
|
tools: [],
|
|
68
69
|
});
|
|
69
70
|
|
|
71
|
+
const readWorkspaceFile = (rel) => {
|
|
72
|
+
if (!rel) return '';
|
|
73
|
+
try {
|
|
74
|
+
return readFileSync(resolveInRoot(toolOptions.root, rel), 'utf8');
|
|
75
|
+
} catch {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
70
80
|
const onAsk = (name, args) =>
|
|
71
81
|
new Promise((resolve) => {
|
|
72
82
|
state.pendingAsk = { name, detail: args?.command ?? args?.path ?? '', resolve };
|
|
@@ -186,10 +196,19 @@ export function runTui({
|
|
|
186
196
|
} else if (event.type === 'stall') {
|
|
187
197
|
if (!sawText) assistant.text = `⚠ ${event.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`;
|
|
188
198
|
} else if (event.type === 'tool_call') {
|
|
189
|
-
|
|
199
|
+
const tool = { name: event.name, args: JSON.stringify(event.args), path: event.args?.path };
|
|
200
|
+
if (event.name === 'write' || event.name === 'edit') {
|
|
201
|
+
tool.oldContent = readWorkspaceFile(event.args?.path);
|
|
202
|
+
}
|
|
203
|
+
assistant.tools.push(tool);
|
|
190
204
|
} else if (event.type === 'tool_result') {
|
|
191
205
|
const tool = assistant.tools[assistant.tools.length - 1];
|
|
192
|
-
if (tool)
|
|
206
|
+
if (tool) {
|
|
207
|
+
tool.result = String(event.result).slice(0, 2000);
|
|
208
|
+
if (tool.oldContent !== undefined) {
|
|
209
|
+
tool.diff = diffRows(tool.oldContent, readWorkspaceFile(tool.path), { maxRows: 400 });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
193
212
|
}
|
|
194
213
|
draw();
|
|
195
214
|
},
|
|
@@ -48,6 +48,53 @@ function clip(text, width) {
|
|
|
48
48
|
return arr.length <= width ? text : arr.slice(0, width).join('');
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function sanitize(text) {
|
|
52
|
+
return String(text ?? '').replace(/\t/g, ' ');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Una columna del diff: numero de linea dim y contenido rojo (viejo) / verde
|
|
56
|
+
// (nuevo) / normal (contexto). Devuelve texto con ANSI ya resuelto (raw).
|
|
57
|
+
function diffSide(cell, kind, numW, contentW) {
|
|
58
|
+
if (!cell) return color.dim(' '.repeat(numW + 3 + contentW));
|
|
59
|
+
const num = String(cell.no).padStart(numW);
|
|
60
|
+
const text = clip(sanitize(cell.text), contentW);
|
|
61
|
+
const pad = ' '.repeat(Math.max(0, contentW - chars(text).length));
|
|
62
|
+
const sign = kind === 'del' ? '-' : kind === 'add' ? '+' : ' ';
|
|
63
|
+
const paint = kind === 'del' ? color.red : kind === 'add' ? color.green : (t) => t;
|
|
64
|
+
return color.dim(`${num} `) + paint(`${sign} ${text}`) + pad;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Arma las lineas del diff lado a lado. Devuelve null si el ancho es muy chico
|
|
68
|
+
// (en ese caso el que llama cae al diff de una sola columna).
|
|
69
|
+
function buildDiff(diff, width) {
|
|
70
|
+
const indent = 2;
|
|
71
|
+
const mid = ' │ ';
|
|
72
|
+
const maxNo = diff.reduce((m, row) => Math.max(m, row.left?.no ?? 0, row.right?.no ?? 0), 1);
|
|
73
|
+
const numW = Math.max(3, String(maxNo).length);
|
|
74
|
+
const colW = Math.floor((width - indent - mid.length) / 2);
|
|
75
|
+
const contentW = colW - numW - 3;
|
|
76
|
+
if (contentW < 6) return null;
|
|
77
|
+
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const row of diff) {
|
|
80
|
+
if (row.type === 'skip') {
|
|
81
|
+
out.push({ text: `${' '.repeat(indent)}${color.dim('…')}`, raw: true });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const leftKind = row.type === 'add' ? 'blank' : row.type === 'ctx' ? 'ctx' : 'del';
|
|
85
|
+
const rightKind = row.type === 'del' ? 'blank' : row.type === 'ctx' ? 'ctx' : 'add';
|
|
86
|
+
const line =
|
|
87
|
+
' '.repeat(indent) +
|
|
88
|
+
diffSide(row.left, leftKind, numW, contentW) +
|
|
89
|
+
color.dim(mid) +
|
|
90
|
+
diffSide(row.right, rightKind, numW, contentW);
|
|
91
|
+
out.push({ text: line, raw: true });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const TOOL_LABELS = { write: 'Write', edit: 'Edit' };
|
|
97
|
+
|
|
51
98
|
function buildBody(state, width) {
|
|
52
99
|
const out = [];
|
|
53
100
|
for (const message of state.messages) {
|
|
@@ -62,6 +109,12 @@ function buildBody(state, width) {
|
|
|
62
109
|
for (const line of wrapText(message.text, width - 2)) out.push({ text: ` ${line}`, style: 'assistant' });
|
|
63
110
|
}
|
|
64
111
|
for (const tool of message.tools ?? []) {
|
|
112
|
+
const diff = tool.diff?.length ? buildDiff(tool.diff, width) : null;
|
|
113
|
+
if (diff) {
|
|
114
|
+
out.push({ text: ` ✎ ${TOOL_LABELS[tool.name] ?? tool.name} ${tool.path ?? ''}`.trimEnd(), style: 'tool' });
|
|
115
|
+
out.push(...diff);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
65
118
|
out.push({ text: ` ⚙ ${tool.name} ${clip(tool.args ?? '', width - 8)}`, style: 'tool' });
|
|
66
119
|
if (tool.result !== undefined) {
|
|
67
120
|
for (const line of wrapText(tool.result, width - 6)) out.push({ text: ` → ${line}`, style: 'dim' });
|
|
@@ -121,8 +174,12 @@ export function render(state, { cols = 80, rows = 24 } = {}) {
|
|
|
121
174
|
const parts = [];
|
|
122
175
|
for (let i = 0; i < rows; i++) {
|
|
123
176
|
const entry = lines[i] ?? { text: '', style: 'dim' };
|
|
124
|
-
|
|
125
|
-
parts.push(
|
|
177
|
+
parts.push(moveTo(i + 1, 1), '\x1b[2K');
|
|
178
|
+
if (entry.raw) parts.push(entry.text);
|
|
179
|
+
else {
|
|
180
|
+
const style = THEME[entry.style] ?? THEME.assistant;
|
|
181
|
+
parts.push(style(clip(entry.text, width)));
|
|
182
|
+
}
|
|
126
183
|
}
|
|
127
184
|
|
|
128
185
|
const cursorCol = state.pendingAsk ? 2 : Math.min(width, chars(` ❯ ${before}`).length + 1);
|
|
@@ -81,6 +81,36 @@ test('render places the cursor at inputCursor', () => {
|
|
|
81
81
|
assert.equal(cursor.col, 6);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
test('render shows a side-by-side diff for write/edit', () => {
|
|
85
|
+
const state = {
|
|
86
|
+
header: { provider: 'p', model: 'm', cwd: '/x' },
|
|
87
|
+
messages: [
|
|
88
|
+
{
|
|
89
|
+
role: 'assistant',
|
|
90
|
+
text: '',
|
|
91
|
+
tools: [
|
|
92
|
+
{
|
|
93
|
+
name: 'edit',
|
|
94
|
+
args: '{"path":"a.js"}',
|
|
95
|
+
path: 'a.js',
|
|
96
|
+
diff: [
|
|
97
|
+
{ type: 'ctx', left: { no: 1, text: 'const a = 1;' }, right: { no: 1, text: 'const a = 1;' } },
|
|
98
|
+
{ type: 'mod', left: { no: 2, text: 'const b = 2;' }, right: { no: 2, text: 'const b = 3;' } },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
input: '',
|
|
105
|
+
streaming: false,
|
|
106
|
+
pendingAsk: null,
|
|
107
|
+
};
|
|
108
|
+
const { frame } = render(state, { cols: 80, rows: 20 });
|
|
109
|
+
assert.match(frame, /Edit a\.js/);
|
|
110
|
+
assert.match(frame, /const b = 2;/);
|
|
111
|
+
assert.match(frame, /const b = 3;/);
|
|
112
|
+
});
|
|
113
|
+
|
|
84
114
|
test('render scrolls the viewport', () => {
|
|
85
115
|
const messages = Array.from({ length: 40 }, (_, i) => ({ role: 'assistant', text: `linea ${i}`, tools: [] }));
|
|
86
116
|
const base = { header: { provider: 'p', model: 'm', cwd: '/x' }, input: '', streaming: false, pendingAsk: null };
|
|
@@ -12,7 +12,7 @@ export { resolveApiKey, resolveEnvMap } from './config/secrets.js';
|
|
|
12
12
|
export { ensureUtf8Console } from './console.js';
|
|
13
13
|
export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
|
|
14
14
|
export { estimateTokens, trimContext } from './agent/context.js';
|
|
15
|
-
export { diffLines } from './tools/diff.js';
|
|
15
|
+
export { diffLines, diffRows } from './tools/diff.js';
|
|
16
16
|
export { isGitRepo, gitStatus, gitDiff } from './git/index.js';
|
|
17
17
|
export { summarizeToolCall, previewToolResult } from './tools/labels.js';
|
|
18
18
|
export { MODES, MODE_IDS, resolveMode } from './agent/modes.js';
|
|
@@ -9,6 +9,10 @@ function preset(id, label, baseUrl) {
|
|
|
9
9
|
export const OPENAI_COMPATIBLE_PRESETS = [
|
|
10
10
|
preset('deepseek', 'DeepSeek', 'https://api.deepseek.com/v1'),
|
|
11
11
|
preset('openai', 'OpenAI', 'https://api.openai.com/v1'),
|
|
12
|
+
// Anthropic y Gemini exponen una capa compatible con OpenAI (chat/completions
|
|
13
|
+
// + tools) que usa el mismo Bearer; sirven con este adaptador sin cambios.
|
|
14
|
+
preset('anthropic', 'Anthropic (Claude)', 'https://api.anthropic.com/v1'),
|
|
15
|
+
preset('gemini', 'Google Gemini', 'https://generativelanguage.googleapis.com/v1beta/openai'),
|
|
12
16
|
preset('groq', 'Groq', 'https://api.groq.com/openai/v1'),
|
|
13
17
|
preset('mistral', 'Mistral AI', 'https://api.mistral.ai/v1'),
|
|
14
18
|
preset('xai', 'xAI (Grok)', 'https://api.x.ai/v1'),
|
|
@@ -7,7 +7,9 @@ const MAX_CELLS = 1_500_000;
|
|
|
7
7
|
|
|
8
8
|
function splitLines(text) {
|
|
9
9
|
if (text === '' || text == null) return [];
|
|
10
|
-
|
|
10
|
+
const lines = String(text).replace(/\r\n/g, '\n').split('\n');
|
|
11
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
|
|
12
|
+
return lines;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
function computeOps(a, b) {
|
|
@@ -88,3 +90,70 @@ export function diffLines(oldText, newText, { context = 3, maxLines = 200 } = {}
|
|
|
88
90
|
if (truncated) out.push(' … (diff truncado)');
|
|
89
91
|
return out.join('\n');
|
|
90
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Diff estructurado para una vista lado a lado (dos columnas): devuelve filas
|
|
96
|
+
* con la linea vieja (izquierda) y la nueva (derecha) ya numeradas. Las
|
|
97
|
+
* supresiones y adiciones de un mismo bloque se emparejan en la misma fila.
|
|
98
|
+
*
|
|
99
|
+
* @returns {Array<{type:'ctx'|'mod'|'del'|'add'|'skip', left:({no:number,text:string}|null), right:({no:number,text:string}|null)}>}
|
|
100
|
+
* [] si no hay cambios.
|
|
101
|
+
*/
|
|
102
|
+
export function diffRows(oldText, newText, { context = 3, maxRows = 400 } = {}) {
|
|
103
|
+
const ops = computeOps(splitLines(oldText), splitLines(newText));
|
|
104
|
+
const changes = ops.map((o, idx) => (o.t === 'eq' ? -1 : idx)).filter((idx) => idx >= 0);
|
|
105
|
+
if (!changes.length) return [];
|
|
106
|
+
|
|
107
|
+
const keep = new Array(ops.length).fill(false);
|
|
108
|
+
for (const idx of changes) {
|
|
109
|
+
for (let j = Math.max(0, idx - context); j <= Math.min(ops.length - 1, idx + context); j++) keep[j] = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rows = [];
|
|
113
|
+
let oldNo = 1;
|
|
114
|
+
let newNo = 1;
|
|
115
|
+
let pending = [];
|
|
116
|
+
let skipped = false;
|
|
117
|
+
|
|
118
|
+
const flush = () => {
|
|
119
|
+
const dels = pending.filter((p) => p.t === 'del');
|
|
120
|
+
const adds = pending.filter((p) => p.t === 'add');
|
|
121
|
+
const total = Math.max(dels.length, adds.length);
|
|
122
|
+
for (let i = 0; i < total; i++) {
|
|
123
|
+
const d = dels[i] ?? null;
|
|
124
|
+
const a = adds[i] ?? null;
|
|
125
|
+
rows.push({
|
|
126
|
+
type: d && a ? 'mod' : d ? 'del' : 'add',
|
|
127
|
+
left: d ? { no: d.no, text: d.line } : null,
|
|
128
|
+
right: a ? { no: a.no, text: a.line } : null,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
pending = [];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
for (let idx = 0; idx < ops.length; idx++) {
|
|
135
|
+
const op = ops[idx];
|
|
136
|
+
if (op.t === 'eq') {
|
|
137
|
+
flush();
|
|
138
|
+
if (keep[idx]) {
|
|
139
|
+
rows.push({ type: 'ctx', left: { no: oldNo, text: op.line }, right: { no: newNo, text: op.line } });
|
|
140
|
+
skipped = false;
|
|
141
|
+
} else if (!skipped) {
|
|
142
|
+
rows.push({ type: 'skip', left: null, right: null });
|
|
143
|
+
skipped = true;
|
|
144
|
+
}
|
|
145
|
+
oldNo++;
|
|
146
|
+
newNo++;
|
|
147
|
+
} else if (op.t === 'del') {
|
|
148
|
+
pending.push({ t: 'del', line: op.line, no: oldNo++ });
|
|
149
|
+
} else {
|
|
150
|
+
pending.push({ t: 'add', line: op.line, no: newNo++ });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
flush();
|
|
154
|
+
|
|
155
|
+
if (rows.length > maxRows) {
|
|
156
|
+
return rows.slice(0, maxRows).concat({ type: 'skip', left: null, right: null });
|
|
157
|
+
}
|
|
158
|
+
return rows;
|
|
159
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { diffLines } from '../src/index.js';
|
|
3
|
+
import { diffLines, diffRows } from '../src/index.js';
|
|
4
4
|
|
|
5
5
|
test('diffLines reports added and removed lines with context', () => {
|
|
6
6
|
const diff = diffLines('a\nb\nc\n', 'a\nB\nc\n');
|
|
@@ -18,3 +18,26 @@ test('diffLines marks a new file as additions', () => {
|
|
|
18
18
|
assert.match(diff, /^\+ l1$/m);
|
|
19
19
|
assert.match(diff, /^\+ l2$/m);
|
|
20
20
|
});
|
|
21
|
+
|
|
22
|
+
test('diffRows pairs a removed and an added line side by side', () => {
|
|
23
|
+
const rows = diffRows('a\nb\nc\n', 'a\nB\nc\n');
|
|
24
|
+
const mod = rows.find((r) => r.type === 'mod');
|
|
25
|
+
assert.equal(mod.left.text, 'b');
|
|
26
|
+
assert.equal(mod.left.no, 2);
|
|
27
|
+
assert.equal(mod.right.text, 'B');
|
|
28
|
+
assert.equal(mod.right.no, 2);
|
|
29
|
+
assert.ok(rows.some((r) => r.type === 'ctx' && r.left.no === 1 && r.right.no === 1));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('diffRows numbers additions for a new file', () => {
|
|
33
|
+
const rows = diffRows('', 'l1\nl2\n');
|
|
34
|
+
assert.equal(rows.length, 2);
|
|
35
|
+
assert.equal(rows[0].type, 'add');
|
|
36
|
+
assert.equal(rows[0].left, null);
|
|
37
|
+
assert.equal(rows[0].right.text, 'l1');
|
|
38
|
+
assert.equal(rows[1].right.no, 2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('diffRows returns an empty list when there are no changes', () => {
|
|
42
|
+
assert.deepEqual(diffRows('x\ny\n', 'x\ny\n'), []);
|
|
43
|
+
});
|
|
@@ -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');
|
|
@@ -1252,18 +1258,13 @@ async function loadFullToolResult(entry, message) {
|
|
|
1252
1258
|
}
|
|
1253
1259
|
}
|
|
1254
1260
|
|
|
1255
|
-
/**
|
|
1256
|
-
function
|
|
1261
|
+
/** Botón del tool `speak`: encola el audio pedido en el reproductor global. */
|
|
1262
|
+
function speakControl(args) {
|
|
1257
1263
|
const texto = String(args?.texto ?? '').trim();
|
|
1258
1264
|
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;
|
|
1265
|
+
const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
|
|
1266
|
+
btn.append(icon('play', 13), el('span', '', 'escuchar'));
|
|
1267
|
+
return btn;
|
|
1267
1268
|
}
|
|
1268
1269
|
|
|
1269
1270
|
/** Texto de la tarjeta: el server ya manda `summary` calculado; esto es el fallback. */
|
|
@@ -1277,17 +1278,17 @@ function addToolCard(message, name, args, { callId = null, open = null, round =
|
|
|
1277
1278
|
const argsEl = el('span', 'tool-args', summary ?? toolSummary(name, args));
|
|
1278
1279
|
head.append(glyph, el('span', 'tool-name', name), argsEl);
|
|
1279
1280
|
const out = el('div', 'tool-out');
|
|
1280
|
-
const
|
|
1281
|
+
const speak = name === 'speak' ? speakControl(args) : null;
|
|
1281
1282
|
const more = button('ver completo', () => loadFullToolResult(entry, message), 'tool-more');
|
|
1282
1283
|
more.hidden = true;
|
|
1283
1284
|
card.append(head, out);
|
|
1284
|
-
if (
|
|
1285
|
+
if (speak) card.appendChild(speak);
|
|
1285
1286
|
card.appendChild(more);
|
|
1286
1287
|
message.tools.appendChild(card);
|
|
1287
1288
|
ensureToolsToggle(message);
|
|
1288
1289
|
scrollMessages(paneOf(message));
|
|
1289
1290
|
|
|
1290
|
-
const entry = { card, out, glyph, argsEl, more,
|
|
1291
|
+
const entry = { card, out, glyph, argsEl, more, callId, full: false, truncated: false };
|
|
1291
1292
|
card.addEventListener('toggle', () => {
|
|
1292
1293
|
if (card.open && entry.truncated && !entry.full) loadFullToolResult(entry, message);
|
|
1293
1294
|
});
|
|
@@ -2022,8 +2023,8 @@ function handleChatEvent(event, data, turn) {
|
|
|
2022
2023
|
setActivity(target, 'working');
|
|
2023
2024
|
turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
|
|
2024
2025
|
if (turn.live && data.name === 'speak') {
|
|
2025
|
-
const
|
|
2026
|
-
if (
|
|
2026
|
+
const texto = String(data.args?.texto ?? '').trim();
|
|
2027
|
+
if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
|
|
2027
2028
|
}
|
|
2028
2029
|
} else if (event === 'tool_result') {
|
|
2029
2030
|
setActivity(turn.assistant(), 'thinking');
|
|
@@ -2446,8 +2447,8 @@ const micBtn = $('#mic-btn');
|
|
|
2446
2447
|
|
|
2447
2448
|
// La cuenta 3-2-1 le da tiempo al micro a arrancar (si hablás apenas tocás se
|
|
2448
2449
|
// 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 =
|
|
2450
|
+
const REC_COUNT = [3, 2, 1, 0];
|
|
2451
|
+
const REC_COUNT_STEP_MS = 500;
|
|
2451
2452
|
const REC_TAIL_MS = 500;
|
|
2452
2453
|
|
|
2453
2454
|
/** Estado visual del botón de dictado: cuenta regresiva, grabando (verde) o transcribiendo. */
|
|
@@ -2891,6 +2892,8 @@ const COMMANDS = [
|
|
|
2891
2892
|
['/new', 'nueva sesión (elegís carpeta)'],
|
|
2892
2893
|
['/init', 'analizar el proyecto y crear/actualizar AGENTS.md'],
|
|
2893
2894
|
['/goal <texto>', 'fijar el objetivo de la sesión'],
|
|
2895
|
+
['/resumen', 'resumir la conversación (sin borrarla)'],
|
|
2896
|
+
['/audioresumen', 'el resumen como audio'],
|
|
2894
2897
|
['/compact', 'resumir la conversación y liberar contexto'],
|
|
2895
2898
|
['/clear', 'borrar los mensajes de esta sesión'],
|
|
2896
2899
|
['/cwd', 'mostrar la carpeta de trabajo'],
|
|
@@ -2991,6 +2994,32 @@ async function runCommand(name, arg) {
|
|
|
2991
2994
|
notify(`Objetivo de la sesión: **${arg}**`);
|
|
2992
2995
|
return { handled: true };
|
|
2993
2996
|
}
|
|
2997
|
+
case 'resumen':
|
|
2998
|
+
case 'resume': {
|
|
2999
|
+
if (!state.sessionId) return needSession();
|
|
3000
|
+
notify('Resumiendo la conversación…');
|
|
3001
|
+
const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
|
|
3002
|
+
error: error.message,
|
|
3003
|
+
}));
|
|
3004
|
+
if (out.error) notify(`No se pudo resumir: ${out.error}`, 'notice err');
|
|
3005
|
+
else notify(`**Resumen**\n\n${out.summary}`);
|
|
3006
|
+
return { handled: true };
|
|
3007
|
+
}
|
|
3008
|
+
case 'audioresumen':
|
|
3009
|
+
case 'audio-resumen': {
|
|
3010
|
+
if (!state.sessionId) return needSession();
|
|
3011
|
+
notify('Preparando el resumen…');
|
|
3012
|
+
const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
|
|
3013
|
+
error: error.message,
|
|
3014
|
+
}));
|
|
3015
|
+
if (out.error) {
|
|
3016
|
+
notify(`No se pudo resumir: ${out.error}`, 'notice err');
|
|
3017
|
+
return { handled: true };
|
|
3018
|
+
}
|
|
3019
|
+
notify(`**Resumen**\n\n${out.summary}`);
|
|
3020
|
+
reproducirVoz(out.summary, { label: 'resumen' }).catch((error) => notify(`No se pudo reproducir el audio: ${error.message}`, 'notice err'));
|
|
3021
|
+
return { handled: true };
|
|
3022
|
+
}
|
|
2994
3023
|
case 'compact': {
|
|
2995
3024
|
if (!state.sessionId) return needSession();
|
|
2996
3025
|
notify('Compactando la conversación…');
|
|
@@ -3053,7 +3082,10 @@ function updateSlashMenu() {
|
|
|
3053
3082
|
const value = chatInput.value;
|
|
3054
3083
|
if (!value.startsWith('/') || /\s/.test(value)) return hideSlashMenu();
|
|
3055
3084
|
const query = value.slice(1).toLowerCase();
|
|
3056
|
-
slashMatches = slashCommandEntries().filter(
|
|
3085
|
+
slashMatches = slashCommandEntries().filter(
|
|
3086
|
+
(entry) =>
|
|
3087
|
+
entry.name.slice(1).toLowerCase().startsWith(query) || entry.desc.toLowerCase().includes(query)
|
|
3088
|
+
);
|
|
3057
3089
|
if (!slashMatches.length) return hideSlashMenu();
|
|
3058
3090
|
if (slashIndex >= slashMatches.length) slashIndex = 0;
|
|
3059
3091
|
renderSlashMenu();
|
|
@@ -3896,35 +3928,139 @@ const voz = {
|
|
|
3896
3928
|
rate: '+0%',
|
|
3897
3929
|
announce: { start: true, tools: true, end: true },
|
|
3898
3930
|
},
|
|
3899
|
-
cola: Promise.resolve(),
|
|
3900
3931
|
};
|
|
3901
3932
|
|
|
3902
|
-
/**
|
|
3903
|
-
|
|
3933
|
+
/**
|
|
3934
|
+
* Un único reproductor para TODO el audio (narración, tool `speak`,
|
|
3935
|
+
* `/audioresumen`, prueba de voz): encola y reproduce de a uno, en orden, con
|
|
3936
|
+
* controles visibles para pausar o detener. Antes cada audio creaba su propio
|
|
3937
|
+
* `<audio>` y, si el modelo pedía dos, sonaban encimados y sin forma de pararlos.
|
|
3938
|
+
*/
|
|
3939
|
+
const ap = { el: $('#audio-player'), audio: new Audio(), queue: [], current: null, ready: false };
|
|
3940
|
+
|
|
3941
|
+
function ensurePlayer() {
|
|
3942
|
+
if (!ap.el || ap.ready) return;
|
|
3943
|
+
ap.ready = true;
|
|
3944
|
+
ap.toggle = button('', () => toggleAudio(), 'ap-btn');
|
|
3945
|
+
ap.iconEl = el('span', 'ap-icon');
|
|
3946
|
+
ap.toggle.appendChild(ap.iconEl);
|
|
3947
|
+
ap.labelEl = el('span', 'ap-label');
|
|
3948
|
+
ap.barEl = el('div', 'ap-bar');
|
|
3949
|
+
const progress = el('div', 'ap-progress');
|
|
3950
|
+
progress.appendChild(ap.barEl);
|
|
3951
|
+
const info = el('div', 'ap-info');
|
|
3952
|
+
info.append(ap.labelEl, progress);
|
|
3953
|
+
ap.queueEl = el('span', 'ap-queue');
|
|
3954
|
+
ap.stopBtn = button('', () => stopAudio(), 'ap-btn ap-stop');
|
|
3955
|
+
ap.stopBtn.title = 'detener';
|
|
3956
|
+
ap.stopBtn.setAttribute('aria-label', 'detener audio');
|
|
3957
|
+
ap.stopBtn.appendChild(icon('square', 14));
|
|
3958
|
+
ap.el.append(ap.toggle, info, ap.queueEl, ap.stopBtn);
|
|
3959
|
+
|
|
3960
|
+
ap.audio.addEventListener('ended', () => advanceAudio(ap.current));
|
|
3961
|
+
ap.audio.addEventListener('error', () => {
|
|
3962
|
+
if (ap.current && ap.audio.getAttribute('src')) advanceAudio(ap.current, new Error('No se pudo reproducir el audio.'));
|
|
3963
|
+
});
|
|
3964
|
+
ap.audio.addEventListener('play', renderAudio);
|
|
3965
|
+
ap.audio.addEventListener('pause', renderAudio);
|
|
3966
|
+
ap.audio.addEventListener('timeupdate', () => {
|
|
3967
|
+
const { duration, currentTime } = ap.audio;
|
|
3968
|
+
if (!Number.isFinite(duration) || duration <= 0) return;
|
|
3969
|
+
ap.barEl.style.width = `${Math.min(100, (currentTime / duration) * 100)}%`;
|
|
3970
|
+
});
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
/** Encola un audio. La promesa se resuelve al terminar (o al detenerlo). */
|
|
3974
|
+
function enqueueSpeech({ texto, voice, rate, label = 'audio', maxAgeMs = 0 } = {}) {
|
|
3975
|
+
const frase = String(texto ?? '').trim();
|
|
3976
|
+
if (!frase) return Promise.resolve();
|
|
3977
|
+
ensurePlayer();
|
|
3904
3978
|
return new Promise((resolve, reject) => {
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3979
|
+
ap.queue.push({ texto: frase, voice, rate, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
|
|
3980
|
+
if (ap.current) renderAudio();
|
|
3981
|
+
else playNextAudio();
|
|
3982
|
+
});
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
function playNextAudio() {
|
|
3986
|
+
const item = ap.queue.shift();
|
|
3987
|
+
if (!item) {
|
|
3988
|
+
ap.current = null;
|
|
3989
|
+
renderAudio();
|
|
3990
|
+
return;
|
|
3991
|
+
}
|
|
3992
|
+
if (item.maxAgeMs && Date.now() - item.createdAt > item.maxAgeMs) {
|
|
3993
|
+
item.resolve?.();
|
|
3994
|
+
return playNextAudio();
|
|
3995
|
+
}
|
|
3996
|
+
ap.current = item;
|
|
3997
|
+
const params = new URLSearchParams({
|
|
3998
|
+
texto: item.texto,
|
|
3999
|
+
voice: item.voice ?? voz.settings.voice,
|
|
4000
|
+
rate: item.rate ?? voz.settings.rate,
|
|
3914
4001
|
});
|
|
4002
|
+
ap.audio.src = `/api/tts?${params}`;
|
|
4003
|
+
ap.audio.play().catch(() => {
|
|
4004
|
+
advanceAudio(item, new Error('el navegador bloqueó el audio: tocá la página y probá de nuevo'));
|
|
4005
|
+
});
|
|
4006
|
+
renderAudio();
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
function advanceAudio(item, error) {
|
|
4010
|
+
if (!item || ap.current !== item) return;
|
|
4011
|
+
ap.current = null;
|
|
4012
|
+
if (error) item.reject?.(error);
|
|
4013
|
+
else item.resolve?.();
|
|
4014
|
+
playNextAudio();
|
|
4015
|
+
}
|
|
4016
|
+
|
|
4017
|
+
function toggleAudio() {
|
|
4018
|
+
if (!ap.current) return;
|
|
4019
|
+
if (ap.audio.paused) ap.audio.play().catch(() => {});
|
|
4020
|
+
else ap.audio.pause();
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
/** Detiene lo que suena y vacía lo que espera. */
|
|
4024
|
+
function stopAudio() {
|
|
4025
|
+
for (const item of ap.queue) item.resolve?.();
|
|
4026
|
+
ap.queue = [];
|
|
4027
|
+
const current = ap.current;
|
|
4028
|
+
ap.current = null;
|
|
4029
|
+
current?.resolve?.();
|
|
4030
|
+
ap.audio.pause();
|
|
4031
|
+
ap.audio.removeAttribute('src');
|
|
4032
|
+
ap.audio.load();
|
|
4033
|
+
if (ap.barEl) ap.barEl.style.width = '0%';
|
|
4034
|
+
renderAudio();
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
function renderAudio() {
|
|
4038
|
+
if (!ap.el || !ap.ready) return;
|
|
4039
|
+
const active = Boolean(ap.current) || ap.queue.length > 0;
|
|
4040
|
+
ap.el.hidden = !active;
|
|
4041
|
+
if (!active) return;
|
|
4042
|
+
ap.labelEl.textContent = ap.current?.label ?? 'audio';
|
|
4043
|
+
ap.queueEl.hidden = ap.queue.length === 0;
|
|
4044
|
+
ap.queueEl.textContent = ap.queue.length ? `+${ap.queue.length}` : '';
|
|
4045
|
+
const playing = Boolean(ap.current) && !ap.audio.paused;
|
|
4046
|
+
ap.iconEl.replaceChildren(icon(playing ? 'pause' : 'play', 14));
|
|
4047
|
+
ap.toggle.title = playing ? 'pausar' : 'reproducir';
|
|
4048
|
+
ap.toggle.setAttribute('aria-label', ap.toggle.title);
|
|
4049
|
+
}
|
|
4050
|
+
|
|
4051
|
+
/** Reproduce una frase contra el endpoint de sintesis del server. */
|
|
4052
|
+
function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
|
|
4053
|
+
return enqueueSpeech({ texto: frase, voice, rate, label, maxAgeMs });
|
|
3915
4054
|
}
|
|
3916
4055
|
|
|
3917
4056
|
/**
|
|
3918
|
-
*
|
|
3919
|
-
*
|
|
4057
|
+
* Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
|
|
4058
|
+
* cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
|
|
3920
4059
|
*/
|
|
3921
4060
|
function hablar(texto) {
|
|
3922
4061
|
const frase = String(texto ?? '').trim();
|
|
3923
4062
|
if (!frase || !voz.settings.enabled) return;
|
|
3924
|
-
|
|
3925
|
-
voz.cola = voz.cola
|
|
3926
|
-
.then(() => (Date.now() - creado > 45000 ? null : reproducirVoz(frase)))
|
|
3927
|
-
.catch(() => {});
|
|
4063
|
+
reproducirVoz(frase, { label: 'narrando', maxAgeMs: 45000 }).catch(() => {});
|
|
3928
4064
|
}
|
|
3929
4065
|
|
|
3930
4066
|
/** 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.3</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">
|
|
@@ -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,73 @@ 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
|
+
|
|
631
|
+
/* Reproductor único de audio (narración, speak, resumen): visible y detenible. */
|
|
632
|
+
.audio-player {
|
|
633
|
+
display: flex;
|
|
634
|
+
align-items: center;
|
|
635
|
+
gap: 10px;
|
|
636
|
+
padding: 7px 10px;
|
|
637
|
+
margin-bottom: 6px;
|
|
638
|
+
border: 1px solid var(--border);
|
|
639
|
+
border-radius: 10px;
|
|
640
|
+
background: var(--solid, var(--panel));
|
|
641
|
+
box-shadow: 0 6px 18px rgb(0 0 0 / 0.12);
|
|
608
642
|
}
|
|
643
|
+
.audio-player[hidden] { display: none; }
|
|
644
|
+
.audio-player .ap-btn {
|
|
645
|
+
flex: 0 0 auto;
|
|
646
|
+
display: inline-grid;
|
|
647
|
+
place-items: center;
|
|
648
|
+
width: 28px;
|
|
649
|
+
height: 28px;
|
|
650
|
+
padding: 0;
|
|
651
|
+
border: 1px solid var(--border);
|
|
652
|
+
border-radius: 999px;
|
|
653
|
+
background: transparent;
|
|
654
|
+
color: var(--accent);
|
|
655
|
+
cursor: pointer;
|
|
656
|
+
}
|
|
657
|
+
.audio-player .ap-btn:hover { background: var(--theirs); }
|
|
658
|
+
.audio-player .ap-stop { color: var(--danger); }
|
|
659
|
+
.audio-player .ap-info { flex: 1 1 auto; min-width: 0; }
|
|
660
|
+
.audio-player .ap-label {
|
|
661
|
+
display: block;
|
|
662
|
+
color: var(--muted);
|
|
663
|
+
font: 600 11px var(--sans);
|
|
664
|
+
text-transform: uppercase;
|
|
665
|
+
letter-spacing: 0.04em;
|
|
666
|
+
margin-bottom: 4px;
|
|
667
|
+
}
|
|
668
|
+
.audio-player .ap-progress {
|
|
669
|
+
height: 4px;
|
|
670
|
+
border-radius: 999px;
|
|
671
|
+
background: var(--border);
|
|
672
|
+
overflow: hidden;
|
|
673
|
+
}
|
|
674
|
+
.audio-player .ap-bar {
|
|
675
|
+
width: 0%;
|
|
676
|
+
height: 100%;
|
|
677
|
+
border-radius: 999px;
|
|
678
|
+
background: var(--accent);
|
|
679
|
+
transition: width 0.15s linear;
|
|
680
|
+
}
|
|
681
|
+
.audio-player .ap-queue { flex: 0 0 auto; color: var(--muted); font: 600 11px var(--sans); }
|
|
682
|
+
.audio-player .ap-queue[hidden] { display: none; }
|
|
609
683
|
.tool-more {
|
|
610
684
|
display: inline-block;
|
|
611
685
|
margin: 2px 0 2px 18px;
|
|
@@ -946,7 +1020,7 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
|
|
|
946
1020
|
left: 14px;
|
|
947
1021
|
right: 14px;
|
|
948
1022
|
bottom: calc(100% - 6px);
|
|
949
|
-
max-height:
|
|
1023
|
+
max-height: min(60vh, 420px);
|
|
950
1024
|
overflow-y: auto;
|
|
951
1025
|
display: flex;
|
|
952
1026
|
flex-direction: column;
|
|
@@ -954,8 +1028,9 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
|
|
|
954
1028
|
padding: 6px;
|
|
955
1029
|
border: 1px solid var(--border);
|
|
956
1030
|
border-radius: 12px;
|
|
957
|
-
background: var(--panel);
|
|
1031
|
+
background: var(--solid, var(--panel));
|
|
958
1032
|
box-shadow: 0 12px 32px rgb(0 0 0 / 0.22);
|
|
1033
|
+
backdrop-filter: blur(18px);
|
|
959
1034
|
z-index: 30;
|
|
960
1035
|
}
|
|
961
1036
|
.slash-menu[hidden] { display: none; }
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
resolveProviderFor,
|
|
15
15
|
toolCallPayload,
|
|
16
16
|
} from '../agentRun.js';
|
|
17
|
-
import { fraseFin } from '../announcer.js';
|
|
18
17
|
import { mp3AOgg } from '../ffmpeg.js';
|
|
19
18
|
import { resolveTts } from '../ttsSettings.js';
|
|
20
19
|
import { transcribeAudio } from '../transcribe.js';
|
|
@@ -195,13 +194,6 @@ export function startTelegramChannel(
|
|
|
195
194
|
}
|
|
196
195
|
}
|
|
197
196
|
|
|
198
|
-
/** Resumen hablado del cierre del turno, según los ajustes de voz. */
|
|
199
|
-
async function sendResumenDeVoz(chatId, texto) {
|
|
200
|
-
const tts = resolveTts(ctx.app);
|
|
201
|
-
if (!tts.enabled || tts.announce.end === false) return;
|
|
202
|
-
await enviarNotaDeVoz(chatId, texto);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
197
|
// --- proyectos ------------------------------------------------------------
|
|
206
198
|
|
|
207
199
|
/** Carpeta raíz para elegir proyecto: la predeterminada de la consola o el cwd. */
|
|
@@ -458,7 +450,6 @@ export function startTelegramChannel(
|
|
|
458
450
|
}
|
|
459
451
|
const footer = completionFooter(result.stopReason, result.rounds, toolCalls, durationMs);
|
|
460
452
|
await edit(chatId, message.message_id, `${body}\n\n— ${footer}`);
|
|
461
|
-
await sendResumenDeVoz(chatId, fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }));
|
|
462
453
|
ctx.store.addMessages(sessionId, newMessages);
|
|
463
454
|
ctx.store.touchSession(sessionId, { provider: providerId, model });
|
|
464
455
|
} catch (error) {
|
|
@@ -784,18 +775,7 @@ export function startTelegramChannel(
|
|
|
784
775
|
|
|
785
776
|
if (event.type === 'done' && notifyConfig.done) {
|
|
786
777
|
const text = `[${project}] ✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`;
|
|
787
|
-
const
|
|
788
|
-
event.data?.say ??
|
|
789
|
-
fraseFin({
|
|
790
|
-
stopReason: event.data?.stopReason,
|
|
791
|
-
rounds: event.data?.rounds,
|
|
792
|
-
toolCalls: event.data?.toolCalls,
|
|
793
|
-
durationMs: event.data?.durationMs,
|
|
794
|
-
});
|
|
795
|
-
for (const chatId of targets) {
|
|
796
|
-
send(chatId, text).catch(() => {});
|
|
797
|
-
sendResumenDeVoz(chatId, `${project}. ${decir}`);
|
|
798
|
-
}
|
|
778
|
+
for (const chatId of targets) send(chatId, text).catch(() => {});
|
|
799
779
|
} else if (event.type === 'error' && notifyConfig.error) {
|
|
800
780
|
const text = `[${project}] ✗ Error en la sesión: ${event.data?.message ?? ''}`;
|
|
801
781
|
for (const chatId of targets) send(chatId, text).catch(() => {});
|
|
@@ -6,6 +6,49 @@ import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView
|
|
|
6
6
|
const COMPACT_INSTRUCTION =
|
|
7
7
|
'Resumí la conversación en un texto conciso que preserve decisiones tomadas, archivos tocados, hallazgos y el estado actual, para poder continuar el trabajo sin el historial completo.';
|
|
8
8
|
|
|
9
|
+
const SUMMARY_INSTRUCTION =
|
|
10
|
+
'Resumí la conversación para la persona: qué se pidió, qué se hizo, decisiones tomadas, archivos o comandos relevantes y el estado actual. Sé claro y conciso, en español, sin markdown pesado.';
|
|
11
|
+
|
|
12
|
+
function sessionTranscript(messages) {
|
|
13
|
+
return messages
|
|
14
|
+
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
|
|
15
|
+
.join('\n');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Corre un resumen no destructivo de la sesión (no toca el historial).
|
|
20
|
+
* Devuelve `{ summary }` o `{ error, status }`.
|
|
21
|
+
*/
|
|
22
|
+
async function summarizeSession(ctx, session, instruction) {
|
|
23
|
+
const messages = ctx.store.getMessages(session.id);
|
|
24
|
+
if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
|
|
25
|
+
const providerId = session.provider || ctx.effectiveProviderId();
|
|
26
|
+
const model = session.model || ctx.effectiveModel();
|
|
27
|
+
if (!model) return { error: 'Falta el modelo para resumir.', status: 400 };
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const { provider, config } = ctx.resolveProvider({
|
|
31
|
+
...ctx.providerSettings(providerId),
|
|
32
|
+
apiKey: ctx.apiKeyFor(providerId),
|
|
33
|
+
});
|
|
34
|
+
const result = await runAgent({
|
|
35
|
+
provider,
|
|
36
|
+
config,
|
|
37
|
+
model,
|
|
38
|
+
messages: [
|
|
39
|
+
{ role: 'system', content: instruction },
|
|
40
|
+
{ role: 'user', content: sessionTranscript(messages).slice(0, 60000) },
|
|
41
|
+
],
|
|
42
|
+
tools: [],
|
|
43
|
+
});
|
|
44
|
+
const summary = result.content?.trim();
|
|
45
|
+
if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
|
|
46
|
+
return { summary };
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { error: `No se pudo resumir: ${error?.message ?? error}`, status: 502 };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
9
52
|
export function registerSessions(route, ctx) {
|
|
10
53
|
route('GET', '/api/sessions', ({ res }) => {
|
|
11
54
|
const awaiting = awaitingBySession(ctx.approvals.list());
|
|
@@ -123,40 +166,19 @@ export function registerSessions(route, ctx) {
|
|
|
123
166
|
route('POST', '/api/sessions/:id/compact', async ({ res, params }) => {
|
|
124
167
|
const session = ctx.store.getSession(params.id);
|
|
125
168
|
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const transcript = messages
|
|
134
|
-
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
|
|
135
|
-
.join('\n');
|
|
169
|
+
const out = await summarizeSession(ctx, session, COMPACT_INSTRUCTION);
|
|
170
|
+
if (out.error) return sendError(res, out.status, out.error);
|
|
171
|
+
ctx.store.clearMessages(params.id);
|
|
172
|
+
ctx.store.touchSession(params.id, { meta: { summary: out.summary } });
|
|
173
|
+
sendJson(res, 200, { summary: out.summary, messages: 0 });
|
|
174
|
+
});
|
|
136
175
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
provider,
|
|
144
|
-
config,
|
|
145
|
-
model,
|
|
146
|
-
messages: [
|
|
147
|
-
{ role: 'system', content: COMPACT_INSTRUCTION },
|
|
148
|
-
{ role: 'user', content: transcript.slice(0, 60000) },
|
|
149
|
-
],
|
|
150
|
-
tools: [],
|
|
151
|
-
});
|
|
152
|
-
const summary = result.content?.trim();
|
|
153
|
-
if (!summary) return sendError(res, 502, 'El modelo no devolvió un resumen.');
|
|
154
|
-
ctx.store.clearMessages(params.id);
|
|
155
|
-
ctx.store.touchSession(params.id, { meta: { summary } });
|
|
156
|
-
sendJson(res, 200, { summary, messages: 0 });
|
|
157
|
-
} catch (error) {
|
|
158
|
-
sendError(res, 502, `No se pudo compactar: ${error?.message ?? error}`);
|
|
159
|
-
}
|
|
176
|
+
route('POST', '/api/sessions/:id/summary', async ({ res, params }) => {
|
|
177
|
+
const session = ctx.store.getSession(params.id);
|
|
178
|
+
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
179
|
+
const out = await summarizeSession(ctx, session, SUMMARY_INSTRUCTION);
|
|
180
|
+
if (out.error) return sendError(res, out.status, out.error);
|
|
181
|
+
sendJson(res, 200, { summary: out.summary });
|
|
160
182
|
});
|
|
161
183
|
|
|
162
184
|
route('DELETE', '/api/sessions/:id', ({ res, params }) => {
|
|
@@ -1719,6 +1719,40 @@ test('compact summarizes the session and clears its messages', async () => {
|
|
|
1719
1719
|
}
|
|
1720
1720
|
});
|
|
1721
1721
|
|
|
1722
|
+
test('the summary endpoint returns a summary without clearing the messages', async () => {
|
|
1723
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-summary-'));
|
|
1724
|
+
const provider = {
|
|
1725
|
+
id: 'sum',
|
|
1726
|
+
async streamChat(_config, opts) {
|
|
1727
|
+
const system = opts.messages.find((m) => m.role === 'system')?.content ?? '';
|
|
1728
|
+
return { content: system.includes('Resumí la conversación') ? 'RESUMEN FINAL' : 'hola', reasoning: '', toolCalls: [] };
|
|
1729
|
+
},
|
|
1730
|
+
};
|
|
1731
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
1732
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1733
|
+
try {
|
|
1734
|
+
const created = await (
|
|
1735
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'S' }) })
|
|
1736
|
+
).json();
|
|
1737
|
+
const id = created.session.id;
|
|
1738
|
+
await (await fetch(`${base}/api/chat`, {
|
|
1739
|
+
method: 'POST',
|
|
1740
|
+
headers: json,
|
|
1741
|
+
body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
|
|
1742
|
+
})).text();
|
|
1743
|
+
|
|
1744
|
+
const summary = await (
|
|
1745
|
+
await fetch(`${base}/api/sessions/${id}/summary`, { method: 'POST', headers: json })
|
|
1746
|
+
).json();
|
|
1747
|
+
assert.equal(summary.summary, 'RESUMEN FINAL');
|
|
1748
|
+
|
|
1749
|
+
const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
1750
|
+
assert.ok(detail.messages.length > 0, 'no borra el historial');
|
|
1751
|
+
} finally {
|
|
1752
|
+
await instance.close();
|
|
1753
|
+
}
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1722
1756
|
test('exposes modes and persists the active mode', async () => {
|
|
1723
1757
|
const { instance, base } = await startServer();
|
|
1724
1758
|
const json = { 'Content-Type': 'application/json' };
|
|
@@ -107,7 +107,7 @@ function preselect(ctx, cwd, chatId = '7') {
|
|
|
107
107
|
return sessionId;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
test('
|
|
110
|
+
test('el cierre del turno de Telegram no manda audio aunque la voz esté activada', async () => {
|
|
111
111
|
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgvoz-'));
|
|
112
112
|
const ctx = makeCtx(cwd, { tts: { enabled: true, voice: 'es-AR-ElenaNeural', rate: '+0%', announce: { end: true } } });
|
|
113
113
|
preselect(ctx, cwd);
|
|
@@ -117,10 +117,8 @@ test('con la voz activada, el turno de Telegram termina con una nota de voz', as
|
|
|
117
117
|
const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
|
|
118
118
|
try {
|
|
119
119
|
mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
|
|
120
|
-
await waitFor(() => mock.
|
|
121
|
-
|
|
122
|
-
assert.equal(nota.chatId, '7');
|
|
123
|
-
assert.match(nota.method, /^send(Voice|Audio)$/);
|
|
120
|
+
await waitFor(() => mock.sent.some((m) => /✓ terminado/.test(m.text ?? '')));
|
|
121
|
+
assert.equal(mock.multipart.length, 0);
|
|
124
122
|
} finally {
|
|
125
123
|
global.WebSocket = original;
|
|
126
124
|
channel.stop();
|
package/package.json
CHANGED