@danieltmn/tamnora 0.26.1 → 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.
- package/CHANGELOG.md +37 -0
- package/README.md +28 -10
- package/harness/packages/cli/src/commands/server.js +10 -10
- package/harness/packages/cli/src/commands/update.js +1 -1
- package/harness/packages/core/src/agent/modes.js +2 -0
- package/harness/packages/core/src/index.js +4 -0
- package/harness/packages/core/src/mcp/registry.js +43 -2
- package/harness/packages/core/src/providers/capabilities.js +67 -0
- package/harness/packages/core/src/providers/index.js +25 -7
- package/harness/packages/core/src/providers/ollama.js +3 -2
- package/harness/packages/core/src/providers/utils.js +16 -6
- package/harness/packages/core/src/skills/remote.js +45 -12
- package/harness/packages/core/src/tools/executors.js +84 -0
- package/harness/packages/core/src/tools/labels.js +7 -0
- package/harness/packages/core/src/tools/permissions.js +3 -0
- package/harness/packages/core/src/tools/registry.js +3 -2
- package/harness/packages/core/src/tools/schema.js +53 -0
- package/harness/packages/core/test/capabilities.test.js +51 -0
- package/harness/packages/core/test/mcp-registry.test.js +63 -1
- package/harness/packages/core/test/providers.test.js +53 -1
- package/harness/packages/core/test/skills.test.js +66 -1
- package/harness/packages/core/test/tools.test.js +63 -0
- package/harness/packages/server/public/app.js +2 -0
- package/harness/packages/server/public/cards.js +22 -6
- package/harness/packages/server/public/chart.js +237 -0
- package/harness/packages/server/public/chat.js +7 -1
- package/harness/packages/server/public/chatFormat.js +15 -4
- package/harness/packages/server/public/chrome.js +10 -4
- package/harness/packages/server/public/index.html +9 -7
- package/harness/packages/server/public/markdown.js +22 -0
- package/harness/packages/server/public/models.js +38 -7
- package/harness/packages/server/public/modelsQuery.js +44 -4
- package/harness/packages/server/public/settings.js +39 -1
- package/harness/packages/server/public/styles.css +33 -0
- package/harness/packages/server/src/lib/channels/telegram/media.js +11 -8
- package/harness/packages/server/src/lib/channels/telegram/run.js +5 -0
- package/harness/packages/server/src/lib/imagegen.js +201 -0
- package/harness/packages/server/src/lib/scheduler.js +2 -0
- package/harness/packages/server/src/lib/securityHeaders.js +1 -1
- package/harness/packages/server/src/lib/tasks.js +2 -0
- package/harness/packages/server/src/lib/transcribe.js +3 -1
- package/harness/packages/server/src/lib/ttsEngine.js +69 -0
- package/harness/packages/server/src/routes/chat.js +2 -0
- package/harness/packages/server/src/routes/files.js +8 -3
- package/harness/packages/server/src/routes/imagegen.js +35 -0
- package/harness/packages/server/src/routes/models.js +2 -1
- package/harness/packages/server/src/routes/tts.js +4 -2
- package/harness/packages/server/src/server.js +2 -0
- package/harness/packages/server/test/chart.test.js +70 -0
- package/harness/packages/server/test/chat.test.js +1 -0
- package/harness/packages/server/test/chatFormat.test.js +9 -5
- package/harness/packages/server/test/imagegen.test.js +215 -0
- package/harness/packages/server/test/markdown.test.js +20 -0
- package/harness/packages/server/test/markdownSecurity.test.js +25 -0
- package/harness/packages/server/test/modelsQuery.test.js +25 -1
- package/harness/packages/server/test/securityHeaders.test.js +1 -0
- package/harness/packages/server/test/ttsEngine.test.js +58 -0
- package/harness/scripts/self-update.mjs +1 -1
- package/package.json +1 -1
|
@@ -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, /<script>/);
|
|
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
|
+
});
|
|
@@ -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('
|
|
61
|
-
assert.equal(
|
|
62
|
-
assert.equal(
|
|
63
|
-
assert.equal(
|
|
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('');
|
|
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(''), /src="capturas\/abc-123\.png"/);
|
|
39
|
+
assert.match(renderMarkdown(''), /src="\/api\/screenshots\/a\.png"/);
|
|
40
|
+
assert.match(
|
|
41
|
+
renderMarkdown(''),
|
|
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('');
|
|
47
|
+
assert.match(alt, /alt="a c b"/);
|
|
48
|
+
|
|
49
|
+
// Fuente insegura: queda el texto literal, sin <img>.
|
|
50
|
+
assert.ok(!renderMarkdown(')').includes('<img'));
|
|
51
|
+
assert.ok(!renderMarkdown(')').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(``);
|
|
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(')');
|
|
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'
|
|
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