iobroker.assistant 1.4.5 → 1.4.7
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/README.md +16 -1
- package/admin/i18n/de.json +11 -2
- package/admin/i18n/en.json +11 -2
- package/admin/i18n/es.json +11 -2
- package/admin/i18n/fr.json +11 -2
- package/admin/i18n/it.json +11 -2
- package/admin/i18n/nl.json +11 -2
- package/admin/i18n/pl.json +11 -2
- package/admin/i18n/pt.json +11 -2
- package/admin/i18n/ru.json +11 -2
- package/admin/i18n/uk.json +11 -2
- package/admin/i18n/zh-cn.json +11 -2
- package/admin/jsonConfig.json +65 -2
- package/build/lib/ask.js +10 -0
- package/build/lib/ask.js.map +1 -1
- package/build/lib/journal.js.map +1 -1
- package/build/lib/llm.js +50 -12
- package/build/lib/llm.js.map +1 -1
- package/build/lib/media.js +226 -0
- package/build/lib/media.js.map +1 -0
- package/build/lib/piAgent.js +1 -0
- package/build/lib/piAgent.js.map +1 -1
- package/build/lib/voice/engines.js +3 -0
- package/build/lib/voice/engines.js.map +1 -1
- package/build/lib/voice/esphome.js +258 -5
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/ffmpegStatic.js +137 -0
- package/build/lib/voice/ffmpegStatic.js.map +1 -0
- package/build/lib/voice/stt.js +35 -3
- package/build/lib/voice/stt.js.map +1 -1
- package/build/main.js +306 -32
- package/build/main.js.map +1 -1
- package/io-package.json +34 -29
- package/package.json +1 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pictures and videos that come with a question (`ask` with `images` / `video`, e.g. from Telegram).
|
|
4
|
+
*
|
|
5
|
+
* Pictures go to the cloud model as they are (base64, the vendors take JPEG/PNG/GIF/WebP). Videos are not looked at
|
|
6
|
+
* yet: the sender keeps them in the ioBroker file storage and only says where, so a video never travels through the
|
|
7
|
+
* message bus as base64.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.MAX_IMAGE_BASE64 = exports.MAX_IMAGES = void 0;
|
|
11
|
+
exports.sniffImageType = sniffImageType;
|
|
12
|
+
exports.parseAskImages = parseAskImages;
|
|
13
|
+
exports.parseAskVideo = parseAskVideo;
|
|
14
|
+
exports.isVideoError = isVideoError;
|
|
15
|
+
exports.redactImages = redactImages;
|
|
16
|
+
exports.imageOnlyQuestion = imageOnlyQuestion;
|
|
17
|
+
exports.videoNotSupportedText = videoNotSupportedText;
|
|
18
|
+
exports.noVisionText = noVisionText;
|
|
19
|
+
exports.attachmentNote = attachmentNote;
|
|
20
|
+
exports.historyText = historyText;
|
|
21
|
+
/** At most this many pictures per question (an album of Telegram has up to 10) */
|
|
22
|
+
exports.MAX_IMAGES = 4;
|
|
23
|
+
/** Largest picture as base64 text: Anthropic refuses images over 5 MB */
|
|
24
|
+
exports.MAX_IMAGE_BASE64 = 5 * 1024 * 1024;
|
|
25
|
+
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
26
|
+
/**
|
|
27
|
+
* The type of a picture from its first bytes, `null` if it is none of the four the vendors take.
|
|
28
|
+
*
|
|
29
|
+
* @param buf the start of the file (16 bytes are enough)
|
|
30
|
+
*/
|
|
31
|
+
function sniffImageType(buf) {
|
|
32
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
33
|
+
return 'image/jpeg';
|
|
34
|
+
}
|
|
35
|
+
if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
36
|
+
return 'image/png';
|
|
37
|
+
}
|
|
38
|
+
if (buf.length >= 6 && /^GIF8[79]a$/.test(buf.subarray(0, 6).toString('latin1'))) {
|
|
39
|
+
return 'image/gif';
|
|
40
|
+
}
|
|
41
|
+
if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') {
|
|
42
|
+
return 'image/webp';
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check the `images` of an `ask`: base64 of JPEG/PNG/GIF/WebP, at most {@link MAX_IMAGES} and
|
|
48
|
+
* {@link MAX_IMAGE_BASE64} each. The type is taken from the content, not from what the sender says.
|
|
49
|
+
*
|
|
50
|
+
* @param raw the `images` field: a list of `{ data, mimeType? }` (one object or a bare base64 string work too)
|
|
51
|
+
* @returns the pictures, or the reason they are refused
|
|
52
|
+
*/
|
|
53
|
+
function parseAskImages(raw) {
|
|
54
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
55
|
+
return { images: [] };
|
|
56
|
+
}
|
|
57
|
+
const list = Array.isArray(raw) ? raw : [raw];
|
|
58
|
+
if (list.length > exports.MAX_IMAGES) {
|
|
59
|
+
return { images: [], error: `too many pictures (${list.length}, at most ${exports.MAX_IMAGES})` };
|
|
60
|
+
}
|
|
61
|
+
const images = [];
|
|
62
|
+
for (const item of list) {
|
|
63
|
+
const data = typeof item === 'string' ? item : item?.data;
|
|
64
|
+
if (typeof data !== 'string' || !data) {
|
|
65
|
+
return { images: [], error: 'a picture without data' };
|
|
66
|
+
}
|
|
67
|
+
// a data URL is fine as well
|
|
68
|
+
const base64 = data.replace(/^data:[^;,]*;base64,/, '').replace(/\s+/g, '');
|
|
69
|
+
if (base64.length > exports.MAX_IMAGE_BASE64) {
|
|
70
|
+
return {
|
|
71
|
+
images: [],
|
|
72
|
+
error: `picture too big (${Math.round((base64.length * 3) / 4 / 1024)} KB, at most ${(exports.MAX_IMAGE_BASE64 * 3) / 4 / 1024} KB)`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const mimeType = sniffImageType(Buffer.from(base64.slice(0, 32), 'base64'));
|
|
76
|
+
if (!mimeType || !IMAGE_TYPES.has(mimeType)) {
|
|
77
|
+
return { images: [], error: 'not a JPEG, PNG, GIF or WebP picture' };
|
|
78
|
+
}
|
|
79
|
+
images.push({ data: base64, mimeType });
|
|
80
|
+
}
|
|
81
|
+
return { images };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Check the `video` of an `ask`: only a reference into the ioBroker file storage, never the content.
|
|
85
|
+
*
|
|
86
|
+
* @param raw the `video` field
|
|
87
|
+
* @returns the reference, `null` without one, or the reason it is refused (e.g. base64 content)
|
|
88
|
+
*/
|
|
89
|
+
function parseAskVideo(raw) {
|
|
90
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
94
|
+
return { error: 'video must be { adapter, path } of the ioBroker file storage' };
|
|
95
|
+
}
|
|
96
|
+
const v = raw;
|
|
97
|
+
if ('data' in v) {
|
|
98
|
+
return { error: 'a video is not sent as content - store it and send { adapter, path }' };
|
|
99
|
+
}
|
|
100
|
+
const str = (x) => (typeof x === 'string' ? x.trim() : '');
|
|
101
|
+
const num = (x) => typeof x === 'number' && Number.isFinite(x) && x >= 0 ? x : undefined;
|
|
102
|
+
const ref = {
|
|
103
|
+
adapter: str(v.adapter),
|
|
104
|
+
path: str(v.path).replace(/^\/+/, ''),
|
|
105
|
+
mimeType: str(v.mimeType) || undefined,
|
|
106
|
+
durationSec: num(v.durationSec),
|
|
107
|
+
size: num(v.size),
|
|
108
|
+
error: str(v.error) || undefined,
|
|
109
|
+
};
|
|
110
|
+
if ((!ref.adapter || !ref.path) && !ref.error) {
|
|
111
|
+
return { error: 'video without adapter and path' };
|
|
112
|
+
}
|
|
113
|
+
return ref;
|
|
114
|
+
}
|
|
115
|
+
/** Whether {@link parseAskVideo} refused the video */
|
|
116
|
+
function isVideoError(v) {
|
|
117
|
+
return !!v && !('adapter' in v);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Copy of a request / message list with the pictures replaced by a short note — for the trace and the debug log,
|
|
121
|
+
* where hundreds of KB of base64 per round help nobody. Understands the OpenAI (`image_url` with a data URL) and the
|
|
122
|
+
* Anthropic form (`image` with a base64 `source`).
|
|
123
|
+
*
|
|
124
|
+
* @param value what to copy
|
|
125
|
+
*/
|
|
126
|
+
function redactImages(value) {
|
|
127
|
+
const walk = (v) => {
|
|
128
|
+
if (Array.isArray(v)) {
|
|
129
|
+
return v.map(walk);
|
|
130
|
+
}
|
|
131
|
+
if (!v || typeof v !== 'object') {
|
|
132
|
+
return v;
|
|
133
|
+
}
|
|
134
|
+
const o = v;
|
|
135
|
+
if (o.type === 'image_url' && typeof o.image_url?.url === 'string' && o.image_url.url.startsWith('data:')) {
|
|
136
|
+
return { ...o, image_url: { ...o.image_url, url: imageNote(o.image_url.url) } };
|
|
137
|
+
}
|
|
138
|
+
if (o.type === 'image' && o.source?.type === 'base64' && typeof o.source.data === 'string') {
|
|
139
|
+
return { ...o, source: { ...o.source, data: imageNote(o.source.data, o.source.media_type) } };
|
|
140
|
+
}
|
|
141
|
+
const out = {};
|
|
142
|
+
for (const [k, x] of Object.entries(o)) {
|
|
143
|
+
out[k] = walk(x);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
};
|
|
147
|
+
return walk(value);
|
|
148
|
+
}
|
|
149
|
+
function imageNote(data, mimeType) {
|
|
150
|
+
const m = /^data:([^;,]+)/.exec(data);
|
|
151
|
+
const base64 = data.replace(/^data:[^,]*,/, '');
|
|
152
|
+
return `<${mimeType || m?.[1] || 'image'}, ${Math.round((base64.length * 3) / 4 / 1024)} KB>`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The question for pictures that came without one (a photo without caption).
|
|
156
|
+
*
|
|
157
|
+
* @param lang language of the answer
|
|
158
|
+
* @param count number of pictures
|
|
159
|
+
*/
|
|
160
|
+
function imageOnlyQuestion(lang, count = 1) {
|
|
161
|
+
if (lang === 'ru') {
|
|
162
|
+
return count > 1 ? 'Что на этих картинках?' : 'Что на этой картинке?';
|
|
163
|
+
}
|
|
164
|
+
if (lang === 'de') {
|
|
165
|
+
return count > 1 ? 'Was ist auf diesen Bildern?' : 'Was ist auf diesem Bild?';
|
|
166
|
+
}
|
|
167
|
+
return count > 1 ? 'What is in these pictures?' : 'What is in this picture?';
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The answer to a video that came without a question.
|
|
171
|
+
*
|
|
172
|
+
* @param lang language of the answer
|
|
173
|
+
*/
|
|
174
|
+
function videoNotSupportedText(lang) {
|
|
175
|
+
if (lang === 'ru') {
|
|
176
|
+
return 'Видео я пока смотреть не умею.';
|
|
177
|
+
}
|
|
178
|
+
if (lang === 'de') {
|
|
179
|
+
return 'Videos kann ich mir noch nicht ansehen.';
|
|
180
|
+
}
|
|
181
|
+
return "I can't watch videos yet.";
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The answer to pictures when there is no cloud model to look at them.
|
|
185
|
+
*
|
|
186
|
+
* @param lang language of the answer
|
|
187
|
+
*/
|
|
188
|
+
function noVisionText(lang) {
|
|
189
|
+
if (lang === 'ru') {
|
|
190
|
+
return 'Картинки я могу посмотреть только с облачной моделью.';
|
|
191
|
+
}
|
|
192
|
+
if (lang === 'de') {
|
|
193
|
+
return 'Bilder kann ich mir nur mit einem Cloud-Modell ansehen.';
|
|
194
|
+
}
|
|
195
|
+
return 'I can only look at pictures with a cloud model.';
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Note for the model in the user turn: what came along besides the text. A model that does not get the pictures
|
|
199
|
+
* (an older pi-agent) at least knows that there were some.
|
|
200
|
+
*
|
|
201
|
+
* @param images number of pictures attached
|
|
202
|
+
* @param video whether a video came along
|
|
203
|
+
*/
|
|
204
|
+
function attachmentNote(images, video) {
|
|
205
|
+
const parts = [];
|
|
206
|
+
if (images) {
|
|
207
|
+
parts.push(images > 1 ? `${images} pictures are attached.` : 'A picture is attached.');
|
|
208
|
+
}
|
|
209
|
+
if (video) {
|
|
210
|
+
parts.push('The user also sent a video, which you cannot watch yet - say so if the question is about it.');
|
|
211
|
+
}
|
|
212
|
+
return parts.length ? `(${parts.join(' ')})` : '';
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* How the question is kept in the conversation history: the pictures are not repeated in later turns, a marker says
|
|
216
|
+
* there were some.
|
|
217
|
+
*
|
|
218
|
+
* @param question the question
|
|
219
|
+
* @param images number of pictures
|
|
220
|
+
* @param video whether a video came along
|
|
221
|
+
*/
|
|
222
|
+
function historyText(question, images, video) {
|
|
223
|
+
const marks = [images ? `[${images > 1 ? `${images} pictures` : 'picture'}]` : '', video ? '[video]' : ''];
|
|
224
|
+
return [...marks, question].filter(Boolean).join(' ');
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=media.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media.js","sourceRoot":"","sources":["../../src/lib/media.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Pictures and videos that come with a question (`ask` with `images` / `video`, e.g. from Telegram).\n *\n * Pictures go to the cloud model as they are (base64, the vendors take JPEG/PNG/GIF/WebP). Videos are not looked at\n * yet: the sender keeps them in the ioBroker file storage and only says where, so a video never travels through the\n * message bus as base64.\n */\n\n/** A picture for the model: base64 without a `data:` prefix and its type. */\nexport interface ImageInput {\n data: string;\n mimeType: string;\n}\n\n/** Where the sender stored a video (ioBroker file storage), with what it knows about it. */\nexport interface VideoRef {\n /** File storage namespace, e.g. `telegram.0` */\n adapter: string;\n /** Path in it, e.g. `assistant/video_2026-11-04_10-00-00.mp4` */\n path: string;\n mimeType?: string;\n durationSec?: number;\n size?: number;\n /** Set when the sender could not store it (e.g. too big to download) */\n error?: string;\n}\n\n/** At most this many pictures per question (an album of Telegram has up to 10) */\nexport const MAX_IMAGES = 4;\n\n/** Largest picture as base64 text: Anthropic refuses images over 5 MB */\nexport const MAX_IMAGE_BASE64 = 5 * 1024 * 1024;\n\nconst IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);\n\n/**\n * The type of a picture from its first bytes, `null` if it is none of the four the vendors take.\n *\n * @param buf the start of the file (16 bytes are enough)\n */\nexport function sniffImageType(buf: Buffer): string | null {\n if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {\n return 'image/jpeg';\n }\n if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {\n return 'image/png';\n }\n if (buf.length >= 6 && /^GIF8[79]a$/.test(buf.subarray(0, 6).toString('latin1'))) {\n return 'image/gif';\n }\n if (buf.length >= 12 && buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') {\n return 'image/webp';\n }\n return null;\n}\n\n/**\n * Check the `images` of an `ask`: base64 of JPEG/PNG/GIF/WebP, at most {@link MAX_IMAGES} and\n * {@link MAX_IMAGE_BASE64} each. The type is taken from the content, not from what the sender says.\n *\n * @param raw the `images` field: a list of `{ data, mimeType? }` (one object or a bare base64 string work too)\n * @returns the pictures, or the reason they are refused\n */\nexport function parseAskImages(raw: unknown): { images: ImageInput[]; error?: string } {\n if (raw === undefined || raw === null || raw === '') {\n return { images: [] };\n }\n const list = Array.isArray(raw) ? raw : [raw];\n if (list.length > MAX_IMAGES) {\n return { images: [], error: `too many pictures (${list.length}, at most ${MAX_IMAGES})` };\n }\n const images: ImageInput[] = [];\n for (const item of list) {\n const data = typeof item === 'string' ? item : (item as { data?: unknown } | null)?.data;\n if (typeof data !== 'string' || !data) {\n return { images: [], error: 'a picture without data' };\n }\n // a data URL is fine as well\n const base64 = data.replace(/^data:[^;,]*;base64,/, '').replace(/\\s+/g, '');\n if (base64.length > MAX_IMAGE_BASE64) {\n return {\n images: [],\n error: `picture too big (${Math.round((base64.length * 3) / 4 / 1024)} KB, at most ${(MAX_IMAGE_BASE64 * 3) / 4 / 1024} KB)`,\n };\n }\n const mimeType = sniffImageType(Buffer.from(base64.slice(0, 32), 'base64'));\n if (!mimeType || !IMAGE_TYPES.has(mimeType)) {\n return { images: [], error: 'not a JPEG, PNG, GIF or WebP picture' };\n }\n images.push({ data: base64, mimeType });\n }\n return { images };\n}\n\n/**\n * Check the `video` of an `ask`: only a reference into the ioBroker file storage, never the content.\n *\n * @param raw the `video` field\n * @returns the reference, `null` without one, or the reason it is refused (e.g. base64 content)\n */\nexport function parseAskVideo(raw: unknown): VideoRef | null | { error: string } {\n if (raw === undefined || raw === null || raw === '') {\n return null;\n }\n if (typeof raw !== 'object' || Array.isArray(raw)) {\n return { error: 'video must be { adapter, path } of the ioBroker file storage' };\n }\n const v = raw as Record<string, unknown>;\n if ('data' in v) {\n return { error: 'a video is not sent as content - store it and send { adapter, path }' };\n }\n const str = (x: unknown): string => (typeof x === 'string' ? x.trim() : '');\n const num = (x: unknown): number | undefined =>\n typeof x === 'number' && Number.isFinite(x) && x >= 0 ? x : undefined;\n const ref: VideoRef = {\n adapter: str(v.adapter),\n path: str(v.path).replace(/^\\/+/, ''),\n mimeType: str(v.mimeType) || undefined,\n durationSec: num(v.durationSec),\n size: num(v.size),\n error: str(v.error) || undefined,\n };\n if ((!ref.adapter || !ref.path) && !ref.error) {\n return { error: 'video without adapter and path' };\n }\n return ref;\n}\n\n/** Whether {@link parseAskVideo} refused the video */\nexport function isVideoError(v: VideoRef | null | { error: string }): v is { error: string } {\n return !!v && !('adapter' in v);\n}\n\n/**\n * Copy of a request / message list with the pictures replaced by a short note — for the trace and the debug log,\n * where hundreds of KB of base64 per round help nobody. Understands the OpenAI (`image_url` with a data URL) and the\n * Anthropic form (`image` with a base64 `source`).\n *\n * @param value what to copy\n */\nexport function redactImages<T>(value: T): T {\n const walk = (v: unknown): unknown => {\n if (Array.isArray(v)) {\n return v.map(walk);\n }\n if (!v || typeof v !== 'object') {\n return v;\n }\n const o = v as Record<string, any>;\n if (o.type === 'image_url' && typeof o.image_url?.url === 'string' && o.image_url.url.startsWith('data:')) {\n return { ...o, image_url: { ...o.image_url, url: imageNote(o.image_url.url) } };\n }\n if (o.type === 'image' && o.source?.type === 'base64' && typeof o.source.data === 'string') {\n return { ...o, source: { ...o.source, data: imageNote(o.source.data, o.source.media_type) } };\n }\n const out: Record<string, unknown> = {};\n for (const [k, x] of Object.entries(o)) {\n out[k] = walk(x);\n }\n return out;\n };\n return walk(value) as T;\n}\n\nfunction imageNote(data: string, mimeType?: string): string {\n const m = /^data:([^;,]+)/.exec(data);\n const base64 = data.replace(/^data:[^,]*,/, '');\n return `<${mimeType || m?.[1] || 'image'}, ${Math.round((base64.length * 3) / 4 / 1024)} KB>`;\n}\n\n/** Language of the texts below: de, ru, else en */\ntype Lang = string;\n\n/**\n * The question for pictures that came without one (a photo without caption).\n *\n * @param lang language of the answer\n * @param count number of pictures\n */\nexport function imageOnlyQuestion(lang: Lang, count = 1): string {\n if (lang === 'ru') {\n return count > 1 ? 'Что на этих картинках?' : 'Что на этой картинке?';\n }\n if (lang === 'de') {\n return count > 1 ? 'Was ist auf diesen Bildern?' : 'Was ist auf diesem Bild?';\n }\n return count > 1 ? 'What is in these pictures?' : 'What is in this picture?';\n}\n\n/**\n * The answer to a video that came without a question.\n *\n * @param lang language of the answer\n */\nexport function videoNotSupportedText(lang: Lang): string {\n if (lang === 'ru') {\n return 'Видео я пока смотреть не умею.';\n }\n if (lang === 'de') {\n return 'Videos kann ich mir noch nicht ansehen.';\n }\n return \"I can't watch videos yet.\";\n}\n\n/**\n * The answer to pictures when there is no cloud model to look at them.\n *\n * @param lang language of the answer\n */\nexport function noVisionText(lang: Lang): string {\n if (lang === 'ru') {\n return 'Картинки я могу посмотреть только с облачной моделью.';\n }\n if (lang === 'de') {\n return 'Bilder kann ich mir nur mit einem Cloud-Modell ansehen.';\n }\n return 'I can only look at pictures with a cloud model.';\n}\n\n/**\n * Note for the model in the user turn: what came along besides the text. A model that does not get the pictures\n * (an older pi-agent) at least knows that there were some.\n *\n * @param images number of pictures attached\n * @param video whether a video came along\n */\nexport function attachmentNote(images: number, video: boolean): string {\n const parts: string[] = [];\n if (images) {\n parts.push(images > 1 ? `${images} pictures are attached.` : 'A picture is attached.');\n }\n if (video) {\n parts.push('The user also sent a video, which you cannot watch yet - say so if the question is about it.');\n }\n return parts.length ? `(${parts.join(' ')})` : '';\n}\n\n/**\n * How the question is kept in the conversation history: the pictures are not repeated in later turns, a marker says\n * there were some.\n *\n * @param question the question\n * @param images number of pictures\n * @param video whether a video came along\n */\nexport function historyText(question: string, images: number, video: boolean): string {\n const marks = [images ? `[${images > 1 ? `${images} pictures` : 'picture'}]` : '', video ? '[video]' : ''];\n return [...marks, question].filter(Boolean).join(' ');\n}\n"]}
|
package/build/lib/piAgent.js
CHANGED
|
@@ -77,6 +77,7 @@ class PiAgentHarness {
|
|
|
77
77
|
conversation: conversationOf(req.conversation),
|
|
78
78
|
instructions: req.instructions,
|
|
79
79
|
...(req.language ? { language: req.language } : {}),
|
|
80
|
+
...(req.images?.length ? { images: req.images } : {}),
|
|
80
81
|
requestId,
|
|
81
82
|
}, { timeout: this.timeoutMs }));
|
|
82
83
|
if (result?.error) {
|
package/build/lib/piAgent.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"piAgent.js","sourceRoot":"","sources":["../../src/lib/piAgent.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;GAgBG;;;
|
|
1
|
+
{"version":3,"file":"piAgent.js","sourceRoot":"","sources":["../../src/lib/piAgent.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The adapter `pi-agent` as the cloud tier (\"harness\"): instead of running its own tool loop, the assistant\n * hands the question to a pi-agent instance via `sendTo(<instance>, 'ask', …)`. pi-agent stores every model\n * and tool call as a checkpoint before it runs (an interrupted request continues after a restart, a write is\n * never repeated), enforces its own write permissions and confirmations, and can use the system-wide AI\n * settings of admin — none of which the built-in `LlmAgent` does.\n *\n * Each request source (satellite, `chat`, `telegram:<user>`) gets a conversation of its own in pi-agent\n * (`conversation`), so two rooms do not see each other's follow-ups. The source-independent part of the\n * prompt (style, follow-up marker, memory, device list) goes along as the `instructions` of that\n * conversation; what changes every turn (time, weather, presence) is prepended to the question.\n * pi-agent 0.3.0 and older ignore both fields and answer everything in their root conversation.\n *\n * With \"Confirmation by text in sendTo conversations\", pi-agent answers a write it may not do unasked with a\n * question ending in `[[LISTEN]]`, and the next `ask` of the conversation is the yes or no. `language` makes it\n * ask that question in the language the satellite listens in.\n */\n\nimport type { ImageInput } from './media';\n\n/** Longest instructions pi-agent accepts for a conversation; anything beyond is cut there. */\nexport const PI_AGENT_MAX_INSTRUCTIONS = 20000;\n\n/** Default for `piAgentTimeoutSec`: a tool loop with several reads can take a while. */\nexport const PI_AGENT_TIMEOUT_SEC = 60;\n\n/** The part of the adapter this module needs (kept small so tests can fake it). */\nexport interface PiAgentHost {\n sendToAsync(instance: string, command: string, message: unknown, options?: { timeout?: number }): Promise<unknown>;\n getForeignStateAsync(id: string): Promise<ioBroker.State | null | undefined>;\n}\n\nexport interface PiAgentRequest {\n /** The text pi-agent answers: context lines + the question. */\n text: string;\n /** Conversation of the caller, e.g. the satellite name. */\n conversation: string;\n /** Instructions of that conversation. */\n instructions: string;\n /**\n * Language the user speaks, e.g. `ru`. pi-agent asks its questions to confirm in it; older versions ask in the\n * system language, and a satellite listening in Russian does not hear a German \"Ja\" (the STT returns nothing).\n */\n language?: string;\n /**\n * Pictures that came with the question (`{ data: base64, mimeType }`, the form of pi-ai's `ImageContent`).\n * Versions of pi-agent before image support ignore them; the text then says that pictures came along.\n */\n images?: ImageInput[];\n}\n\n/**\n * Join instruction blocks, leaving out optional ones from the end until the result fits into pi-agent's\n * limit — pi-agent would cut the text in the middle, and a half device list is worse than none.\n *\n * @param required blocks that always go along (style, follow-up marker)\n * @param optional blocks in order of importance (memory before the device list)\n */\nexport function composeInstructions(required: string[], optional: string[]): { text: string; dropped: number } {\n const blocks = [...required.filter(Boolean)];\n const extra = optional.filter(Boolean);\n let dropped = 0;\n while (extra.length) {\n const text = [...blocks, ...extra].join('\\n\\n');\n if (text.length <= PI_AGENT_MAX_INSTRUCTIONS) {\n return { text, dropped };\n }\n extra.pop();\n dropped++;\n }\n return { text: blocks.join('\\n\\n').slice(0, PI_AGENT_MAX_INSTRUCTIONS), dropped };\n}\n\n/** Conversation name for a request source; pi-agent uses \"/\" as separator in its keys. */\nexport function conversationOf(source: string): string {\n return (source || 'default').replace(/\\//g, '_').slice(0, 100);\n}\n\nexport class PiAgentHarness {\n private seq = 0;\n\n public constructor(\n private readonly host: PiAgentHost,\n /** e.g. `pi-agent.0` */\n public readonly instance: string,\n private readonly timeoutMs: number = PI_AGENT_TIMEOUT_SEC * 1000,\n ) {}\n\n /**\n * Ask pi-agent. Fails fast when the instance is not running (otherwise the caller would wait for the whole\n * timeout), and turns pi-agent's `{error}` into an exception so the caller can fall back to the local model.\n */\n public async ask(req: PiAgentRequest): Promise<string> {\n const alive = await this.host.getForeignStateAsync(`system.adapter.${this.instance}.alive`);\n if (!alive?.val) {\n throw new Error(`${this.instance} is not running`);\n }\n // Unique per request: pi-agent answers a repeated requestId only once.\n const requestId = `assistant-${Date.now().toString(36)}-${(++this.seq).toString(36)}`;\n const result = (await this.host.sendToAsync(\n this.instance,\n 'ask',\n {\n text: req.text,\n conversation: conversationOf(req.conversation),\n instructions: req.instructions,\n ...(req.language ? { language: req.language } : {}),\n ...(req.images?.length ? { images: req.images } : {}),\n requestId,\n },\n { timeout: this.timeoutMs },\n )) as { answer?: unknown; error?: unknown } | null | undefined;\n if (result?.error) {\n const error = typeof result.error === 'string' ? result.error : JSON.stringify(result.error);\n throw new Error(`${this.instance}: ${error}`);\n }\n const answer = typeof result?.answer === 'string' ? result.answer.trim() : '';\n if (!answer) {\n throw new Error(`${this.instance} gave no answer`);\n }\n return answer;\n }\n}\n"]}
|
|
@@ -104,6 +104,9 @@ function buildSttEngine(provider, ctx) {
|
|
|
104
104
|
case 'piper':
|
|
105
105
|
throw new Error('Piper is a text-to-speech engine, not speech-to-text');
|
|
106
106
|
default:
|
|
107
|
+
if (ctx.sttBaseUrl?.trim()) {
|
|
108
|
+
return new stt_1.OpenAiStt(ctx.sttServerKey || '', ctx.sttModel || undefined, ctx.sttBaseUrl);
|
|
109
|
+
}
|
|
107
110
|
requireOpenAi(ctx.creds);
|
|
108
111
|
return new stt_1.OpenAiStt(ctx.creds.openaiKey, ctx.sttModel || undefined);
|
|
109
112
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engines.js","sourceRoot":"","sources":["../../../src/lib/voice/engines.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Factory that builds the configured STT/TTS engine. Cloud engines (OpenAI/Azure/AWS) use resolved\n * credentials; local engines (Vosk/Piper) download their models on demand into the data dir.\n * Adding a provider = one new engine class + one `case` here.\n */\nimport { OpenAiStt, type SttEngine } from './stt';\nimport { OpenAiTts, type OpenAiVoice, type TtsEngine } from './tts';\nimport { AzureStt, AzureTts, listAzureVoices } from './azure';\nimport { AwsStt, AwsTts, listPollyVoices, type AwsCreds } from './aws';\nimport { VoskStt, listVoskModels } from './vosk';\nimport { PiperTts, listPiperVoices } from './piper';\nimport {\n OpenRouterStt,\n OpenRouterTts,\n listOpenRouterSpeechModels,\n voicesForLanguage,\n DEFAULT_OPENROUTER_TTS_MODEL,\n type AudioDecoder,\n} from './openrouter';\nimport * as path from 'node:path';\n\nimport { CachedTts } from './ttsCache';\nimport { FallbackStt, FallbackTts, type NamedEngine } from './fallback';\nimport { isoToLocale } from './lang';\nimport type { VoiceLogger } from './download';\n\nexport type SpeechProvider = 'openai' | 'openrouter' | 'azure' | 'aws' | 'vosk' | 'piper';\n\n/** All speech credentials, resolved from config (or the central credential store). */\nexport interface VoiceCredentials {\n openaiKey: string;\n openrouterKey: string;\n azureKey: string;\n azureRegion: string;\n aws: AwsCreds;\n}\n\n/** Provider-specific voice ids (each provider names voices differently). */\nexport interface VoiceNames {\n openai: string; // alloy/echo/…\n azure: string; // e.g. de-DE-KatjaNeural\n aws: string; // e.g. Vicki\n piper: string; // e.g. de_DE-thorsten-medium\n openrouter: string; // depends on the model, e.g. Kore, george\n}\n\n/** Everything the factory needs to build any engine. */\nexport interface EngineContext {\n creds: VoiceCredentials;\n voices: VoiceNames;\n /** Optional second engine, used when the primary one fails ('' = none). */\n sttFallback?: SpeechProvider | '';\n ttsFallback?: SpeechProvider | '';\n /** Writable instance data dir (for the local engines' downloads). */\n dataDir: string;\n log: VoiceLogger;\n /** Local model overrides ('' → default per language). */\n voskModel: string;\n /** OpenAI STT model ('' → 'whisper-1'; e.g. 'gpt-4o-transcribe'). */\n sttModel: string;\n /** OpenAI TTS model ('' → 'tts-1'; e.g. 'gpt-4o-mini-tts'). */\n ttsModel: string;\n /** OpenRouter models ('' → the defaults of openrouter.ts) */\n openrouterSttModel: string;\n openrouterTtsModel: string;\n /** Decoder for compressed speech (OpenRouter models that only return mp3); absent → those models fail */\n decodeAudio?: AudioDecoder;\n /** Longest text that is spoken, in characters; longer ones are cut at a sentence end. 0 = no limit, absent = 400. */\n ttsMaxChars?: number;\n}\n\nfunction requireAzure(creds: VoiceCredentials): void {\n if (!creds.azureKey || !creds.azureRegion) {\n throw new Error('Azure speech needs a subscription key and region');\n }\n}\n\nfunction requireAws(creds: VoiceCredentials): void {\n if (!creds.aws.accessKeyId || !creds.aws.secretAccessKey || !creds.aws.region) {\n throw new Error('AWS speech needs an access key id, secret access key and region');\n }\n}\n\nfunction requireOpenAi(creds: VoiceCredentials): void {\n if (!creds.openaiKey) {\n throw new Error('OpenAI speech needs an API key');\n }\n}\n\nfunction requireOpenRouter(creds: VoiceCredentials): void {\n if (!creds.openrouterKey) {\n throw new Error('OpenRouter speech needs an API key');\n }\n}\n\nexport function createSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n const primary: NamedEngine<SttEngine> = { name: provider, engine: buildSttEngine(provider, ctx) };\n const fallback = ctx.sttFallback && ctx.sttFallback !== provider ? ctx.sttFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackStt([primary, { name: fallback, engine: buildSttEngine(fallback, ctx) }], ctx.log);\n } catch (e) {\n ctx.log.warn(`Speech-to-text fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\nfunction buildSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureStt(ctx.creds.azureKey, ctx.creds.azureRegion);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsStt(ctx.creds.aws);\n case 'vosk':\n return new VoskStt(ctx.dataDir, ctx.voskModel, ctx.log);\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterStt(ctx.creds.openrouterKey, ctx.openrouterSttModel);\n case 'piper':\n throw new Error('Piper is a text-to-speech engine, not speech-to-text');\n default:\n requireOpenAi(ctx.creds);\n return new OpenAiStt(ctx.creds.openaiKey, ctx.sttModel || undefined);\n }\n}\n\nexport function createTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n // Each engine gets the shared behaviour (disk cache, length limit, SSML) and its own cache directory,\n // so a fallback engine's voice can never be served later while the primary one is healthy again.\n const primary: NamedEngine<TtsEngine> = { name: provider, engine: cachedTts(provider, ctx) };\n const fallback = ctx.ttsFallback && ctx.ttsFallback !== provider ? ctx.ttsFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackTts([primary, { name: fallback, engine: cachedTts(fallback, ctx) }], ctx.log);\n } catch (e) {\n // A fallback that cannot even be built (missing key, wrong provider) must not take the main one down.\n ctx.log.warn(`Text-to-speech fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\n/**\n * One provider binding plus the shared cache/length/SSML layer. One cache directory per provider+voice —\n * a voice change must not serve audio in the old voice.\n */\nfunction cachedTts(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n const voice =\n provider === 'azure'\n ? ctx.voices.azure\n : provider === 'aws'\n ? ctx.voices.aws\n : provider === 'piper'\n ? ctx.voices.piper\n : provider === 'openrouter'\n ? `${ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL}-${ctx.voices.openrouter}`\n : ctx.voices.openai;\n const dir = path.join(ctx.dataDir, 'ttscache', `${provider}-${(voice || 'default').replace(/[^\\w.-]/g, '_')}`);\n return new CachedTts(buildTtsEngine(provider, ctx), { dir, log: ctx.log, maxChars: ctx.ttsMaxChars });\n}\n\nfunction buildTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureTts(ctx.creds.azureKey, ctx.creds.azureRegion, ctx.voices.azure);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsTts(ctx.creds.aws, ctx.voices.aws);\n case 'piper':\n return new PiperTts(ctx.dataDir, ctx.voices.piper, ctx.log);\n case 'vosk':\n throw new Error('Vosk is a speech-to-text engine, not text-to-speech');\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterTts(\n ctx.creds.openrouterKey,\n ctx.openrouterTtsModel,\n ctx.voices.openrouter,\n ctx.decodeAudio,\n );\n default:\n requireOpenAi(ctx.creds);\n return new OpenAiTts(\n ctx.creds.openaiKey,\n (ctx.voices.openai || 'alloy') as OpenAiVoice,\n ctx.ttsModel || undefined,\n );\n }\n}\n\n/** OpenAI TTS voices are a fixed set (no list API). */\nconst OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];\n\n/** List the available TTS voices for a provider (for the settings voice dropdown). */\nexport async function listVoices(provider: SpeechProvider, ctx: EngineContext, language: string): Promise<string[]> {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return listAzureVoices(ctx.creds.azureKey, ctx.creds.azureRegion, isoToLocale(language));\n case 'aws':\n requireAws(ctx.creds);\n return listPollyVoices(ctx.creds.aws, isoToLocale(language));\n case 'piper':\n return listPiperVoices();\n case 'openrouter': {\n // public list, no key needed: the voices of the chosen model\n const model = ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL;\n const models = await listOpenRouterSpeechModels('speech');\n return voicesForLanguage(models.find(m => m.id === model)?.voices || [], language);\n }\n default:\n return OPENAI_VOICES;\n }\n}\n\n/** Suggested STT model names for the settings dropdown: the local Vosk catalogue, or the models of OpenRouter. */\nexport async function listSttModels(provider: SpeechProvider, language: string): Promise<string[]> {\n if (provider === 'openrouter') {\n return (await listOpenRouterSpeechModels('transcription')).map(m => m.id);\n }\n return provider === 'vosk' ? listVoskModels(language) : [];\n}\n\n/** TTS model names for the settings dropdown — only OpenRouter has a choice worth listing. */\nexport async function listTtsModels(provider: SpeechProvider): Promise<string[]> {\n return provider === 'openrouter' ? (await listOpenRouterSpeechModels('speech')).map(m => m.id) : [];\n}\n"]}
|
|
1
|
+
{"version":3,"file":"engines.js","sourceRoot":"","sources":["../../../src/lib/voice/engines.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Factory that builds the configured STT/TTS engine. Cloud engines (OpenAI/Azure/AWS) use resolved\n * credentials; local engines (Vosk/Piper) download their models on demand into the data dir.\n * Adding a provider = one new engine class + one `case` here.\n */\nimport { OpenAiStt, type SttEngine } from './stt';\nimport { OpenAiTts, type OpenAiVoice, type TtsEngine } from './tts';\nimport { AzureStt, AzureTts, listAzureVoices } from './azure';\nimport { AwsStt, AwsTts, listPollyVoices, type AwsCreds } from './aws';\nimport { VoskStt, listVoskModels } from './vosk';\nimport { PiperTts, listPiperVoices } from './piper';\nimport {\n OpenRouterStt,\n OpenRouterTts,\n listOpenRouterSpeechModels,\n voicesForLanguage,\n DEFAULT_OPENROUTER_TTS_MODEL,\n type AudioDecoder,\n} from './openrouter';\nimport * as path from 'node:path';\n\nimport { CachedTts } from './ttsCache';\nimport { FallbackStt, FallbackTts, type NamedEngine } from './fallback';\nimport { isoToLocale } from './lang';\nimport type { VoiceLogger } from './download';\n\nexport type SpeechProvider = 'openai' | 'openrouter' | 'azure' | 'aws' | 'vosk' | 'piper';\n\n/** All speech credentials, resolved from config (or the central credential store). */\nexport interface VoiceCredentials {\n openaiKey: string;\n openrouterKey: string;\n azureKey: string;\n azureRegion: string;\n aws: AwsCreds;\n}\n\n/** Provider-specific voice ids (each provider names voices differently). */\nexport interface VoiceNames {\n openai: string; // alloy/echo/…\n azure: string; // e.g. de-DE-KatjaNeural\n aws: string; // e.g. Vicki\n piper: string; // e.g. de_DE-thorsten-medium\n openrouter: string; // depends on the model, e.g. Kore, george\n}\n\n/** Everything the factory needs to build any engine. */\nexport interface EngineContext {\n creds: VoiceCredentials;\n voices: VoiceNames;\n /** Optional second engine, used when the primary one fails ('' = none). */\n sttFallback?: SpeechProvider | '';\n ttsFallback?: SpeechProvider | '';\n /** Writable instance data dir (for the local engines' downloads). */\n dataDir: string;\n log: VoiceLogger;\n /** Local model overrides ('' → default per language). */\n voskModel: string;\n /** OpenAI STT model ('' → 'whisper-1'; e.g. 'gpt-4o-transcribe'). */\n sttModel: string;\n /**\n * OpenAI-compatible transcription server instead of OpenAI ('' = OpenAI), e.g. a local whisper container.\n * It gets `sttServerKey`, never the OpenAI key — that one is only for OpenAI.\n */\n sttBaseUrl?: string;\n /** Key for `sttBaseUrl` ('' = the server needs none). */\n sttServerKey?: string;\n /** OpenAI TTS model ('' → 'tts-1'; e.g. 'gpt-4o-mini-tts'). */\n ttsModel: string;\n /** OpenRouter models ('' → the defaults of openrouter.ts) */\n openrouterSttModel: string;\n openrouterTtsModel: string;\n /** Decoder for compressed speech (OpenRouter models that only return mp3); absent → those models fail */\n decodeAudio?: AudioDecoder;\n /** Longest text that is spoken, in characters; longer ones are cut at a sentence end. 0 = no limit, absent = 400. */\n ttsMaxChars?: number;\n}\n\nfunction requireAzure(creds: VoiceCredentials): void {\n if (!creds.azureKey || !creds.azureRegion) {\n throw new Error('Azure speech needs a subscription key and region');\n }\n}\n\nfunction requireAws(creds: VoiceCredentials): void {\n if (!creds.aws.accessKeyId || !creds.aws.secretAccessKey || !creds.aws.region) {\n throw new Error('AWS speech needs an access key id, secret access key and region');\n }\n}\n\nfunction requireOpenAi(creds: VoiceCredentials): void {\n if (!creds.openaiKey) {\n throw new Error('OpenAI speech needs an API key');\n }\n}\n\nfunction requireOpenRouter(creds: VoiceCredentials): void {\n if (!creds.openrouterKey) {\n throw new Error('OpenRouter speech needs an API key');\n }\n}\n\nexport function createSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n const primary: NamedEngine<SttEngine> = { name: provider, engine: buildSttEngine(provider, ctx) };\n const fallback = ctx.sttFallback && ctx.sttFallback !== provider ? ctx.sttFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackStt([primary, { name: fallback, engine: buildSttEngine(fallback, ctx) }], ctx.log);\n } catch (e) {\n ctx.log.warn(`Speech-to-text fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\nfunction buildSttEngine(provider: SpeechProvider, ctx: EngineContext): SttEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureStt(ctx.creds.azureKey, ctx.creds.azureRegion);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsStt(ctx.creds.aws);\n case 'vosk':\n return new VoskStt(ctx.dataDir, ctx.voskModel, ctx.log);\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterStt(ctx.creds.openrouterKey, ctx.openrouterSttModel);\n case 'piper':\n throw new Error('Piper is a text-to-speech engine, not speech-to-text');\n default:\n if (ctx.sttBaseUrl?.trim()) {\n return new OpenAiStt(ctx.sttServerKey || '', ctx.sttModel || undefined, ctx.sttBaseUrl);\n }\n requireOpenAi(ctx.creds);\n return new OpenAiStt(ctx.creds.openaiKey, ctx.sttModel || undefined);\n }\n}\n\nexport function createTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n // Each engine gets the shared behaviour (disk cache, length limit, SSML) and its own cache directory,\n // so a fallback engine's voice can never be served later while the primary one is healthy again.\n const primary: NamedEngine<TtsEngine> = { name: provider, engine: cachedTts(provider, ctx) };\n const fallback = ctx.ttsFallback && ctx.ttsFallback !== provider ? ctx.ttsFallback : '';\n if (!fallback) {\n return primary.engine;\n }\n try {\n return new FallbackTts([primary, { name: fallback, engine: cachedTts(fallback, ctx) }], ctx.log);\n } catch (e) {\n // A fallback that cannot even be built (missing key, wrong provider) must not take the main one down.\n ctx.log.warn(`Text-to-speech fallback '${fallback}' unavailable: ${(e as Error).message}`);\n return primary.engine;\n }\n}\n\n/**\n * One provider binding plus the shared cache/length/SSML layer. One cache directory per provider+voice —\n * a voice change must not serve audio in the old voice.\n */\nfunction cachedTts(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n const voice =\n provider === 'azure'\n ? ctx.voices.azure\n : provider === 'aws'\n ? ctx.voices.aws\n : provider === 'piper'\n ? ctx.voices.piper\n : provider === 'openrouter'\n ? `${ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL}-${ctx.voices.openrouter}`\n : ctx.voices.openai;\n const dir = path.join(ctx.dataDir, 'ttscache', `${provider}-${(voice || 'default').replace(/[^\\w.-]/g, '_')}`);\n return new CachedTts(buildTtsEngine(provider, ctx), { dir, log: ctx.log, maxChars: ctx.ttsMaxChars });\n}\n\nfunction buildTtsEngine(provider: SpeechProvider, ctx: EngineContext): TtsEngine {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return new AzureTts(ctx.creds.azureKey, ctx.creds.azureRegion, ctx.voices.azure);\n case 'aws':\n requireAws(ctx.creds);\n return new AwsTts(ctx.creds.aws, ctx.voices.aws);\n case 'piper':\n return new PiperTts(ctx.dataDir, ctx.voices.piper, ctx.log);\n case 'vosk':\n throw new Error('Vosk is a speech-to-text engine, not text-to-speech');\n case 'openrouter':\n requireOpenRouter(ctx.creds);\n return new OpenRouterTts(\n ctx.creds.openrouterKey,\n ctx.openrouterTtsModel,\n ctx.voices.openrouter,\n ctx.decodeAudio,\n );\n default:\n requireOpenAi(ctx.creds);\n return new OpenAiTts(\n ctx.creds.openaiKey,\n (ctx.voices.openai || 'alloy') as OpenAiVoice,\n ctx.ttsModel || undefined,\n );\n }\n}\n\n/** OpenAI TTS voices are a fixed set (no list API). */\nconst OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];\n\n/** List the available TTS voices for a provider (for the settings voice dropdown). */\nexport async function listVoices(provider: SpeechProvider, ctx: EngineContext, language: string): Promise<string[]> {\n switch (provider) {\n case 'azure':\n requireAzure(ctx.creds);\n return listAzureVoices(ctx.creds.azureKey, ctx.creds.azureRegion, isoToLocale(language));\n case 'aws':\n requireAws(ctx.creds);\n return listPollyVoices(ctx.creds.aws, isoToLocale(language));\n case 'piper':\n return listPiperVoices();\n case 'openrouter': {\n // public list, no key needed: the voices of the chosen model\n const model = ctx.openrouterTtsModel || DEFAULT_OPENROUTER_TTS_MODEL;\n const models = await listOpenRouterSpeechModels('speech');\n return voicesForLanguage(models.find(m => m.id === model)?.voices || [], language);\n }\n default:\n return OPENAI_VOICES;\n }\n}\n\n/** Suggested STT model names for the settings dropdown: the local Vosk catalogue, or the models of OpenRouter. */\nexport async function listSttModels(provider: SpeechProvider, language: string): Promise<string[]> {\n if (provider === 'openrouter') {\n return (await listOpenRouterSpeechModels('transcription')).map(m => m.id);\n }\n return provider === 'vosk' ? listVoskModels(language) : [];\n}\n\n/** TTS model names for the settings dropdown — only OpenRouter has a choice worth listing. */\nexport async function listTtsModels(provider: SpeechProvider): Promise<string[]> {\n return provider === 'openrouter' ? (await listOpenRouterSpeechModels('speech')).map(m => m.id) : [];\n}\n"]}
|