@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.
- package/CHANGELOG.md +43 -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/icons/icon-128x128.png +0 -0
- package/harness/packages/server/public/icons/icon-144x144.png +0 -0
- package/harness/packages/server/public/icons/icon-152x152.png +0 -0
- package/harness/packages/server/public/icons/icon-192x192.png +0 -0
- package/harness/packages/server/public/icons/icon-384x384.png +0 -0
- package/harness/packages/server/public/icons/icon-512x512.png +0 -0
- package/harness/packages/server/public/icons/icon-72x72.png +0 -0
- package/harness/packages/server/public/icons/icon-96x96.png +0 -0
- package/harness/packages/server/public/icons/logo.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/index.html +9 -7
- package/harness/packages/server/public/logo.png +0 -0
- 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
- package/harness/packages/server/public/icons (1).zip +0 -0
|
@@ -104,7 +104,12 @@ export function openProviderForm(provider = null) {
|
|
|
104
104
|
$('#pf-id').value = provider?.id ?? '';
|
|
105
105
|
$('#pf-id').readOnly = Boolean(provider);
|
|
106
106
|
$('#pf-label').value = provider?.label ?? '';
|
|
107
|
-
|
|
107
|
+
// Sin base URL propia, se autocompleta la del preset conocido (no el ejemplo
|
|
108
|
+
// del placeholder). `data-autofill` recuerda que lo pusimos nosotros: si el
|
|
109
|
+
// usuario escribe otra cosa, no se vuelve a pisar.
|
|
110
|
+
const base = $('#pf-baseurl');
|
|
111
|
+
base.value = provider?.baseUrl || provider?.defaultBaseUrl || '';
|
|
112
|
+
base.dataset.autofill = provider?.defaultBaseUrl ?? base.value;
|
|
108
113
|
$('#pf-key').value = '';
|
|
109
114
|
$('#pf-key').placeholder = provider?.apiKeyHint ? `guardada (${provider.apiKeyHint})` : 'dejar vacío para no cambiar';
|
|
110
115
|
$('#pf-models').value = (provider?.models ?? []).join(', ');
|
|
@@ -113,6 +118,25 @@ export function openProviderForm(provider = null) {
|
|
|
113
118
|
if (!provider) $('#pf-id').focus();
|
|
114
119
|
}
|
|
115
120
|
|
|
121
|
+
/** Base URL del preset conocido con ese id ('' si no hay preset). */
|
|
122
|
+
function presetBaseUrl(id) {
|
|
123
|
+
return state.providers?.find((p) => p.id === id)?.defaultBaseUrl ?? '';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Mientras se tipea el id de un proveedor nuevo, se autocompleta el base URL
|
|
128
|
+
* del preset conocido (deepseek, openai, ...) salvo que el usuario haya
|
|
129
|
+
* escrito el suyo.
|
|
130
|
+
*/
|
|
131
|
+
export function syncProviderDefaults() {
|
|
132
|
+
const known = presetBaseUrl($('#pf-id').value.trim());
|
|
133
|
+
const base = $('#pf-baseurl');
|
|
134
|
+
if (known && (!base.value.trim() || base.dataset.autofill === base.value)) {
|
|
135
|
+
base.value = known;
|
|
136
|
+
base.dataset.autofill = known;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
116
140
|
export function closeProviderForm() {
|
|
117
141
|
if (providerModal.open) providerModal.close();
|
|
118
142
|
}
|
|
@@ -1167,10 +1191,24 @@ export async function searchMcp() {
|
|
|
1167
1191
|
setStatus('#mcp-search-status', `${data.results.length} resultado(s)`);
|
|
1168
1192
|
for (const r of data.results) {
|
|
1169
1193
|
const li = el('li', r.installed ? 'active' : '');
|
|
1194
|
+
if (r.registry) li.title = r.registry;
|
|
1170
1195
|
const main = el('div', 'grow');
|
|
1171
1196
|
main.append(el('div', '', r.name || r.id), el('div', 'pmeta', r.description || r.id));
|
|
1197
|
+
const meta = [r.publisher, r.version ? `v${r.version}` : '', r.transport === 'http' ? 'http' : 'stdio']
|
|
1198
|
+
.filter(Boolean)
|
|
1199
|
+
.join(' · ');
|
|
1200
|
+
if (meta) main.append(el('div', 'pmeta', meta));
|
|
1172
1201
|
li.appendChild(main);
|
|
1173
1202
|
const actions = el('div', 'pactions');
|
|
1203
|
+
if (r.official) actions.appendChild(el('span', 'chip ok', 'oficial'));
|
|
1204
|
+
else if (r.status && r.status !== 'active') actions.appendChild(el('span', 'chip warn', r.status));
|
|
1205
|
+
if (r.repository) {
|
|
1206
|
+
const repo = el('a', 'pmeta', 'repo');
|
|
1207
|
+
repo.href = r.repository;
|
|
1208
|
+
repo.target = '_blank';
|
|
1209
|
+
repo.rel = 'noopener noreferrer';
|
|
1210
|
+
actions.appendChild(repo);
|
|
1211
|
+
}
|
|
1174
1212
|
if (r.requiredEnv?.length) actions.appendChild(el('span', 'pmeta', `env: ${r.requiredEnv.join(', ')}`));
|
|
1175
1213
|
if (r.installed) actions.appendChild(el('span', 'pmeta', 'instalado'));
|
|
1176
1214
|
else actions.appendChild(button('instalar', (event) => { event.stopPropagation(); installMcpFromRegistry(r.registry || r.name); }));
|
|
@@ -1021,6 +1021,30 @@ body.resizing * { cursor: col-resize !important; }
|
|
|
1021
1021
|
.plan-step.active .plan-mark { color: var(--accent); }
|
|
1022
1022
|
.plan-step.active .plan-text { color: var(--text); font-weight: 600; }
|
|
1023
1023
|
|
|
1024
|
+
/* chart card (show_chart) --------------------------------------------------- */
|
|
1025
|
+
.chart-card {
|
|
1026
|
+
border: 1px solid var(--border);
|
|
1027
|
+
border-radius: 8px;
|
|
1028
|
+
padding: 10px 12px;
|
|
1029
|
+
background: var(--theirs);
|
|
1030
|
+
margin-top: 6px;
|
|
1031
|
+
}
|
|
1032
|
+
.chart-figure { margin: 0; }
|
|
1033
|
+
.chart-figure .chart { display: block; width: 100%; height: auto; color: var(--text); }
|
|
1034
|
+
.chart-tick { font: 11px var(--mono); fill: var(--muted); }
|
|
1035
|
+
.chart-legend { font: 12px var(--sans); fill: var(--text); }
|
|
1036
|
+
|
|
1037
|
+
/* imagenes inline del markdown (  ) ------------------------------ */
|
|
1038
|
+
.md-img {
|
|
1039
|
+
display: block;
|
|
1040
|
+
max-width: 100%;
|
|
1041
|
+
max-height: 420px;
|
|
1042
|
+
width: auto;
|
|
1043
|
+
margin: 6px 0;
|
|
1044
|
+
border: 1px solid var(--border);
|
|
1045
|
+
border-radius: 8px;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1024
1048
|
/* activity indicators ------------------------------------------------------ */
|
|
1025
1049
|
.activity { display: flex; align-items: center; gap: 9px; margin-top: 10px; color: var(--muted); font-size: 12.5px; }
|
|
1026
1050
|
.activity .loader { color: var(--accent); }
|
|
@@ -2248,6 +2272,15 @@ dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: bl
|
|
|
2248
2272
|
.model-item-id { color: var(--muted); font: 500 11px var(--mono); flex: 0 0 auto; }
|
|
2249
2273
|
.model-item-price { color: var(--muted); font: 500 11px var(--mono); flex: 0 0 auto; }
|
|
2250
2274
|
.model-item-free { color: var(--ok); font: 600 10px var(--sans); flex: 0 0 auto; }
|
|
2275
|
+
.model-actions .btn.active { border-color: var(--accent); color: var(--accent); font-weight: 700; }
|
|
2276
|
+
.model-cap {
|
|
2277
|
+
flex: 0 0 auto;
|
|
2278
|
+
font: 600 10px var(--sans);
|
|
2279
|
+
color: var(--accent);
|
|
2280
|
+
border: 1px solid var(--border);
|
|
2281
|
+
border-radius: 999px;
|
|
2282
|
+
padding: 1px 7px;
|
|
2283
|
+
}
|
|
2251
2284
|
.model-empty { padding: 16px; color: var(--muted); text-align: center; }
|
|
2252
2285
|
.confirm-text { margin: 0; color: var(--muted, inherit); line-height: 1.5; }
|
|
2253
2286
|
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { readFile } from 'node:fs/promises';
|
|
6
|
-
import {
|
|
6
|
+
import { MAX_TEXTO, screenshotsDir } from '../../core.js';
|
|
7
|
+
import { sintetizarVoz } from '../../ttsEngine.js';
|
|
7
8
|
import { mp3AOgg } from '../../ffmpeg.js';
|
|
8
9
|
import { resolveTts } from '../../ttsSettings.js';
|
|
9
10
|
import { tgForm } from './api.js';
|
|
@@ -17,7 +18,7 @@ export async function enviarNotaDeVoz(T, chatId, texto, { voice, rate, pitch } =
|
|
|
17
18
|
const frase = String(texto ?? '').trim().slice(0, MAX_TEXTO);
|
|
18
19
|
if (!frase) return;
|
|
19
20
|
try {
|
|
20
|
-
const mp3 = await
|
|
21
|
+
const mp3 = await sintetizarVoz(T.ctx, frase, {
|
|
21
22
|
voice: voice || tts.voice,
|
|
22
23
|
rate: rate || tts.rate,
|
|
23
24
|
pitch: pitch || tts.pitch,
|
|
@@ -37,28 +38,30 @@ export async function enviarNotaDeVoz(T, chatId, texto, { voice, rate, pitch } =
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
/** Manda una imagen (una captura del tool `screenshot`). */
|
|
41
|
-
export async function enviarFoto(T, chatId, buffer, caption) {
|
|
41
|
+
/** Manda una imagen (una captura del tool `screenshot` o una imagen generada). */
|
|
42
|
+
export async function enviarFoto(T, chatId, buffer, caption, ext = 'png') {
|
|
43
|
+
const mime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }[String(ext).toLowerCase()] ?? 'image/png';
|
|
42
44
|
try {
|
|
43
45
|
const form = new FormData();
|
|
44
46
|
form.append('chat_id', chatId);
|
|
45
47
|
if (caption) form.append('caption', String(caption).slice(0, 1000));
|
|
46
|
-
form.append('photo', new Blob([buffer], { type:
|
|
48
|
+
form.append('photo', new Blob([buffer], { type: mime }), `imagen.${ext}`);
|
|
47
49
|
await tgForm(T.token, 'sendPhoto', form);
|
|
48
50
|
} catch (error) {
|
|
49
51
|
T.log(`telegram: foto falló: ${error?.message ?? error}`);
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
/** Entrega una
|
|
55
|
+
/** Entrega una imagen guardada en la casa (por nombre, sin path traversal). */
|
|
54
56
|
export async function enviarCaptura(T, chatId, name, caption) {
|
|
55
57
|
const safe = String(name ?? '');
|
|
56
|
-
|
|
58
|
+
const match = /^([a-z0-9][a-z0-9-]*)\.(png|jpe?g|webp|gif)$/i.exec(safe);
|
|
59
|
+
if (!match) return;
|
|
57
60
|
let buffer;
|
|
58
61
|
try {
|
|
59
62
|
buffer = await readFile(path.join(screenshotsDir(), safe));
|
|
60
63
|
} catch {
|
|
61
64
|
return;
|
|
62
65
|
}
|
|
63
|
-
await enviarFoto(T, chatId, buffer, caption);
|
|
66
|
+
await enviarFoto(T, chatId, buffer, caption, match[2]);
|
|
64
67
|
}
|
|
@@ -16,6 +16,7 @@ import { edit, send, tg } from './api.js';
|
|
|
16
16
|
import { completionFooter } from './text.js';
|
|
17
17
|
import { permissionMessage, permissionKeyboard } from './permissions.js';
|
|
18
18
|
import { enviarCaptura, enviarNotaDeVoz } from './media.js';
|
|
19
|
+
import { generateImage } from '../../imagegen.js';
|
|
19
20
|
import { activeSessionId, projectLabel, sendProjectPicker } from './projects.js';
|
|
20
21
|
|
|
21
22
|
const RESUME_INSTRUCTION =
|
|
@@ -93,6 +94,7 @@ export async function run(T, chatId, input, images = []) {
|
|
|
93
94
|
mcp,
|
|
94
95
|
mcpMode: T.ctx.effectiveMcpMode(),
|
|
95
96
|
mcpPinned: T.ctx.effectiveMcpPinned(),
|
|
97
|
+
onImage: (req) => generateImage(T.ctx, req),
|
|
96
98
|
});
|
|
97
99
|
const exposeAdmin = !mode.tools;
|
|
98
100
|
const admin = T.ctx.agentTools;
|
|
@@ -158,6 +160,9 @@ export async function run(T, chatId, input, images = []) {
|
|
|
158
160
|
if (event.name === 'screenshot' && event.meta?.screenshot?.name) {
|
|
159
161
|
enviarCaptura(T, chatId, event.meta.screenshot.name, event.meta.screenshot.url);
|
|
160
162
|
}
|
|
163
|
+
if (event.name === 'generate_image' && event.meta?.image?.name) {
|
|
164
|
+
enviarCaptura(T, chatId, event.meta.image.name, event.meta.image.prompt);
|
|
165
|
+
}
|
|
161
166
|
} else {
|
|
162
167
|
return;
|
|
163
168
|
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Generacion de imagenes, compartida por la consola y los canales que arman
|
|
2
|
+
// tools. Regla de resolucion: **el mismo modelo del chat primero** — si el
|
|
3
|
+
// modelo activo sabe generar imagenes, genera el (endpoint `/images/generations`
|
|
4
|
+
// para las familias tipo dall-e/gpt-image, o una respuesta de chat con parte de
|
|
5
|
+
// imagen para las tipo gemini-*-image). Si no, cae al ajuste `app.json` ->
|
|
6
|
+
// `imageGen` (provider/model/size) y recien ahi al proveedor activo con
|
|
7
|
+
// `dall-e-3`. La imagen se guarda en la casa (`screenshots/`), igual que las
|
|
8
|
+
// capturas, para que los endpoints y canales existentes la sirvan.
|
|
9
|
+
|
|
10
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { apiRoot, modelCapabilities, screenshotsDir } from './core.js';
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_IMAGE_MODEL = 'dall-e-3';
|
|
15
|
+
export const DEFAULT_IMAGE_SIZE = '1024x1024';
|
|
16
|
+
|
|
17
|
+
/** Familias que generan por el endpoint de imagenes (no por chat). */
|
|
18
|
+
const IMAGE_API = /dall-e|gpt-image|imagen-/i;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Defaults cuando ni el modelo del chat ni `imageGen` generan imagenes.
|
|
22
|
+
* OpenRouter enruta la generacion por chat (con `modalities`), y su catalogo
|
|
23
|
+
* no usa ids `dall-e-*`; el resto asume el endpoint de imagenes estandar.
|
|
24
|
+
*/
|
|
25
|
+
const PROVIDER_IMAGE_DEFAULTS = {
|
|
26
|
+
openrouter: { model: 'google/gemini-2.5-flash-image', viaChat: true },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Como genera este modelo: por chat (salida de imagen) o por /images/generations. */
|
|
30
|
+
const viaChatFor = (model) => !IMAGE_API.test(model) && modelCapabilities(model).image;
|
|
31
|
+
|
|
32
|
+
/** Detalle legible del error del proveedor (OpenAI devuelve `{error:{message}}`). */
|
|
33
|
+
async function providerError(res) {
|
|
34
|
+
const body = await res.text().catch(() => '');
|
|
35
|
+
try {
|
|
36
|
+
const json = JSON.parse(body);
|
|
37
|
+
return json?.error?.message || json?.error || json?.message || body;
|
|
38
|
+
} catch {
|
|
39
|
+
return body;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Formato por magic bytes + dimensiones cuando se pueden leer (PNG/JPEG). */
|
|
44
|
+
function imageInfo(buffer) {
|
|
45
|
+
if (buffer.length > 24 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
|
|
46
|
+
return {
|
|
47
|
+
ext: 'png',
|
|
48
|
+
width: buffer.readUInt32BE(16),
|
|
49
|
+
height: buffer.readUInt32BE(20),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (buffer.length > 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
53
|
+
// JPEG: buscar el marcador SOF (el alto/ancho viven ahi).
|
|
54
|
+
let i = 2;
|
|
55
|
+
while (i + 9 < buffer.length) {
|
|
56
|
+
if (buffer[i] !== 0xff) break;
|
|
57
|
+
const marker = buffer[i + 1];
|
|
58
|
+
const size = buffer.readUInt16BE(i + 2);
|
|
59
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
60
|
+
return { ext: 'jpg', height: buffer.readUInt16BE(i + 5), width: buffer.readUInt16BE(i + 7) };
|
|
61
|
+
}
|
|
62
|
+
i += 2 + size;
|
|
63
|
+
}
|
|
64
|
+
return { ext: 'jpg' };
|
|
65
|
+
}
|
|
66
|
+
if (buffer.length > 12 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') {
|
|
67
|
+
return { ext: 'webp' };
|
|
68
|
+
}
|
|
69
|
+
if (buffer.toString('ascii', 0, 3) === 'GIF') return { ext: 'gif' };
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Data URL o URL remota -> buffer. */
|
|
74
|
+
async function urlToBuffer(url, signal) {
|
|
75
|
+
const text = String(url ?? '');
|
|
76
|
+
const data = /^data:([^,]+);base64,(.+)$/s.exec(text);
|
|
77
|
+
if (data) return Buffer.from(data[2], 'base64');
|
|
78
|
+
const res = await fetch(text, { signal });
|
|
79
|
+
if (!res.ok) throw new Error(`no se pudo descargar la imagen devuelta (HTTP ${res.status})`);
|
|
80
|
+
return Buffer.from(await res.arrayBuffer());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** POST /images/generations (familia dall-e/gpt-image). */
|
|
84
|
+
async function viaImagesApi(baseUrl, apiKey, { model, prompt, size, signal }) {
|
|
85
|
+
const url = `${baseUrl}/images/generations`;
|
|
86
|
+
const res = await fetch(url, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
'Content-Type': 'application/json',
|
|
90
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({ model, prompt, n: 1, size: size || DEFAULT_IMAGE_SIZE }),
|
|
93
|
+
signal,
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
const detail = String((await providerError(res)) ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
97
|
+
throw new Error(`generación falló (HTTP ${res.status}) en ${url}${detail ? `: ${detail}` : ''}`);
|
|
98
|
+
}
|
|
99
|
+
const data = await res.json().catch(() => null);
|
|
100
|
+
const item = Array.isArray(data?.data) ? data.data[0] : null;
|
|
101
|
+
if (item?.b64_json) return Buffer.from(String(item.b64_json), 'base64');
|
|
102
|
+
if (item?.url) return urlToBuffer(item.url, signal);
|
|
103
|
+
throw new Error('el proveedor no devolvió una imagen');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Busca una parte de imagen en la respuesta de un chat (OpenAI-compatible). */
|
|
107
|
+
function imageFromMessage(message) {
|
|
108
|
+
const fromImages = Array.isArray(message?.images) ? message.images : [];
|
|
109
|
+
const fromContent = Array.isArray(message?.content) ? message.content : [];
|
|
110
|
+
for (const part of [...fromImages, ...fromContent]) {
|
|
111
|
+
const url = part?.image_url?.url ?? part?.url;
|
|
112
|
+
if (url) return url;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Chat con salida de imagen (familia gemini-*-image y afines). */
|
|
118
|
+
async function viaChatImage(baseUrl, apiKey, { model, prompt, signal }) {
|
|
119
|
+
const endpoint = `${baseUrl}/chat/completions`;
|
|
120
|
+
const res = await fetch(endpoint, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
'Content-Type': 'application/json',
|
|
124
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
125
|
+
},
|
|
126
|
+
body: JSON.stringify({
|
|
127
|
+
model,
|
|
128
|
+
modalities: ['text', 'image'],
|
|
129
|
+
messages: [{ role: 'user', content: prompt }],
|
|
130
|
+
}),
|
|
131
|
+
signal,
|
|
132
|
+
});
|
|
133
|
+
if (!res.ok) {
|
|
134
|
+
const detail = String((await providerError(res)) ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
135
|
+
throw new Error(`generación falló (HTTP ${res.status}) en ${endpoint}${detail ? `: ${detail}` : ''}`);
|
|
136
|
+
}
|
|
137
|
+
const data = await res.json().catch(() => null);
|
|
138
|
+
const message = data?.choices?.[0]?.message;
|
|
139
|
+
const url = imageFromMessage(message);
|
|
140
|
+
if (!url) {
|
|
141
|
+
const text = typeof message?.content === 'string' ? message.content.slice(0, 160) : '';
|
|
142
|
+
throw new Error(`el modelo no devolvió una imagen${text ? `: ${text}` : ''}`);
|
|
143
|
+
}
|
|
144
|
+
return urlToBuffer(url, signal);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveTarget(ctx, { model, providerId: override }, saved) {
|
|
148
|
+
// 1) modelo explicito de la tool; 2) el modelo del chat si genera imagenes;
|
|
149
|
+
// 3) el ajuste imageGen; 4) el default conocido del proveedor.
|
|
150
|
+
const explicit = String(model ?? '').trim();
|
|
151
|
+
if (explicit) {
|
|
152
|
+
const id = String(override ?? '').trim() || String(ctx.effectiveProviderId?.() ?? '');
|
|
153
|
+
return { providerId: id, model: explicit, viaChat: viaChatFor(explicit) };
|
|
154
|
+
}
|
|
155
|
+
const chatModel = String(ctx.effectiveModel?.() ?? '').trim();
|
|
156
|
+
if (chatModel && modelCapabilities(chatModel).image) {
|
|
157
|
+
return {
|
|
158
|
+
providerId: String(override ?? '').trim() || String(ctx.effectiveProviderId?.() ?? ''),
|
|
159
|
+
model: chatModel,
|
|
160
|
+
viaChat: viaChatFor(chatModel),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const providerId =
|
|
164
|
+
String(override ?? '').trim() || String(saved.provider ?? '').trim() || String(ctx.effectiveProviderId?.() ?? '');
|
|
165
|
+
const savedModel = String(saved.model ?? '').trim();
|
|
166
|
+
if (savedModel) return { providerId, model: savedModel, viaChat: viaChatFor(savedModel) };
|
|
167
|
+
const fallback = PROVIDER_IMAGE_DEFAULTS[providerId] ?? { model: DEFAULT_IMAGE_MODEL, viaChat: false };
|
|
168
|
+
return { providerId, model: fallback.model, viaChat: fallback.viaChat };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Pide una imagen y la guarda en la casa. Devuelve
|
|
173
|
+
* `{ name, bytes, width, height, model }`. Lanza `Error` con detalle legible.
|
|
174
|
+
*/
|
|
175
|
+
export async function generateImage(ctx, { prompt, size, model, providerId: override, signal } = {}) {
|
|
176
|
+
const app = ctx.app ?? {};
|
|
177
|
+
const saved = app.imageGen && typeof app.imageGen === 'object' ? app.imageGen : {};
|
|
178
|
+
const target = resolveTarget(ctx, { model, providerId: override }, saved);
|
|
179
|
+
|
|
180
|
+
const settings = { ...(ctx.providerSettings(target.providerId) ?? {}), apiKey: ctx.apiKeyFor(target.providerId) };
|
|
181
|
+
// `providerSettings` no trae el baseUrl de los proveedores builtin (lo resuelve
|
|
182
|
+
// el runtime), asi que resolvemos como lo hace el agente. `apiRoot` recorta el
|
|
183
|
+
// sufijo de endpoint si el usuario pego la URL completa como baseUrl.
|
|
184
|
+
const resolved = ctx.resolveProvider?.(settings)?.config ?? {};
|
|
185
|
+
const baseUrl = apiRoot(settings.baseUrl || resolved.baseUrl || '');
|
|
186
|
+
const apiKey = settings.apiKey || resolved.apiKey || '';
|
|
187
|
+
if (!baseUrl) throw new Error(`el proveedor "${target.providerId}" no tiene baseUrl para generar imágenes`);
|
|
188
|
+
|
|
189
|
+
const buffer = target.viaChat
|
|
190
|
+
? await viaChatImage(baseUrl, apiKey, { model: target.model, prompt, signal })
|
|
191
|
+
: await viaImagesApi(baseUrl, apiKey, { model: target.model, prompt, size, signal });
|
|
192
|
+
if (!buffer?.length) throw new Error('el proveedor no devolvió una imagen');
|
|
193
|
+
const info = imageInfo(buffer);
|
|
194
|
+
if (!info) throw new Error('el proveedor devolvió un formato de imagen desconocido');
|
|
195
|
+
|
|
196
|
+
const dir = screenshotsDir();
|
|
197
|
+
await mkdir(dir, { recursive: true });
|
|
198
|
+
const name = `gen-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${info.ext}`;
|
|
199
|
+
await writeFile(path.join(dir, name), buffer);
|
|
200
|
+
return { name, bytes: buffer.length, width: info.width, height: info.height, model: target.model };
|
|
201
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
toolResultPayload,
|
|
13
13
|
} from './agentRun.js';
|
|
14
14
|
import { createSubagentTool } from './tasks.js';
|
|
15
|
+
import { generateImage } from './imagegen.js';
|
|
15
16
|
import { createRulesInjector } from './rules.js';
|
|
16
17
|
import { enabledAgents } from './agents.js';
|
|
17
18
|
|
|
@@ -65,6 +66,7 @@ async function runAgentSchedule(ctx, schedule) {
|
|
|
65
66
|
mcpMode: ctx.effectiveMcpMode(),
|
|
66
67
|
mcpPinned: ctx.effectiveMcpPinned(),
|
|
67
68
|
signal,
|
|
69
|
+
onImage: (req) => generateImage(ctx, req),
|
|
68
70
|
});
|
|
69
71
|
const exposeAdmin = !mode.tools;
|
|
70
72
|
const admin = ctx.agentTools;
|
|
@@ -10,7 +10,7 @@ export const CSP_DIRECTIVES = {
|
|
|
10
10
|
'script-src': ["'self'"],
|
|
11
11
|
'style-src': ["'self'", "'unsafe-inline'", FONTS_CSS],
|
|
12
12
|
'font-src': ["'self'", FONTS_FILES, 'data:'],
|
|
13
|
-
'img-src': ["'self'", 'data:', 'blob:'],
|
|
13
|
+
'img-src': ["'self'", 'data:', 'blob:', 'http:', 'https:'],
|
|
14
14
|
'media-src': ["'self'", 'blob:', 'data:'],
|
|
15
15
|
'connect-src': ["'self'"],
|
|
16
16
|
// La pestaña preview embebe el dev server del usuario (local o por tunel).
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { runAgent, createTools, resolveMode, normalizeEffort, buildSubagentPrompt } from './core.js';
|
|
10
10
|
import { toolCallPayload, toolResultPayload } from './agentRun.js';
|
|
11
|
+
import { generateImage } from './imagegen.js';
|
|
11
12
|
import { skillsBlockFor } from './skills.js';
|
|
12
13
|
|
|
13
14
|
function definition(name, description, parameters, required = []) {
|
|
@@ -127,6 +128,7 @@ export function createSubagentTool(ctx, options) {
|
|
|
127
128
|
mcpMode,
|
|
128
129
|
mcpPinned,
|
|
129
130
|
signal,
|
|
131
|
+
onImage: (req) => generateImage(ctx, req),
|
|
130
132
|
});
|
|
131
133
|
// Un subagente con skills declaradas ve solo esas skills (arbol de contexto
|
|
132
134
|
// acotado); sin declaracion hereda el bloque general.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// consola (microfono en el input). Usa el endpoint OpenAI-compatible
|
|
3
3
|
// `/audio/transcriptions` del proveedor (OpenAI, Groq, ...).
|
|
4
4
|
|
|
5
|
+
import { apiRoot } from './core.js';
|
|
6
|
+
|
|
5
7
|
export const DEFAULT_TRANSCRIBE_MODEL = 'whisper-1';
|
|
6
8
|
|
|
7
9
|
/** Detalle legible del error del proveedor (OpenAI/Groq devuelven `{error:{message}}`). */
|
|
@@ -24,7 +26,7 @@ export async function transcribeAudio(
|
|
|
24
26
|
// `providerSettings` no trae el baseUrl de los proveedores builtin (lo resuelve
|
|
25
27
|
// el runtime), asi que resolvemos como lo hace el agente.
|
|
26
28
|
const resolved = ctx.resolveProvider?.(settings)?.config ?? {};
|
|
27
|
-
const baseUrl =
|
|
29
|
+
const baseUrl = apiRoot(settings.baseUrl || resolved.baseUrl || '');
|
|
28
30
|
const apiKey = settings.apiKey || resolved.apiKey || '';
|
|
29
31
|
if (!baseUrl) throw new Error(`el proveedor "${id}" no tiene baseUrl para transcribir`);
|
|
30
32
|
const form = new FormData();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Sintesis de voz con eleccion de motor: si el modelo del chat sabe hablar
|
|
2
|
+
// (familia tts, o el ajuste `app.json` -> `tts.model`), se usa el endpoint
|
|
3
|
+
// OpenAI-compatible `POST /audio/speech` del proveedor; si no (o si falla), cae
|
|
4
|
+
// a Edge Read Aloud (gratis, sin API key) como siempre.
|
|
5
|
+
|
|
6
|
+
import { apiRoot, modelCapabilities, sintetizar } from './core.js';
|
|
7
|
+
|
|
8
|
+
/** '+10%' / '1.25' -> 1.1 / 1.25, default 1. */
|
|
9
|
+
function speedFromRate(rate) {
|
|
10
|
+
const text = String(rate ?? '').trim();
|
|
11
|
+
const pct = /^([+-]?\d+)%$/.exec(text);
|
|
12
|
+
if (pct) return Math.min(4, Math.max(0.25, 1 + Number(pct[1]) / 100));
|
|
13
|
+
const n = Number(text);
|
|
14
|
+
return Number.isFinite(n) && n > 0 ? Math.min(4, Math.max(0.25, n)) : 1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Modelo TTS efectivo: ajuste propio -> el modelo del chat si habla -> null (Edge). */
|
|
18
|
+
export function ttsModelFor(ctx) {
|
|
19
|
+
const saved = ctx.app?.tts && typeof ctx.app.tts === 'object' ? ctx.app.tts : {};
|
|
20
|
+
const explicit = String(saved.model ?? '').trim();
|
|
21
|
+
if (explicit) return explicit;
|
|
22
|
+
const active = String(ctx.effectiveModel?.() ?? '').trim();
|
|
23
|
+
return active && modelCapabilities(active).tts ? active : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function providerSpeech(ctx, model, texto, { voice, rate, signal } = {}) {
|
|
27
|
+
const id = ctx.effectiveProviderId?.();
|
|
28
|
+
const settings = { ...(ctx.providerSettings(id) ?? {}), apiKey: ctx.apiKeyFor(id) };
|
|
29
|
+
const resolved = ctx.resolveProvider?.(settings)?.config ?? {};
|
|
30
|
+
const baseUrl = apiRoot(settings.baseUrl || resolved.baseUrl || '');
|
|
31
|
+
const apiKey = settings.apiKey || resolved.apiKey || '';
|
|
32
|
+
if (!baseUrl) throw new Error(`el proveedor "${id}" no tiene baseUrl para sintetizar`);
|
|
33
|
+
const speak = (v) =>
|
|
34
|
+
fetch(`${baseUrl}/audio/speech`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({ model, input: texto, voice: v || 'alloy', speed: speedFromRate(rate), response_format: 'mp3' }),
|
|
41
|
+
signal,
|
|
42
|
+
});
|
|
43
|
+
// Las voces de Edge no existen en el proveedor: si rechaza la voz, una
|
|
44
|
+
// segunda chance con la default antes de caer a Edge entero.
|
|
45
|
+
let res = await speak(voice);
|
|
46
|
+
if (!res.ok && voice && voice !== 'alloy') res = await speak('alloy');
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const detail = String((await res.text().catch(() => '')) ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
49
|
+
throw new Error(`audio/speech falló (HTTP ${res.status})${detail ? `: ${detail}` : ''}`);
|
|
50
|
+
}
|
|
51
|
+
return Buffer.from(await res.arrayBuffer());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sintetiza voz y devuelve el MP3: proveedor del chat si el modelo habla,
|
|
56
|
+
* Edge como fallback (tambien cuando el proveedor falla).
|
|
57
|
+
*/
|
|
58
|
+
export async function sintetizarVoz(ctx, texto, opciones = {}) {
|
|
59
|
+
const model = ttsModelFor(ctx);
|
|
60
|
+
if (model) {
|
|
61
|
+
try {
|
|
62
|
+
return await providerSpeech(ctx, model, texto, opciones);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (opciones.signal?.aborted) throw error;
|
|
65
|
+
console.error(`[tts] el proveedor no pudo sintetizar (${error?.message ?? error}); cae a Edge`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return sintetizar(texto, opciones);
|
|
69
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { openSse, readJson, sendJson, sendError } from '../lib/http.js';
|
|
10
10
|
import { workspaceForRequest, resolveExistingDir, localOk } from '../lib/workspace.js';
|
|
11
11
|
import { createSubagentTool } from '../lib/tasks.js';
|
|
12
|
+
import { generateImage } from '../lib/imagegen.js';
|
|
12
13
|
import { enabledAgents } from '../lib/agents.js';
|
|
13
14
|
import {
|
|
14
15
|
combineToolset,
|
|
@@ -264,6 +265,7 @@ export function registerChat(route, ctx) {
|
|
|
264
265
|
mcpMode: ctx.effectiveMcpMode(),
|
|
265
266
|
mcpPinned: ctx.effectiveMcpPinned(),
|
|
266
267
|
signal: ctx.runs.signal(sessionId),
|
|
268
|
+
onImage: (req) => generateImage(ctx, req),
|
|
267
269
|
});
|
|
268
270
|
// En modo plan (solo lectura) no exponemos las tools de administración.
|
|
269
271
|
const exposeAdmin = !mode.tools;
|
|
@@ -145,14 +145,16 @@ export function registerFiles(route, ctx) {
|
|
|
145
145
|
});
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
-
//
|
|
148
|
+
// Imagenes de la casa: capturas del tool `screenshot` e imagenes generadas
|
|
149
|
+
// (`generate_image`). No viven en el workspace.
|
|
149
150
|
route('GET', '/api/screenshots/:name', ({ req, res, params }) => {
|
|
150
151
|
const local = localOk(req);
|
|
151
152
|
if (!local && !ctx.authEnabled) {
|
|
152
153
|
return sendError(res, 403, 'Las capturas solo están disponibles en conexiones locales.');
|
|
153
154
|
}
|
|
154
155
|
const name = String(params.name ?? '');
|
|
155
|
-
|
|
156
|
+
const match = /^([a-z0-9][a-z0-9-]*)\.(png|jpe?g|webp|gif)$/i.exec(name);
|
|
157
|
+
if (!match) return sendError(res, 400, 'Nombre de captura inválido.');
|
|
156
158
|
const file = path.join(screenshotsDir(), name);
|
|
157
159
|
let info;
|
|
158
160
|
try {
|
|
@@ -161,7 +163,10 @@ export function registerFiles(route, ctx) {
|
|
|
161
163
|
return sendError(res, 404, 'La captura no existe.');
|
|
162
164
|
}
|
|
163
165
|
if (!info.isFile()) return sendError(res, 404, 'La captura no existe.');
|
|
164
|
-
|
|
166
|
+
const mime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }[
|
|
167
|
+
match[2].toLowerCase()
|
|
168
|
+
];
|
|
169
|
+
res.writeHead(200, { 'Content-Type': mime, 'Content-Length': info.size, 'Cache-Control': 'no-store' });
|
|
165
170
|
if (req.method === 'HEAD') {
|
|
166
171
|
res.end();
|
|
167
172
|
return;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Ajustes de generacion de imagenes (`app.json` -> `imageGen`). No hay UI todavia:
|
|
2
|
+
// se configuran por aca (o editando app.json) igual que los de transcripcion.
|
|
3
|
+
// El proveedor/modelo efectivos tienen fallback al proveedor activo.
|
|
4
|
+
|
|
5
|
+
import { readApp, writeApp } from '../lib/core.js';
|
|
6
|
+
import { readJson, sendJson } from '../lib/http.js';
|
|
7
|
+
import { DEFAULT_IMAGE_MODEL, DEFAULT_IMAGE_SIZE } from '../lib/imagegen.js';
|
|
8
|
+
|
|
9
|
+
function settingsView(app) {
|
|
10
|
+
const saved = app.imageGen && typeof app.imageGen === 'object' ? app.imageGen : {};
|
|
11
|
+
return {
|
|
12
|
+
provider: String(saved.provider ?? ''),
|
|
13
|
+
model: String(saved.model ?? ''),
|
|
14
|
+
size: String(saved.size ?? '') || DEFAULT_IMAGE_SIZE,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerImageGen(route, ctx) {
|
|
19
|
+
route('GET', '/api/imagegen/settings', ({ res }) => {
|
|
20
|
+
sendJson(res, 200, settingsView(ctx.app));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
route('PUT', '/api/imagegen/settings', async ({ req, res }) => {
|
|
24
|
+
const body = await readJson(req);
|
|
25
|
+
const app = readApp();
|
|
26
|
+
const next = { ...(app.imageGen ?? {}) };
|
|
27
|
+
if ('provider' in body) next.provider = String(body.provider ?? '').trim();
|
|
28
|
+
if ('model' in body) next.model = String(body.model ?? '').trim() || DEFAULT_IMAGE_MODEL;
|
|
29
|
+
if ('size' in body) next.size = String(body.size ?? '').trim() || DEFAULT_IMAGE_SIZE;
|
|
30
|
+
app.imageGen = next;
|
|
31
|
+
writeApp(app);
|
|
32
|
+
ctx.app = app;
|
|
33
|
+
sendJson(res, 200, settingsView(app));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -9,7 +9,8 @@ export function registerModels(route, ctx) {
|
|
|
9
9
|
const providerId = query.get('provider') || ctx.effectiveProviderId();
|
|
10
10
|
const settings = { ...ctx.providerSettings(providerId), apiKey: ctx.apiKeyFor(providerId) };
|
|
11
11
|
if (query.get('baseUrl')) settings.baseUrl = query.get('baseUrl');
|
|
12
|
-
const
|
|
12
|
+
const manual = settings.providerModels?.[providerId] ?? [];
|
|
13
|
+
const key = [providerId, settings.baseUrl ?? '', manual.join(',')].join('|');
|
|
13
14
|
|
|
14
15
|
try {
|
|
15
16
|
const { models, cached, stale } = await modelCache.get(key, () => listProviderModels(settings));
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// app.json (la casa). La config no necesita recarga: cada consumidor la lee
|
|
3
3
|
// cuando va a hablar.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { listarVoces, MAX_TEXTO } from '../lib/core.js';
|
|
6
6
|
import { readApp, writeApp } from '../lib/core.js';
|
|
7
7
|
import { ffmpegDisponible } from '../lib/ffmpeg.js';
|
|
8
8
|
import { resolveTts, sanearVoz, sanearRate, sanearPitch } from '../lib/ttsSettings.js';
|
|
9
|
+
import { sintetizarVoz } from '../lib/ttsEngine.js';
|
|
9
10
|
import { sendJson, sendError, readJson } from '../lib/http.js';
|
|
10
11
|
|
|
11
12
|
const cacheVoces = { at: 0, data: null, filtro: 'es-' };
|
|
@@ -38,6 +39,7 @@ export function registerTts(route, ctx) {
|
|
|
38
39
|
if ('voice' in body) next.voice = sanearVoz(body.voice, actual.voice);
|
|
39
40
|
if ('rate' in body) next.rate = sanearRate(body.rate, actual.rate);
|
|
40
41
|
if ('pitch' in body) next.pitch = sanearPitch(body.pitch, actual.pitch);
|
|
42
|
+
if ('model' in body) next.model = String(body.model ?? '').trim();
|
|
41
43
|
if (body.announce && typeof body.announce === 'object') {
|
|
42
44
|
const announce = { ...actual.announce };
|
|
43
45
|
for (const key of ['start', 'tools', 'end', 'answer']) {
|
|
@@ -71,7 +73,7 @@ export function registerTts(route, ctx) {
|
|
|
71
73
|
const rate = sanearRate(query.get('rate'), config.rate);
|
|
72
74
|
const pitch = sanearPitch(query.get('pitch'), config.pitch);
|
|
73
75
|
try {
|
|
74
|
-
const mp3 = await
|
|
76
|
+
const mp3 = await sintetizarVoz(ctx, texto, { voice: voz, rate, pitch });
|
|
75
77
|
res.writeHead(200, {
|
|
76
78
|
'Content-Type': 'audio/mpeg',
|
|
77
79
|
'Content-Length': mp3.length,
|