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
package/build/main.js
CHANGED
|
@@ -37,6 +37,7 @@ const os = __importStar(require("node:os"));
|
|
|
37
37
|
const fs = __importStar(require("node:fs"));
|
|
38
38
|
const node_child_process_1 = require("node:child_process");
|
|
39
39
|
const node_async_hooks_1 = require("node:async_hooks");
|
|
40
|
+
const node_util_1 = require("node:util");
|
|
40
41
|
const adapter_core_1 = require("@iobroker/adapter-core");
|
|
41
42
|
const path = __importStar(require("node:path"));
|
|
42
43
|
const mcp_server_1 = require("@iobroker/mcp-server");
|
|
@@ -69,7 +70,9 @@ const engines_1 = require("./lib/voice/engines");
|
|
|
69
70
|
const openrouter_1 = require("./lib/voice/openrouter");
|
|
70
71
|
const ttsCache_1 = require("./lib/voice/ttsCache");
|
|
71
72
|
const captures_1 = require("./lib/voice/captures");
|
|
73
|
+
const ffmpegStatic_1 = require("./lib/voice/ffmpegStatic");
|
|
72
74
|
const piAgent_1 = require("./lib/piAgent");
|
|
75
|
+
const media_1 = require("./lib/media");
|
|
73
76
|
const systemAi_1 = require("./lib/systemAi");
|
|
74
77
|
/**
|
|
75
78
|
* How long a rendered weather context line stays valid. Weather adapters refresh every few minutes at best,
|
|
@@ -89,6 +92,7 @@ const NLU_DEVICES_TTL_MS = 60_000;
|
|
|
89
92
|
* confirmation by text.
|
|
90
93
|
*/
|
|
91
94
|
const SCRIPT_TOPIC_MS = 2 * 60_000;
|
|
95
|
+
const NO_ATTACHMENTS = { images: [], video: null };
|
|
92
96
|
/** Default for `localLlmTimeoutSec`: after this the local model is stopped and the next tier answers. */
|
|
93
97
|
const LOCAL_LLM_TIMEOUT_SEC = 15;
|
|
94
98
|
/**
|
|
@@ -103,10 +107,14 @@ const LOCAL_LLM_MAX_TOKENS = 256;
|
|
|
103
107
|
/** Fields of the Voice tab the voice test takes from the unsaved form (everything an STT or TTS engine is built from). */
|
|
104
108
|
/** Transports whose answer is spoken by a satellite (and therefore cut at `ttsMaxChars`). */
|
|
105
109
|
const VOICE_TRANSPORTS = new Set(['esphome', 'udp', 'native', 'wyoming']);
|
|
110
|
+
/** Appended to a failed ffmpeg start: the usual cause is a missing binary or a wrong path. */
|
|
111
|
+
const FFMPEG_HINT = ' — install ffmpeg, or download it or set its path on the Voice tab';
|
|
106
112
|
const VOICE_FORM_FIELDS = [
|
|
107
113
|
'voiceLanguage',
|
|
108
114
|
'sttProvider',
|
|
109
115
|
'sttModel',
|
|
116
|
+
'sttBaseUrl',
|
|
117
|
+
'sttServerKey',
|
|
110
118
|
'openrouterSttModel',
|
|
111
119
|
'voskModel',
|
|
112
120
|
'ttsProvider',
|
|
@@ -131,7 +139,13 @@ const VOICE_FORM_FIELDS = [
|
|
|
131
139
|
'piperVoice',
|
|
132
140
|
];
|
|
133
141
|
/** Of those, the protected ones: the browser never gets them, so an empty value there means "keep the saved one" */
|
|
134
|
-
const VOICE_FORM_SECRETS = [
|
|
142
|
+
const VOICE_FORM_SECRETS = [
|
|
143
|
+
'voiceApiKey',
|
|
144
|
+
'openrouterSpeechKey',
|
|
145
|
+
'azureSpeechKey',
|
|
146
|
+
'awsSecretAccessKey',
|
|
147
|
+
'sttServerKey',
|
|
148
|
+
];
|
|
135
149
|
/** An empty `ask` from the same caller is reported at most this often. */
|
|
136
150
|
const EMPTY_ASK_REPORT_MS = 60_000;
|
|
137
151
|
/** How long to wait for a device to confirm a write, and how often to look. */
|
|
@@ -310,6 +324,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
310
324
|
localLlmLoading = null;
|
|
311
325
|
/** Last reported local-LLM download percent (to throttle state writes / avoid log spam). */
|
|
312
326
|
lastLlmPct = -1;
|
|
327
|
+
/** A running "Download ffmpeg" - a second click waits for the same download. */
|
|
328
|
+
ffmpegDownload = null;
|
|
313
329
|
/** UDP voice server for satellites (only when voiceEnabled); null otherwise. */
|
|
314
330
|
voice = null;
|
|
315
331
|
/** Wyoming TCP endpoint (only when wyomingEnabled); null otherwise. */
|
|
@@ -644,6 +660,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
644
660
|
sttMs: origin.sttMs,
|
|
645
661
|
audioMs: origin.audioMs,
|
|
646
662
|
vad: origin.vad,
|
|
663
|
+
button: origin.button,
|
|
647
664
|
}),
|
|
648
665
|
onSpoken: (device, info) => this.reportSpoken(device, info),
|
|
649
666
|
getHints: () => this.buildSttHints(),
|
|
@@ -700,6 +717,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
700
717
|
log: this.log,
|
|
701
718
|
voskModel: (cfg.voskModel || '').trim(),
|
|
702
719
|
sttModel: (cfg.sttModel || '').trim(),
|
|
720
|
+
sttBaseUrl: (cfg.sttBaseUrl || '').trim(),
|
|
721
|
+
sttServerKey: cfg.sttServerKey || '',
|
|
703
722
|
ttsModel: (cfg.ttsModel || '').trim(),
|
|
704
723
|
openrouterSttModel: (cfg.openrouterSttModel || '').trim(),
|
|
705
724
|
openrouterTtsModel: (cfg.openrouterTtsModel || '').trim(),
|
|
@@ -806,11 +825,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
806
825
|
}
|
|
807
826
|
/**
|
|
808
827
|
* True when the assistant's reply asks the user something, so a satellite should re-open its mic to
|
|
809
|
-
* capture the answer without a wake word (→ the `listen` flag of the voice response).
|
|
810
|
-
* mark anywhere, not just at the end, because a clarifying reply often reads "Welches Gerät …? Sag mir …".
|
|
828
|
+
* capture the answer without a wake word (→ the `listen` flag of the voice response). See {@link endsWithQuestion}.
|
|
811
829
|
*/
|
|
812
830
|
expectsFollowUp(answer) {
|
|
813
|
-
return
|
|
831
|
+
return (0, ask_1.endsWithQuestion)(answer);
|
|
814
832
|
}
|
|
815
833
|
/** A transport synthesised the reply to `source`'s latest request — the journal shows it with that request. */
|
|
816
834
|
reportSpoken(source, info) {
|
|
@@ -1985,21 +2003,57 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1985
2003
|
decodeAudioToPcm(src) {
|
|
1986
2004
|
return this.runFfmpegDecode(src);
|
|
1987
2005
|
}
|
|
1988
|
-
/**
|
|
1989
|
-
|
|
1990
|
-
|
|
2006
|
+
/**
|
|
2007
|
+
* Decode an in-memory audio buffer (uploaded mp3/wav, a Telegram voice message) to mono 16-bit PCM via ffmpeg
|
|
2008
|
+
* (stdin pipe). `sampleRate` 16 kHz is what speech recognition wants; playback keeps 24 kHz.
|
|
2009
|
+
*/
|
|
2010
|
+
decodeAudioBufferToPcm(buf, sampleRate = 24000) {
|
|
2011
|
+
return this.runFfmpegDecode('pipe:0', buf, sampleRate);
|
|
2012
|
+
}
|
|
2013
|
+
/** The ffmpeg binary: the configured path, else the downloaded one, else `ffmpeg` from the search path. */
|
|
2014
|
+
ffmpegBin() {
|
|
2015
|
+
return this.config.ffmpegPath?.trim() || this.downloadedFfmpeg() || 'ffmpeg';
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* The static ffmpeg from "Download ffmpeg" on the Voice tab, '' if there is none. Looked up every time (one
|
|
2019
|
+
* `stat`), so deleting the file falls back to the search path without a restart.
|
|
2020
|
+
*/
|
|
2021
|
+
downloadedFfmpeg() {
|
|
2022
|
+
const file = (0, ffmpegStatic_1.ffmpegStaticPath)(this.instanceDataDir());
|
|
2023
|
+
return fs.existsSync(file) ? file : '';
|
|
2024
|
+
}
|
|
2025
|
+
/**
|
|
2026
|
+
* "Download ffmpeg" on the Voice tab: the static build of ffmpeg-static into the data dir, then the same check
|
|
2027
|
+
* as "Check ffmpeg". It is used from now on, unless a path is entered.
|
|
2028
|
+
*
|
|
2029
|
+
* @param configuredPath the path from the (unsaved) form
|
|
2030
|
+
*/
|
|
2031
|
+
async downloadFfmpeg(configuredPath) {
|
|
2032
|
+
let file;
|
|
2033
|
+
try {
|
|
2034
|
+
this.ffmpegDownload ??= (0, ffmpegStatic_1.downloadFfmpegStatic)(this.instanceDataDir(), { log: this.log }).finally(() => (this.ffmpegDownload = null));
|
|
2035
|
+
file = await this.ffmpegDownload;
|
|
2036
|
+
}
|
|
2037
|
+
catch (e) {
|
|
2038
|
+
this.log.warn(`Download of ffmpeg failed: ${e.message}`);
|
|
2039
|
+
return { error: `Download failed: ${e.message}` };
|
|
2040
|
+
}
|
|
2041
|
+
const check = await this.testFfmpeg(file);
|
|
2042
|
+
if (check.error || !configuredPath.trim()) {
|
|
2043
|
+
return check;
|
|
2044
|
+
}
|
|
2045
|
+
return { result: `${check.result} — not used while a path to ffmpeg is entered` };
|
|
1991
2046
|
}
|
|
1992
2047
|
/** Run ffmpeg to decode `input` (a path/URL, or `pipe:0` with `stdinData`) to mono 16-bit PCM. */
|
|
1993
|
-
runFfmpegDecode(input, stdinData) {
|
|
2048
|
+
runFfmpegDecode(input, stdinData, sampleRate = 24000) {
|
|
1994
2049
|
return new Promise((resolve, reject) => {
|
|
1995
|
-
const sampleRate = 24000;
|
|
1996
2050
|
// prettier-ignore
|
|
1997
2051
|
const args = ['-hide_banner', '-loglevel', 'error', '-i', input, '-ac', '1', '-ar', String(sampleRate), '-f', 's16le', '-'];
|
|
1998
|
-
const proc = (0, node_child_process_1.spawn)(
|
|
2052
|
+
const proc = (0, node_child_process_1.spawn)(this.ffmpegBin(), args);
|
|
1999
2053
|
const chunks = [];
|
|
2000
2054
|
proc.stdout.on('data', (d) => chunks.push(d));
|
|
2001
2055
|
proc.stderr.on('data', (d) => this.log.debug(`ffmpeg: ${String(d).trim()}`));
|
|
2002
|
-
proc.on('error', e => reject(new Error(`ffmpeg failed: ${e.message}
|
|
2056
|
+
proc.on('error', e => reject(new Error(`ffmpeg failed: ${e.message}${FFMPEG_HINT}`)));
|
|
2003
2057
|
proc.on('close', code => code === 0
|
|
2004
2058
|
? resolve({ pcm: Buffer.concat(chunks), sampleRate })
|
|
2005
2059
|
: reject(new Error(`ffmpeg exited with code ${code}`)));
|
|
@@ -2026,11 +2080,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2026
2080
|
? 48000
|
|
2027
2081
|
: target.sampleRate;
|
|
2028
2082
|
const args = [...input, '-ar', String(rate), '-ac', String(target.channels), ...output, '-'];
|
|
2029
|
-
const proc = (0, node_child_process_1.spawn)(
|
|
2083
|
+
const proc = (0, node_child_process_1.spawn)(this.ffmpegBin(), args);
|
|
2030
2084
|
const chunks = [];
|
|
2031
2085
|
proc.stdout.on('data', (d) => chunks.push(d));
|
|
2032
2086
|
proc.stderr.on('data', (d) => this.log.debug(`ffmpeg: ${String(d).trim()}`));
|
|
2033
|
-
proc.on('error', e => reject(new Error(`ffmpeg failed: ${e.message}`)));
|
|
2087
|
+
proc.on('error', e => reject(new Error(`ffmpeg failed: ${e.message}${FFMPEG_HINT}`)));
|
|
2034
2088
|
proc.on('close', code => code === 0 && chunks.length
|
|
2035
2089
|
? resolve(Buffer.concat(chunks))
|
|
2036
2090
|
: reject(new Error(`ffmpeg exited with code ${code}`)));
|
|
@@ -2038,6 +2092,32 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2038
2092
|
proc.stdin.end(pcm);
|
|
2039
2093
|
});
|
|
2040
2094
|
}
|
|
2095
|
+
/**
|
|
2096
|
+
* "Check ffmpeg" on the Voice tab: start the binary at `path` ('' = from the search path), report its version and
|
|
2097
|
+
* whether it can write MP3 and Opus (ESPHome devices that play nothing else). Decoding needs no extra library.
|
|
2098
|
+
*/
|
|
2099
|
+
async testFfmpeg(path) {
|
|
2100
|
+
const bin = path.trim() || this.downloadedFfmpeg() || 'ffmpeg';
|
|
2101
|
+
const run = async (args) => (await (0, node_util_1.promisify)(node_child_process_1.execFile)(bin, args, { timeout: 10_000, maxBuffer: 4 * 1024 * 1024 })).stdout;
|
|
2102
|
+
try {
|
|
2103
|
+
const firstLine = (await run(['-version'])).split(/\r?\n/)[0];
|
|
2104
|
+
const version = firstLine.replace(/\s+Copyright.*$/i, '').trim();
|
|
2105
|
+
const encoders = await run(['-hide_banner', '-encoders']).catch(() => '');
|
|
2106
|
+
const has = (name) => new RegExp(`\\s${name}\\s`).test(encoders);
|
|
2107
|
+
const missing = ['libmp3lame', 'libopus'].filter(e => !has(e));
|
|
2108
|
+
return {
|
|
2109
|
+
result: `${version} (${bin})${missing.length ? ` — without ${missing.join(', ')}: MP3/Opus replies for ESPHome devices are not possible` : ''}`,
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
catch (e) {
|
|
2113
|
+
const err = e;
|
|
2114
|
+
return {
|
|
2115
|
+
error: err.code === 'ENOENT'
|
|
2116
|
+
? `${bin} not found${path.trim() ? '' : ' in the search path'} — install ffmpeg, download it or enter the full path`
|
|
2117
|
+
: `${bin} failed: ${err.message}`,
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2041
2121
|
/** Vendor and key of the cloud LLM: speech of the same vendor (OpenAI, OpenRouter) reuses the key. */
|
|
2042
2122
|
async speechLlmKey() {
|
|
2043
2123
|
const provider = this.config.provider || 'openai';
|
|
@@ -2642,6 +2722,24 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2642
2722
|
}
|
|
2643
2723
|
return;
|
|
2644
2724
|
}
|
|
2725
|
+
// Voice tab: "Check ffmpeg" with the (unsaved) path from the form. message = { path }.
|
|
2726
|
+
if (obj.command === 'testFfmpeg') {
|
|
2727
|
+
const raw = obj.message?.path;
|
|
2728
|
+
const result = await this.testFfmpeg(typeof raw === 'string' ? raw : this.config.ffmpegPath);
|
|
2729
|
+
if (obj.callback) {
|
|
2730
|
+
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2731
|
+
}
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
// Voice tab: "Download ffmpeg". message = { path } (the unsaved path, only for the result text).
|
|
2735
|
+
if (obj.command === 'downloadFfmpeg') {
|
|
2736
|
+
const raw = obj.message?.path;
|
|
2737
|
+
const result = await this.downloadFfmpeg(typeof raw === 'string' ? raw : this.config.ffmpegPath || '');
|
|
2738
|
+
if (obj.callback) {
|
|
2739
|
+
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2740
|
+
}
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2645
2743
|
// Satellites tab: forget a satellite that is gone. message = { id } (the state id under `satellites.`).
|
|
2646
2744
|
if (obj.command === 'deleteSatellite') {
|
|
2647
2745
|
const raw = obj.message?.id;
|
|
@@ -2869,8 +2967,49 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2869
2967
|
const source = (typeof message === 'string' ? '' : message?.source?.trim()) || 'chat';
|
|
2870
2968
|
// Who sent it (javascript.0, telegram.0, admin.0 for the chat tab) — the way to find a script gone wrong.
|
|
2871
2969
|
const caller = String(obj.from || '').replace(/^system\.adapter\./, '') || 'unknown';
|
|
2872
|
-
|
|
2873
|
-
|
|
2970
|
+
let question = String(text ?? '').trim();
|
|
2971
|
+
// A recording instead of text (a Telegram voice message): recognise it first, then answer as usual.
|
|
2972
|
+
const audio = typeof message === 'string' ? undefined : message?.audio;
|
|
2973
|
+
let heard;
|
|
2974
|
+
if (!question && typeof audio === 'string' && audio) {
|
|
2975
|
+
if (!this.ready) {
|
|
2976
|
+
if (obj.callback) {
|
|
2977
|
+
this.sendTo(obj.from, obj.command, { error: 'agent not ready' }, obj.callback);
|
|
2978
|
+
}
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
try {
|
|
2982
|
+
const res = await this.transcribeAsk(message);
|
|
2983
|
+
if (!res.text) {
|
|
2984
|
+
this.log.info(`Voice Q (ask from ${caller}, ${source}): (empty) — speech recognition returned no text for ${res.audioMs} ms of audio`);
|
|
2985
|
+
if (obj.callback) {
|
|
2986
|
+
this.sendTo(obj.from, obj.command, { heard: '', error: this.nothingHeardText() }, obj.callback);
|
|
2987
|
+
}
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
question = res.text;
|
|
2991
|
+
heard = { sttMs: res.sttMs, audioMs: res.audioMs };
|
|
2992
|
+
}
|
|
2993
|
+
catch (e) {
|
|
2994
|
+
this.log.warn(`Voice message from ${caller} (${source}) not recognised: ${e.message}`);
|
|
2995
|
+
if (obj.callback) {
|
|
2996
|
+
this.sendTo(obj.from, obj.command, { error: e.message }, obj.callback);
|
|
2997
|
+
}
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
// Pictures (a Telegram photo or album) go to the cloud model; a video only as the place it was stored.
|
|
3002
|
+
const attachments = this.askAttachments(message, caller, source);
|
|
3003
|
+
if (typeof attachments === 'string') {
|
|
3004
|
+
if (obj.callback) {
|
|
3005
|
+
this.sendTo(obj.from, obj.command, { error: attachments }, obj.callback);
|
|
3006
|
+
}
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
if (!question && attachments.images.length) {
|
|
3010
|
+
question = (0, media_1.imageOnlyQuestion)(this.answerLanguage(), attachments.images.length);
|
|
3011
|
+
}
|
|
3012
|
+
if (!question && !attachments.video) {
|
|
2874
3013
|
this.reportEmptyAsk(caller);
|
|
2875
3014
|
if (obj.callback) {
|
|
2876
3015
|
this.sendTo(obj.from, obj.command, { error: 'empty question' }, obj.callback);
|
|
@@ -2883,25 +3022,101 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2883
3022
|
}
|
|
2884
3023
|
return;
|
|
2885
3024
|
}
|
|
2886
|
-
|
|
2887
|
-
this.
|
|
3025
|
+
const shown = (0, media_1.historyText)(question, attachments.images.length, !!attachments.video);
|
|
3026
|
+
this.log.info(`${heard ? 'Voice Q' : 'Q'} (${obj.command} from ${caller}): ${shown}${attachments.video ? ` (video ${attachments.video.adapter}/${attachments.video.path})` : ''}`);
|
|
3027
|
+
this.setStateAsync('text.request', { val: shown, ack: true }).catch(() => { });
|
|
2888
3028
|
this.setQuerySource(source);
|
|
2889
3029
|
try {
|
|
2890
|
-
const answer = await this.answer(question, source);
|
|
3030
|
+
const answer = await this.answer(question, source, heard ?? {}, attachments);
|
|
2891
3031
|
this.log.info(`A: ${answer}`);
|
|
2892
3032
|
this.setStateAsync('text.response', { val: answer, ack: true }).catch(() => { });
|
|
2893
3033
|
if (obj.callback) {
|
|
2894
3034
|
// `journaled`: the admin chat gets this exchange from the journal and must not add it twice.
|
|
2895
|
-
|
|
3035
|
+
// `heard`: what was recognised in a recording, so the caller can show it with the answer.
|
|
3036
|
+
this.sendTo(obj.from, obj.command, { answer, journaled: !!this.journal?.enabled, ...(heard ? { heard: question } : {}) }, obj.callback);
|
|
2896
3037
|
}
|
|
2897
3038
|
}
|
|
2898
3039
|
catch (e) {
|
|
2899
3040
|
this.log.error(`Assistant error: ${e.message}`);
|
|
2900
3041
|
if (obj.callback) {
|
|
2901
|
-
this.sendTo(obj.from, obj.command, {
|
|
3042
|
+
this.sendTo(obj.from, obj.command, {
|
|
3043
|
+
error: e.message,
|
|
3044
|
+
journaled: !!this.journal?.enabled,
|
|
3045
|
+
...(heard ? { heard: question } : {}),
|
|
3046
|
+
}, obj.callback);
|
|
2902
3047
|
}
|
|
2903
3048
|
}
|
|
2904
3049
|
}
|
|
3050
|
+
/**
|
|
3051
|
+
* The pictures and the video of an `ask`, or why they are refused (then nothing is answered: a question about a
|
|
3052
|
+
* picture without the picture would be answered with a guess).
|
|
3053
|
+
*/
|
|
3054
|
+
askAttachments(message, caller, source) {
|
|
3055
|
+
if (typeof message === 'string' || !message) {
|
|
3056
|
+
return NO_ATTACHMENTS;
|
|
3057
|
+
}
|
|
3058
|
+
const { images, error } = (0, media_1.parseAskImages)(message.images);
|
|
3059
|
+
if (error) {
|
|
3060
|
+
this.log.warn(`Pictures from ${caller} (${source}) refused: ${error}`);
|
|
3061
|
+
return `pictures: ${error}`;
|
|
3062
|
+
}
|
|
3063
|
+
const video = (0, media_1.parseAskVideo)(message.video);
|
|
3064
|
+
if ((0, media_1.isVideoError)(video)) {
|
|
3065
|
+
this.log.warn(`Video from ${caller} (${source}) refused: ${video.error}`);
|
|
3066
|
+
return video.error;
|
|
3067
|
+
}
|
|
3068
|
+
return images.length || video ? { images, video } : NO_ATTACHMENTS;
|
|
3069
|
+
}
|
|
3070
|
+
/** Pictures and video of the request being answered */
|
|
3071
|
+
attachments() {
|
|
3072
|
+
return this.requestOrigin.getStore()?.attachments ?? NO_ATTACHMENTS;
|
|
3073
|
+
}
|
|
3074
|
+
/** Language of fixed replies: the voice language, else the system language. */
|
|
3075
|
+
answerLanguage() {
|
|
3076
|
+
return String(this.config.voiceLanguage || this.language || 'en');
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* Speech recognition for an `ask` that carries a recording instead of text. `audio` is base64; `format` says what
|
|
3080
|
+
* it is: `wav` and `pcm` (16-bit mono, `sampleRate`, default 16 kHz) are read directly, anything else (`ogg` from
|
|
3081
|
+
* Telegram, mp3, m4a …) goes through ffmpeg. Uses the STT engine of the Voice tab, so voice must be enabled.
|
|
3082
|
+
*/
|
|
3083
|
+
async transcribeAsk(msg) {
|
|
3084
|
+
if (!this.config.voiceEnabled) {
|
|
3085
|
+
throw new Error('speech recognition is off — enable voice on the Voice tab of the assistant');
|
|
3086
|
+
}
|
|
3087
|
+
const engines = await this.getSpeechEngines();
|
|
3088
|
+
if (!engines) {
|
|
3089
|
+
throw new Error('speech recognition is not configured — check the Voice tab of the assistant');
|
|
3090
|
+
}
|
|
3091
|
+
const data = Buffer.from(msg.audio || '', 'base64');
|
|
3092
|
+
const format = (msg.format || '').toLowerCase().replace(/^\./, '');
|
|
3093
|
+
let pcm;
|
|
3094
|
+
let sampleRate;
|
|
3095
|
+
if (format === 'pcm') {
|
|
3096
|
+
pcm = data;
|
|
3097
|
+
sampleRate = Number(msg.sampleRate) || 16000;
|
|
3098
|
+
}
|
|
3099
|
+
else if (data.toString('latin1', 0, 4) === 'RIFF') {
|
|
3100
|
+
({ pcm, sampleRate } = (0, openrouter_1.pcmFromResponse)(data, ''));
|
|
3101
|
+
}
|
|
3102
|
+
else {
|
|
3103
|
+
({ pcm, sampleRate } = await this.decodeAudioBufferToPcm(data, 16000));
|
|
3104
|
+
}
|
|
3105
|
+
const audioMs = Math.round((pcm.length / 2 / sampleRate) * 1000);
|
|
3106
|
+
const language = msg.language || engines.language;
|
|
3107
|
+
const start = Date.now();
|
|
3108
|
+
const text = (await engines.stt.transcribe(pcm, sampleRate, language, await this.buildSttHints())).trim();
|
|
3109
|
+
return { text, sttMs: Date.now() - start, audioMs };
|
|
3110
|
+
}
|
|
3111
|
+
/** Reply to a voice message in which speech recognition found nothing. */
|
|
3112
|
+
nothingHeardText() {
|
|
3113
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
3114
|
+
return lang === 'ru'
|
|
3115
|
+
? 'В голосовом сообщении я ничего не разобрала.'
|
|
3116
|
+
: lang === 'de'
|
|
3117
|
+
? 'In der Sprachnachricht habe ich nichts verstanden.'
|
|
3118
|
+
: "I couldn't make out anything in the voice message.";
|
|
3119
|
+
}
|
|
2905
3120
|
/**
|
|
2906
3121
|
* Answer a question. Tier 0: the built-in rule-based NLU handles simple device commands offline/free;
|
|
2907
3122
|
* anything it can't resolve falls through to the LLM (Tier 2, and later the local model as Tier 1a).
|
|
@@ -2911,19 +3126,38 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2911
3126
|
* is applied to the cloud LLM tier; every completed exchange (whichever tier answered) is recorded so
|
|
2912
3127
|
* later turns have the full thread.
|
|
2913
3128
|
*/
|
|
2914
|
-
answer(question, source = '', origin = {}) {
|
|
2915
|
-
if (!question.trim()) {
|
|
3129
|
+
answer(question, source = '', origin = {}, attachments = NO_ATTACHMENTS) {
|
|
3130
|
+
if (!question.trim() && !attachments.video) {
|
|
2916
3131
|
// Nothing to answer — and a model asked with nothing but the time context still costs a request.
|
|
2917
3132
|
return Promise.resolve('');
|
|
2918
3133
|
}
|
|
2919
3134
|
const voice = VOICE_TRANSPORTS.has(origin.transport || '');
|
|
2920
|
-
|
|
2921
|
-
|
|
3135
|
+
// The journal shows "[picture] What is in this picture?", like the conversation history
|
|
3136
|
+
const shown = (0, media_1.historyText)(question, attachments.images.length, !!attachments.video);
|
|
3137
|
+
return this.requestOrigin.run({ source, room: origin.room || '', voice, attachments }, () => this.journal
|
|
3138
|
+
? this.journal.run(shown, source, origin, () => {
|
|
2922
3139
|
this.traceSpeechInput(question, origin);
|
|
3140
|
+
this.traceAttachments(attachments);
|
|
2923
3141
|
return this.answerTracked(question, source);
|
|
2924
3142
|
})
|
|
2925
3143
|
: this.answerTracked(question, source));
|
|
2926
3144
|
}
|
|
3145
|
+
/** Pictures and video of the request at the head of its trace (sizes only, not the base64). */
|
|
3146
|
+
traceAttachments({ images, video }) {
|
|
3147
|
+
if (!images.length && !video) {
|
|
3148
|
+
return;
|
|
3149
|
+
}
|
|
3150
|
+
const kb = (base64) => Math.round((base64.length * 3) / 4 / 1024);
|
|
3151
|
+
const parts = [
|
|
3152
|
+
images.length ? `${images.length} picture(s), ${images.reduce((n, i) => n + kb(i.data), 0)} KB` : '',
|
|
3153
|
+
video ? `video ${video.adapter}/${video.path}` : '',
|
|
3154
|
+
].filter(Boolean);
|
|
3155
|
+
this.journal?.step({
|
|
3156
|
+
stage: 'request',
|
|
3157
|
+
title: `Attachments: ${parts.join(', ')}`,
|
|
3158
|
+
data: { images: images.map(i => ({ mimeType: i.mimeType, kb: kb(i.data) })), video: video || undefined },
|
|
3159
|
+
});
|
|
3160
|
+
}
|
|
2927
3161
|
/** Speech recognition happened before the request: put it at the head of its trace. */
|
|
2928
3162
|
traceSpeechInput(question, origin) {
|
|
2929
3163
|
if (origin.sttMs === undefined) {
|
|
@@ -2941,6 +3175,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2941
3175
|
fallback: cfg.sttFallback || undefined,
|
|
2942
3176
|
model: (provider === 'openrouter' ? cfg.openrouterSttModel : cfg.sttModel || cfg.voskModel || '').trim() ||
|
|
2943
3177
|
undefined,
|
|
3178
|
+
server: (provider === 'openai' && cfg.sttBaseUrl?.trim()) || undefined,
|
|
2944
3179
|
language: cfg.voiceLanguage || this.language || '',
|
|
2945
3180
|
audioMs: origin.audioMs,
|
|
2946
3181
|
wakeWord: origin.wakeWord,
|
|
@@ -2967,7 +3202,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2967
3202
|
const followUp = /\[\[LISTEN\]\]/i.test(raw);
|
|
2968
3203
|
this.followUps.set(source, followUp);
|
|
2969
3204
|
// A script conversation goes on while the cloud model asks back: the reply goes to it as well.
|
|
2970
|
-
if (cloudOnly && followUp) {
|
|
3205
|
+
if ((cloudOnly === 'script' || cloudOnly === 'scriptReply') && followUp) {
|
|
2971
3206
|
this.scriptTopics.set(source, Date.now());
|
|
2972
3207
|
}
|
|
2973
3208
|
else {
|
|
@@ -2996,7 +3231,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2996
3231
|
data: { answer: result, raw: raw !== result ? raw : undefined, followUp, tone: toned || undefined },
|
|
2997
3232
|
});
|
|
2998
3233
|
if (useCtx && (result || toned)) {
|
|
2999
|
-
|
|
3234
|
+
// The pictures are not sent again in later turns; a marker says there were some
|
|
3235
|
+
const { images, video } = this.attachments();
|
|
3236
|
+
this.context.add(source, (0, media_1.historyText)(question, images.length, !!video), result || this.doneText());
|
|
3000
3237
|
}
|
|
3001
3238
|
return result;
|
|
3002
3239
|
}
|
|
@@ -3052,7 +3289,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3052
3289
|
return 'script';
|
|
3053
3290
|
}
|
|
3054
3291
|
const since = this.scriptTopics.get(source);
|
|
3055
|
-
|
|
3292
|
+
if (since !== undefined && Date.now() - since < SCRIPT_TOPIC_MS) {
|
|
3293
|
+
return 'scriptReply';
|
|
3294
|
+
}
|
|
3295
|
+
// Rules and the local model are blind: the caption of a photo is about the photo, not a command
|
|
3296
|
+
return this.attachments().images.length ? 'image' : null;
|
|
3056
3297
|
}
|
|
3057
3298
|
/**
|
|
3058
3299
|
* Should the satellite behind `source` re-open its mic after this reply? Primarily the LLM's own
|
|
@@ -3211,6 +3452,14 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3211
3452
|
}
|
|
3212
3453
|
async produceAnswer(question, history, source = '', cloudOnly = null) {
|
|
3213
3454
|
this.log.debug(`answer: source='${source}', context=${history.length} turn(s)`);
|
|
3455
|
+
const { images, video } = this.attachments();
|
|
3456
|
+
if (!question.trim() && video) {
|
|
3457
|
+
// A video without a question: nothing looks at videos yet
|
|
3458
|
+
this.log.info(`Video from '${source}' (${video.adapter}/${video.path}) not looked at: not supported yet.`);
|
|
3459
|
+
this.journal?.note({ tier: 'none' });
|
|
3460
|
+
this.journal?.step({ stage: 'none', title: 'A video without a question: videos are not looked at yet' });
|
|
3461
|
+
return (0, media_1.videoNotSupportedText)(this.answerLanguage());
|
|
3462
|
+
}
|
|
3214
3463
|
// A ringing timer/alarm is silenced by a stop word ("stop"/"halt"/"aufhören"/…) — checked first so
|
|
3215
3464
|
// it always wins over the normal pipeline while something is ringing.
|
|
3216
3465
|
if (this.rings.length && (0, nlu_1.isStopCommand)(question)) {
|
|
@@ -3237,10 +3486,22 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3237
3486
|
return '';
|
|
3238
3487
|
}
|
|
3239
3488
|
}
|
|
3489
|
+
if (images.length && !this.agent && !this.piAgent) {
|
|
3490
|
+
this.log.info(`Pictures from '${source}' not looked at: no cloud model is configured.`);
|
|
3491
|
+
this.journal?.note({ tier: 'none' });
|
|
3492
|
+
this.journal?.step({
|
|
3493
|
+
stage: 'none',
|
|
3494
|
+
level: 'warn',
|
|
3495
|
+
title: 'Pictures came with the request, but no cloud model is configured to look at them',
|
|
3496
|
+
});
|
|
3497
|
+
return (0, media_1.noVisionText)(this.answerLanguage());
|
|
3498
|
+
}
|
|
3240
3499
|
if (cloudOnly) {
|
|
3241
3500
|
const why = cloudOnly === 'script'
|
|
3242
3501
|
? 'it is about a script'
|
|
3243
|
-
:
|
|
3502
|
+
: cloudOnly === 'scriptReply'
|
|
3503
|
+
? 'it answers a question of the cloud model about a script'
|
|
3504
|
+
: 'pictures came with it, only the cloud model can see them';
|
|
3244
3505
|
this.log.info(`Routines, NLU and local LLM skipped (source='${source}'): ${why}.`);
|
|
3245
3506
|
this.journal?.step({
|
|
3246
3507
|
stage: 'nlu',
|
|
@@ -3320,7 +3581,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3320
3581
|
// every time voice and text requests alternate.
|
|
3321
3582
|
const time = this.buildTimeContext();
|
|
3322
3583
|
const lengthHint = this.spokenLengthHint();
|
|
3323
|
-
|
|
3584
|
+
// The pictures themselves go along; only a video needs a word, the model gets nothing of it
|
|
3585
|
+
const questionForLlm = [time, weather, question, (0, media_1.attachmentNote)(0, !!video), lengthHint]
|
|
3586
|
+
.filter(Boolean)
|
|
3587
|
+
.join('\n\n');
|
|
3324
3588
|
this.journal?.note({
|
|
3325
3589
|
tier: 'cloud',
|
|
3326
3590
|
detail: `${this.llm?.provider || this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
|
|
@@ -3340,10 +3604,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3340
3604
|
devices: deviceLines,
|
|
3341
3605
|
time,
|
|
3342
3606
|
weather: weather || undefined,
|
|
3607
|
+
images: images.length || undefined,
|
|
3608
|
+
video: video ? `${video.adapter}/${video.path}` : undefined,
|
|
3343
3609
|
},
|
|
3344
3610
|
});
|
|
3345
3611
|
try {
|
|
3346
|
-
return await this.agent.ask(questionForLlm, sys, history);
|
|
3612
|
+
return await this.agent.ask(questionForLlm, sys, history, images);
|
|
3347
3613
|
}
|
|
3348
3614
|
catch (e) {
|
|
3349
3615
|
// Offline, quota, outage: the local model is the fallback — unless it was already asked above.
|
|
@@ -3376,7 +3642,15 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3376
3642
|
if (dropped) {
|
|
3377
3643
|
this.log.debug(`pi-agent: ${dropped} context block(s) left out, the instructions would be too long.`);
|
|
3378
3644
|
}
|
|
3379
|
-
|
|
3645
|
+
// An older pi-agent drops the pictures: the note at least tells the model that there were some
|
|
3646
|
+
const { images, video } = this.attachments();
|
|
3647
|
+
const text = [
|
|
3648
|
+
this.buildTimeContext(),
|
|
3649
|
+
weather,
|
|
3650
|
+
this.buildPresenceContext(),
|
|
3651
|
+
question,
|
|
3652
|
+
(0, media_1.attachmentNote)(images.length, !!video),
|
|
3653
|
+
]
|
|
3380
3654
|
.filter(Boolean)
|
|
3381
3655
|
.join('\n\n');
|
|
3382
3656
|
this.log.info(`Cloud tier ${harness.instance} (source='${source}') — asking pi-agent…`);
|
|
@@ -3385,7 +3659,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3385
3659
|
// pi-agent asks its questions to confirm a write ("… Say yes or no.") in this language, not in the system
|
|
3386
3660
|
// language: a satellite listening in Russian does not hear a German "Ja"
|
|
3387
3661
|
const language = String(this.config.voiceLanguage || this.language || '');
|
|
3388
|
-
return await harness.ask({ text, conversation: source, instructions, language });
|
|
3662
|
+
return await harness.ask({ text, conversation: source, instructions, language, images });
|
|
3389
3663
|
}
|
|
3390
3664
|
catch (e) {
|
|
3391
3665
|
if (localFallback) {
|