@danieltmn/tamnora 0.26.0 → 0.27.0

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +28 -10
  3. package/harness/packages/cli/src/commands/server.js +10 -10
  4. package/harness/packages/cli/src/commands/update.js +1 -1
  5. package/harness/packages/core/src/agent/modes.js +2 -0
  6. package/harness/packages/core/src/index.js +4 -0
  7. package/harness/packages/core/src/mcp/registry.js +43 -2
  8. package/harness/packages/core/src/providers/capabilities.js +67 -0
  9. package/harness/packages/core/src/providers/index.js +25 -7
  10. package/harness/packages/core/src/providers/ollama.js +3 -2
  11. package/harness/packages/core/src/providers/utils.js +16 -6
  12. package/harness/packages/core/src/skills/remote.js +45 -12
  13. package/harness/packages/core/src/tools/executors.js +84 -0
  14. package/harness/packages/core/src/tools/labels.js +7 -0
  15. package/harness/packages/core/src/tools/permissions.js +3 -0
  16. package/harness/packages/core/src/tools/registry.js +3 -2
  17. package/harness/packages/core/src/tools/schema.js +53 -0
  18. package/harness/packages/core/test/capabilities.test.js +51 -0
  19. package/harness/packages/core/test/mcp-registry.test.js +63 -1
  20. package/harness/packages/core/test/providers.test.js +53 -1
  21. package/harness/packages/core/test/skills.test.js +66 -1
  22. package/harness/packages/core/test/tools.test.js +63 -0
  23. package/harness/packages/server/public/app.js +2 -0
  24. package/harness/packages/server/public/cards.js +22 -6
  25. package/harness/packages/server/public/chart.js +237 -0
  26. package/harness/packages/server/public/chat.js +7 -1
  27. package/harness/packages/server/public/chatFormat.js +15 -4
  28. package/harness/packages/server/public/chrome.js +10 -4
  29. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  30. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  31. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  32. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  33. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  34. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  35. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  36. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  37. package/harness/packages/server/public/icons/logo.png +0 -0
  38. package/harness/packages/server/public/icons/logo_light.png +0 -0
  39. package/harness/packages/server/public/index.html +9 -7
  40. package/harness/packages/server/public/logo.png +0 -0
  41. package/harness/packages/server/public/markdown.js +22 -0
  42. package/harness/packages/server/public/models.js +38 -7
  43. package/harness/packages/server/public/modelsQuery.js +44 -4
  44. package/harness/packages/server/public/settings.js +39 -1
  45. package/harness/packages/server/public/styles.css +33 -0
  46. package/harness/packages/server/src/lib/channels/telegram/media.js +11 -8
  47. package/harness/packages/server/src/lib/channels/telegram/run.js +5 -0
  48. package/harness/packages/server/src/lib/imagegen.js +201 -0
  49. package/harness/packages/server/src/lib/scheduler.js +2 -0
  50. package/harness/packages/server/src/lib/securityHeaders.js +1 -1
  51. package/harness/packages/server/src/lib/tasks.js +2 -0
  52. package/harness/packages/server/src/lib/transcribe.js +3 -1
  53. package/harness/packages/server/src/lib/ttsEngine.js +69 -0
  54. package/harness/packages/server/src/routes/chat.js +2 -0
  55. package/harness/packages/server/src/routes/files.js +8 -3
  56. package/harness/packages/server/src/routes/imagegen.js +35 -0
  57. package/harness/packages/server/src/routes/models.js +2 -1
  58. package/harness/packages/server/src/routes/tts.js +4 -2
  59. package/harness/packages/server/src/server.js +2 -0
  60. package/harness/packages/server/test/chart.test.js +70 -0
  61. package/harness/packages/server/test/chat.test.js +1 -0
  62. package/harness/packages/server/test/chatFormat.test.js +9 -5
  63. package/harness/packages/server/test/imagegen.test.js +215 -0
  64. package/harness/packages/server/test/markdown.test.js +20 -0
  65. package/harness/packages/server/test/markdownSecurity.test.js +25 -0
  66. package/harness/packages/server/test/modelsQuery.test.js +25 -1
  67. package/harness/packages/server/test/securityHeaders.test.js +1 -0
  68. package/harness/packages/server/test/ttsEngine.test.js +58 -0
  69. package/harness/scripts/self-update.mjs +1 -1
  70. package/package.json +1 -1
  71. package/harness/packages/server/public/icons (1).zip +0 -0
@@ -50,6 +50,7 @@ import { registerChannels } from './routes/channels.js';
50
50
  import { registerProfile } from './routes/profile.js';
51
51
  import { registerTts } from './routes/tts.js';
52
52
  import { registerTranscribe } from './routes/transcribe.js';
53
+ import { registerImageGen } from './routes/imagegen.js';
53
54
  import { registerUpdate } from './routes/update.js';
54
55
  import { registerReload } from './routes/reload.js';
55
56
  import { registerProcs } from './routes/procs.js';
@@ -336,6 +337,7 @@ export async function createTamnoraServer({
336
337
  registerProfile(route, ctx);
337
338
  registerTts(route, ctx);
338
339
  registerTranscribe(route, ctx);
340
+ registerImageGen(route, ctx);
339
341
  registerUpdate(route, ctx);
340
342
  registerReload(route, ctx);
341
343
  registerProcs(route, ctx);
@@ -0,0 +1,70 @@
1
+ // Graficos SVG de la tool `show_chart`: estructura, datos y escape de markup.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { normalizeChart, renderChart } from '../public/chart.js';
5
+
6
+ const SALES = {
7
+ title: 'Ventas',
8
+ type: 'bar',
9
+ unit: 'USD',
10
+ labels: ['Ene', 'Feb', 'Mar'],
11
+ series: [
12
+ { name: '2024', values: [10, 22, 18] },
13
+ { name: '2025', values: [14, 20, 30] },
14
+ ],
15
+ };
16
+
17
+ test('normalizeChart alinea labels y series y descarta lo invalido', () => {
18
+ const spec = normalizeChart(SALES);
19
+ assert.equal(spec.labels.length, 3);
20
+ assert.equal(spec.series.length, 2);
21
+ assert.deepEqual(spec.series[0].values, [10, 22, 18]);
22
+
23
+ const padded = normalizeChart({ labels: ['a', 'b'], series: [{ values: [1] }] });
24
+ assert.deepEqual(padded.series[0].values, [1, 0]);
25
+
26
+ assert.equal(normalizeChart({ labels: [], series: [{ values: [1] }] }), null);
27
+ assert.equal(normalizeChart({ labels: ['a'], series: [] }), null);
28
+ assert.equal(normalizeChart({ type: 'pie', labels: ['a'], series: [{ values: [-1] }] }), null);
29
+ assert.equal(normalizeChart({ labels: ['a'], series: [{ values: ['x'] }] }).series[0].values[0], 0);
30
+ });
31
+
32
+ test('renderChart dibuja barras, lineas y torta como SVG', () => {
33
+ const bar = renderChart(SALES);
34
+ assert.match(bar, /^<svg class="chart"/);
35
+ assert.match(bar, /<rect /); // barras
36
+ assert.match(bar, /<line /); // ejes
37
+ assert.match(bar, /viewBox="0 0 720 420"/);
38
+ assert.match(bar, /aria-label="Ventas"/);
39
+
40
+ const line = renderChart({ ...SALES, type: 'line' });
41
+ assert.match(line, /<polyline /);
42
+ assert.match(line, /<circle /);
43
+
44
+ const pie = renderChart({ type: 'pie', labels: ['a', 'b'], series: [{ values: [1, 3] }] });
45
+ assert.match(pie, /<path /);
46
+ assert.match(pie, /\(25%\)/);
47
+ assert.match(pie, /\(75%\)/);
48
+ });
49
+
50
+ test('renderChart escapa todo lo que viene del modelo', () => {
51
+ const svg = renderChart({
52
+ title: '<script>alert(1)</script>',
53
+ labels: ['"><img src=x onerror=alert(1)>', 'b'],
54
+ series: [{ name: '<b>x</b>', values: [1, 2] }],
55
+ });
56
+ assert.ok(!svg.includes('<script'), svg);
57
+ assert.ok(!svg.includes('<img'), svg);
58
+ assert.ok(!svg.includes('<b>'), svg);
59
+ assert.match(svg, /&lt;script&gt;/);
60
+
61
+ assert.equal(renderChart({ labels: [], series: [] }), '');
62
+ assert.equal(renderChart(null), '');
63
+ });
64
+
65
+ test('renderChart no se rompe con extremos numericos', () => {
66
+ const svg = renderChart({ labels: ['a', 'b', 'c'], series: [{ values: [-5, 0, 12.5] }] });
67
+ assert.match(svg, /<rect /);
68
+ const pie = renderChart({ type: 'pie', labels: ['único'], series: [{ values: [1] }] });
69
+ assert.match(pie, /<circle /); // 100% es un circulo, no un arco degenerado
70
+ });
@@ -481,6 +481,7 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
481
481
  'screenshot',
482
482
  'search_mcp',
483
483
  'search_skills',
484
+ 'show_chart',
484
485
  'show_plan',
485
486
  'skill',
486
487
  'speak',
@@ -3,11 +3,11 @@ import assert from 'node:assert/strict';
3
3
  import {
4
4
  basename,
5
5
  formatDuration,
6
+ imageName,
6
7
  isFailedToolText,
7
8
  isReadOnlyTool,
8
9
  parseToolArgs,
9
10
  previewText,
10
- screenshotName,
11
11
  shortText,
12
12
  toolSummary,
13
13
  userImages,
@@ -40,6 +40,8 @@ test('toolSummary resume cada tool en una linea corta', () => {
40
40
  assert.equal(toolSummary('grep', { pattern: 'foo', path: 'src' }), '"foo" en src');
41
41
  assert.equal(toolSummary('git_diff', { staged: true, path: 'a.js' }), 'staged a.js');
42
42
  assert.equal(toolSummary('git_log', { limit: 5 }), '5 commits');
43
+ assert.equal(toolSummary('show_chart', { type: 'pie', labels: ['a', 'b'], title: 'Ventas' }), 'Ventas · torta · 2 datos');
44
+ assert.equal(toolSummary('generate_image', { prompt: 'un gato astronauta' }), 'un gato astronauta');
43
45
  assert.equal(toolSummary('desconocida', {}), '');
44
46
  });
45
47
 
@@ -57,10 +59,12 @@ test('isFailedToolText detecta errores y permisos denegados', () => {
57
59
  assert.ok(!isFailedToolText(undefined));
58
60
  });
59
61
 
60
- test('screenshotName sale del meta o del texto del resultado', () => {
61
- assert.equal(screenshotName({ meta: { screenshot: { name: 'a.png' } } }, ''), 'a.png');
62
- assert.equal(screenshotName({}, 'guardado en capturas/abc-123.png'), 'abc-123.png');
63
- assert.equal(screenshotName({}, 'sin captura'), null);
62
+ test('imageName sale del meta (captura o imagen generada) o del texto del resultado', () => {
63
+ assert.equal(imageName({ meta: { screenshot: { name: 'a.png' } } }, ''), 'a.png');
64
+ assert.equal(imageName({ meta: { image: { name: 'gen-1-abc.jpg' } } }, ''), 'gen-1-abc.jpg');
65
+ assert.equal(imageName({}, 'guardado en capturas/abc-123.png'), 'abc-123.png');
66
+ assert.equal(imageName({}, 'Imagen generada → capturas/gen-9-x.webp (10 KB)'), 'gen-9-x.webp');
67
+ assert.equal(imageName({}, 'sin captura'), null);
64
68
  });
65
69
 
66
70
  test('previewText trunca por lineas y por caracteres', () => {
@@ -0,0 +1,215 @@
1
+ // Generacion de imagenes: el mismo modelo del chat primero (chat con salida de
2
+ // imagen o endpoint /images/generations), imageGen como fallback. Todo contra un
3
+ // proveedor stub de node:http: no se llama a ningun servicio real.
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import http from 'node:http';
7
+ import { mkdtemp, readFile, readdir } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
9
+ import path from 'node:path';
10
+ import { setHome, screenshotsDir } from '../../core/src/index.js';
11
+ import { generateImage } from '../src/lib/imagegen.js';
12
+
13
+ // 1x1 PNG real (magic bytes que `imageInfo` reconoce).
14
+ const PNG = Buffer.from(
15
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
16
+ 'base64'
17
+ );
18
+
19
+ function stubProvider(handler) {
20
+ const calls = [];
21
+ const server = http.createServer((req, res) => {
22
+ let body = '';
23
+ req.on('data', (chunk) => (body += chunk));
24
+ req.on('end', () => {
25
+ calls.push({ url: req.url, body: body ? JSON.parse(body) : null });
26
+ handler(req.url, body, res);
27
+ });
28
+ });
29
+ return new Promise((resolve) => {
30
+ server.listen(0, '127.0.0.1', () => {
31
+ resolve({ calls, base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() });
32
+ });
33
+ });
34
+ }
35
+
36
+ function fakeCtx({ baseUrl, model = '', app = {}, providerId = 'stub' }) {
37
+ return {
38
+ app,
39
+ effectiveModel: () => model,
40
+ effectiveProviderId: () => providerId,
41
+ providerSettings: () => ({ baseUrl, apiKey: 'k' }),
42
+ apiKeyFor: () => 'k',
43
+ resolveProvider: () => ({ config: {} }),
44
+ };
45
+ }
46
+
47
+ const home = await mkdtemp(path.join(tmpdir(), 'tamnora-img-'));
48
+ setHome(home);
49
+
50
+ test('el modelo del chat con salida de imagen genera por chat', async () => {
51
+ const stub = await stubProvider((url, _body, res) => {
52
+ assert.equal(url, '/chat/completions');
53
+ res.end(
54
+ JSON.stringify({
55
+ choices: [
56
+ { message: { content: 'listo', images: [{ type: 'image_url', image_url: { url: `data:image/png;base64,${PNG.toString('base64')}` } }] } },
57
+ ],
58
+ })
59
+ );
60
+ });
61
+ try {
62
+ const out = await generateImage(fakeCtx({ baseUrl: stub.base, model: 'gemini-2.5-flash-image-preview' }), {
63
+ prompt: 'un gato',
64
+ });
65
+ assert.match(out.name, /^gen-.*\.png$/);
66
+ assert.equal(out.model, 'gemini-2.5-flash-image-preview');
67
+ assert.equal(stub.calls.length, 1);
68
+ assert.deepEqual(stub.calls[0].body.modalities, ['text', 'image']);
69
+ const saved = await readFile(path.join(screenshotsDir(), out.name));
70
+ assert.deepEqual(saved, PNG);
71
+ } finally {
72
+ stub.close();
73
+ }
74
+ });
75
+
76
+ test('una familia tipo dall-e usa /images/generations con el modelo del chat', async () => {
77
+ const stub = await stubProvider((url, _body, res) => {
78
+ assert.equal(url, '/images/generations');
79
+ res.end(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }));
80
+ });
81
+ try {
82
+ const out = await generateImage(fakeCtx({ baseUrl: stub.base, model: 'gpt-image-1' }), { prompt: 'un perro', size: '512x512' });
83
+ assert.match(out.name, /\.png$/);
84
+ assert.equal(stub.calls[0].body.model, 'gpt-image-1');
85
+ assert.equal(stub.calls[0].body.size, '512x512');
86
+ } finally {
87
+ stub.close();
88
+ }
89
+ });
90
+
91
+ test('sin capacidad en el modelo del chat cae al ajuste imageGen', async () => {
92
+ const stub = await stubProvider((url, _body, res) => {
93
+ assert.equal(url, '/images/generations');
94
+ res.end(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }));
95
+ });
96
+ try {
97
+ const ctx = fakeCtx({
98
+ baseUrl: stub.base,
99
+ model: 'deepseek-chat',
100
+ app: { imageGen: { model: 'dall-e-3' } },
101
+ });
102
+ const out = await generateImage(ctx, { prompt: 'una casa' });
103
+ assert.match(out.name, /\.png$/);
104
+ assert.equal(stub.calls[0].body.model, 'dall-e-3');
105
+ } finally {
106
+ stub.close();
107
+ }
108
+ });
109
+
110
+ test('el modelo explicito de la tool manda y los errores se propagan', async () => {
111
+ const stub = await stubProvider((url, _body, res) => {
112
+ res.statusCode = 402;
113
+ res.end(JSON.stringify({ error: { message: 'sin creditos' } }));
114
+ });
115
+ try {
116
+ await assert.rejects(
117
+ () => generateImage(fakeCtx({ baseUrl: stub.base, model: 'gemini-2.5-flash-image-preview' }), { prompt: 'x', model: 'dall-e-3' }),
118
+ /402.*sin creditos/
119
+ );
120
+ assert.equal(stub.calls[0].url, '/images/generations');
121
+ } finally {
122
+ stub.close();
123
+ }
124
+
125
+ // Un chat sin parte de imagen no es un "si" silencioso.
126
+ const stub2 = await stubProvider((url, _body, res) => {
127
+ res.end(JSON.stringify({ choices: [{ message: { content: 'no puedo' } }] }));
128
+ });
129
+ try {
130
+ await assert.rejects(
131
+ () => generateImage(fakeCtx({ baseUrl: stub2.base, model: 'gemini-2.5-flash-image-preview' }), { prompt: 'x' }),
132
+ /no devolvió una imagen/
133
+ );
134
+ } finally {
135
+ stub2.close();
136
+ }
137
+
138
+ // La imagen generada vive en la casa, no en el workspace.
139
+ const names = await readdir(screenshotsDir());
140
+ assert.ok(names.every((n) => /^gen-/.test(n)), names.join(','));
141
+ });
142
+
143
+ test('un baseUrl con el endpoint completo pegado no arma rutas duplicadas', async () => {
144
+ const stub = await stubProvider((url, _body, res) => {
145
+ res.end(JSON.stringify({ data: [{ b64_json: PNG.toString('base64') }] }));
146
+ });
147
+ try {
148
+ // El usuario pego "https://.../v1/chat/completions" como baseUrl del proveedor.
149
+ const out = await generateImage(fakeCtx({ baseUrl: `${stub.base}/chat/completions`, model: 'gpt-image-1' }), {
150
+ prompt: 'un perro',
151
+ });
152
+ assert.match(out.name, /\.png$/);
153
+ assert.equal(stub.calls[0].url, '/images/generations');
154
+ } finally {
155
+ stub.close();
156
+ }
157
+ });
158
+
159
+ test('imageGen con modelo de salida de imagen genera por chat, no por endpoint', async () => {
160
+ const stub = await stubProvider((url, _body, res) => {
161
+ assert.equal(url, '/chat/completions');
162
+ res.end(
163
+ JSON.stringify({
164
+ choices: [{ message: { content: '', images: [{ type: 'image_url', image_url: { url: `data:image/png;base64,${PNG.toString('base64')}` } }] } }],
165
+ })
166
+ );
167
+ });
168
+ try {
169
+ const ctx = fakeCtx({
170
+ baseUrl: stub.base,
171
+ model: 'deepseek-chat',
172
+ app: { imageGen: { model: 'google/gemini-2.5-flash-image' } },
173
+ });
174
+ const out = await generateImage(ctx, { prompt: 'una casa' });
175
+ assert.match(out.name, /\.png$/);
176
+ assert.equal(stub.calls[0].body.model, 'google/gemini-2.5-flash-image');
177
+ assert.deepEqual(stub.calls[0].body.modalities, ['text', 'image']);
178
+ } finally {
179
+ stub.close();
180
+ }
181
+ });
182
+
183
+ test('sin ajuste imageGen, openrouter cae en su default por chat', async () => {
184
+ const stub = await stubProvider((url, _body, res) => {
185
+ assert.equal(url, '/chat/completions');
186
+ res.end(
187
+ JSON.stringify({
188
+ choices: [{ message: { content: '', images: [{ type: 'image_url', image_url: { url: `data:image/png;base64,${PNG.toString('base64')}` } }] } }],
189
+ })
190
+ );
191
+ });
192
+ try {
193
+ const ctx = fakeCtx({ baseUrl: stub.base, model: 'deepseek-chat', providerId: 'openrouter' });
194
+ const out = await generateImage(ctx, { prompt: 'un gato' });
195
+ assert.match(out.name, /\.png$/);
196
+ assert.equal(stub.calls[0].body.model, 'google/gemini-2.5-flash-image');
197
+ } finally {
198
+ stub.close();
199
+ }
200
+ });
201
+
202
+ test('un error del proveedor muestra la URL intentada', async () => {
203
+ const stub = await stubProvider((url, _body, res) => {
204
+ res.statusCode = 404;
205
+ res.end(JSON.stringify({ error: { message: 'Not Found' } }));
206
+ });
207
+ try {
208
+ await assert.rejects(
209
+ () => generateImage(fakeCtx({ baseUrl: stub.base, model: 'dall-e-3' }), { prompt: 'x' }),
210
+ new RegExp(`404.*en http://127\\.0\\.0\\.1:${stub.base.split(':').pop()}/images/generations`)
211
+ );
212
+ } finally {
213
+ stub.close();
214
+ }
215
+ });
@@ -31,6 +31,26 @@ test('only allows http(s) links', () => {
31
31
  assert.ok(!renderMarkdown('[x](javascript:alert(1))').includes('<a '));
32
32
  });
33
33
 
34
+ test('renders markdown images inline from safe sources', () => {
35
+ const html = renderMarkdown('![un gato](https://a.com/gato.png)');
36
+ assert.match(html, /<img class="md-img" src="https:\/\/a\.com\/gato\.png" alt="un gato" loading="lazy" referrerpolicy="no-referrer">/);
37
+
38
+ assert.match(renderMarkdown('![x](capturas/abc-123.png)'), /src="capturas\/abc-123\.png"/);
39
+ assert.match(renderMarkdown('![x](/api/screenshots/a.png)'), /src="\/api\/screenshots\/a\.png"/);
40
+ assert.match(
41
+ renderMarkdown('![x](data:image/png;base64,iVBORw0KGgo=)'),
42
+ /src="data:image\/png;base64,iVBORw0KGgo="/
43
+ );
44
+
45
+ // El alt con codigo inline se restaura a texto plano (sin markup en el atributo).
46
+ const alt = renderMarkdown('![a `c` b](https://a.com/i.png)');
47
+ assert.match(alt, /alt="a c b"/);
48
+
49
+ // Fuente insegura: queda el texto literal, sin <img>.
50
+ assert.ok(!renderMarkdown('![x](javascript:alert(1))').includes('<img'));
51
+ assert.ok(!renderMarkdown('![x](data:text/html,x))').includes('<img'));
52
+ });
53
+
34
54
  test('renders pipe tables with alignment and stops at the next block', () => {
35
55
  const md = [
36
56
  '| Archivo | Qué es | Centro | Derecha |',
@@ -61,6 +61,31 @@ test('los enlaces solo salen para http(s) y no se pueden salir del atributo href
61
61
  }
62
62
  });
63
63
 
64
+ test('las imagenes markdown no se pueden usar para inyectar atributos ni fuentes peligrosas', () => {
65
+ for (const url of [
66
+ 'javascript:alert(1)',
67
+ 'JaVaScRiPt:alert(1)',
68
+ 'data:text/html,<script>alert(1)</script>',
69
+ 'vbscript:x',
70
+ '//evil.com/x.png',
71
+ 'file:///etc/passwd',
72
+ ]) {
73
+ const html = renderMarkdown(`![x](${url})`);
74
+ assert.ok(!/<img\b/i.test(html), `no deberia haber imagen para ${url}: ${html}`);
75
+ }
76
+
77
+ // La fuente no se puede salir del atributo src.
78
+ const html = renderMarkdown('![x](https://a.com/"onmouseover="alert(1))');
79
+ assert.ok(!/\sonmouseover\s*=/i.test(html), `el atributo se escapo del src: ${html}`);
80
+ const imgs = html.match(/<img\b[^>]*>/g) ?? [];
81
+ assert.equal(imgs.length, 1, html);
82
+ for (const img of imgs) {
83
+ const attrs = [...img.matchAll(/\s([a-z-]+)=/gi)].map((m) => m[1]).sort();
84
+ assert.deepEqual(attrs, ['alt', 'class', 'loading', 'referrerpolicy', 'src'], img);
85
+ assert.ok(!/\son\w+\s*=/i.test(img), img);
86
+ }
87
+ });
88
+
64
89
  test('el nombre del lenguaje del fence no se interpreta como HTML', () => {
65
90
  const html = renderMarkdown('```<img src=x onerror=alert(1)>\ncodigo\n```');
66
91
  assert.ok(!/<img\b/i.test(html), html);
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { matchModel, filterModels, pickVisible } from '../public/modelsQuery.js';
3
+ import { matchModel, filterModels, pickVisible, capabilityTags, applyView } from '../public/modelsQuery.js';
4
4
 
5
5
  const CATALOG = [
6
6
  { id: 'z-ai/glm-5.3', label: 'Z.ai: GLM 5.3' },
@@ -74,3 +74,27 @@ test('pickVisible: catalogo vacio o no-array → []', () => {
74
74
  assert.deepEqual(pickVisible(null, ['a']), []);
75
75
  assert.deepEqual(pickVisible(undefined, ['a']), []);
76
76
  });
77
+
78
+ test('capabilityTags y la busqueda por capacidades', () => {
79
+ const model = { id: 'x', label: 'X', capabilities: { vision: true, image: true, tools: true } };
80
+ assert.deepEqual(capabilityTags(model.capabilities), ['visión', 'imagen', 'tools']);
81
+ assert.deepEqual(capabilityTags(undefined), []);
82
+ assert.equal(matchModel('imagen', model), true);
83
+ assert.equal(matchModel('visión', model), true);
84
+ assert.equal(matchModel('video', model), false);
85
+ assert.equal(matchModel('imagen vision', model), true);
86
+ });
87
+
88
+ test('applyView: todos, gratis y seleccionados', () => {
89
+ const models = [
90
+ { id: 'a', free: true },
91
+ { id: 'b', free: false },
92
+ { id: 'c', free: true },
93
+ ];
94
+ assert.deepEqual(applyView('all', models, ['b']).map((m) => m.id), ['a', 'b', 'c']);
95
+ assert.deepEqual(applyView('free', models, []).map((m) => m.id), ['a', 'c']);
96
+ assert.deepEqual(applyView('selected', models, ['b', 'c']).map((m) => m.id), ['b', 'c']);
97
+ assert.deepEqual(applyView('selected', models, new Set(['a'])).map((m) => m.id), ['a']);
98
+ assert.deepEqual(applyView('selected', models, []), []);
99
+ assert.deepEqual(applyView('all', null), []);
100
+ });
@@ -40,6 +40,7 @@ test('la CSP deja pasar lo que la consola necesita: preview embebido y fuentes',
40
40
  assert.match(csp, /style-src [^;]*https:\/\/fonts\.googleapis\.com/);
41
41
  assert.match(csp, /font-src [^;]*https:\/\/fonts\.gstatic\.com/);
42
42
  assert.match(csp, /img-src [^;]*data: blob:/);
43
+ assert.match(csp, /img-src [^;]*https?:/);
43
44
  assert.match(csp, /media-src [^;]*blob:/);
44
45
  assert.match(csp, /connect-src 'self'(;|$)/);
45
46
  });
@@ -0,0 +1,58 @@
1
+ // Motor de voz: el proveedor del chat si el modelo habla (POST /audio/speech),
2
+ // Edge como fallback. Contra un proveedor stub: no se llama a ningun servicio.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import http from 'node:http';
6
+ import { ttsModelFor, sintetizarVoz } from '../src/lib/ttsEngine.js';
7
+
8
+ function fakeCtx({ model = '', app = {}, base = '' } = {}) {
9
+ return {
10
+ app,
11
+ effectiveModel: () => model,
12
+ effectiveProviderId: () => 'stub',
13
+ providerSettings: () => ({ baseUrl: base, apiKey: 'k' }),
14
+ apiKeyFor: () => 'k',
15
+ resolveProvider: () => ({ config: {} }),
16
+ };
17
+ }
18
+
19
+ test('ttsModelFor: ajuste propio -> modelo del chat si habla -> null (Edge)', () => {
20
+ assert.equal(ttsModelFor(fakeCtx({ model: 'deepseek-chat', app: { tts: { model: 'tts-1' } } })), 'tts-1');
21
+ assert.equal(ttsModelFor(fakeCtx({ model: 'gpt-4o-mini-tts' })), 'gpt-4o-mini-tts');
22
+ assert.equal(ttsModelFor(fakeCtx({ model: 'deepseek-chat' })), null);
23
+ });
24
+
25
+ test('sintetizarVoz usa /audio/speech y reintenta con la voz default del proveedor', async () => {
26
+ const calls = [];
27
+ const server = http.createServer((req, res) => {
28
+ let body = '';
29
+ req.on('data', (chunk) => (body += chunk));
30
+ req.on('end', () => {
31
+ const parsed = JSON.parse(body);
32
+ calls.push(parsed);
33
+ if (parsed.voice !== 'alloy') {
34
+ res.statusCode = 400;
35
+ res.end(JSON.stringify({ error: { message: 'voz desconocida' } }));
36
+ return;
37
+ }
38
+ res.end(Buffer.from('MP3'));
39
+ });
40
+ });
41
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
42
+ try {
43
+ const base = `http://127.0.0.1:${server.address().port}`;
44
+ const mp3 = await sintetizarVoz(fakeCtx({ base, model: 'gpt-4o-mini-tts' }), 'hola', {
45
+ voice: 'es-AR-ElenaNeural',
46
+ rate: '+10%',
47
+ });
48
+ assert.equal(mp3.toString(), 'MP3');
49
+ assert.equal(calls.length, 2);
50
+ assert.equal(calls[0].voice, 'es-AR-ElenaNeural');
51
+ assert.equal(calls[1].voice, 'alloy');
52
+ assert.equal(calls[0].speed, 1.1);
53
+ assert.equal(calls[0].input, 'hola');
54
+ assert.equal(calls[0].model, 'gpt-4o-mini-tts');
55
+ } finally {
56
+ server.close();
57
+ }
58
+ });
@@ -106,7 +106,7 @@ async function main() {
106
106
  return;
107
107
  }
108
108
 
109
- const args = [bin, 'server', '--stream', '--no-open'];
109
+ const args = [bin, 'server', '--stream'];
110
110
  if (port) args.push('--port', port);
111
111
  if (host) args.push('--host', host);
112
112
  if (cwd) args.push('--dir', cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI y consola web. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",