iobroker.assistant 1.4.6 → 1.4.8
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 +18 -1
- package/admin/custom/.vite/manifest.json +1 -1
- package/admin/custom/assets/{Components-BR57MZLJ.js → Components-srYDLjt6.js} +2 -2
- package/admin/custom/customComponents.js +1 -1
- package/admin/custom/index.html +1 -1
- package/admin/custom/mf-manifest.json +1 -1
- package/admin/custom/mf-stats.json +1 -1
- package/admin/i18n/de.json +6 -1
- package/admin/i18n/en.json +6 -1
- package/admin/i18n/es.json +6 -1
- package/admin/i18n/fr.json +6 -1
- package/admin/i18n/it.json +6 -1
- package/admin/i18n/nl.json +6 -1
- package/admin/i18n/pl.json +6 -1
- package/admin/i18n/pt.json +6 -1
- package/admin/i18n/ru.json +6 -1
- package/admin/i18n/uk.json +6 -1
- package/admin/i18n/zh-cn.json +6 -1
- package/admin/jsonConfig.json +41 -0
- 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/esphome.js +263 -6
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/lib/voice/esphomeEntities.js +52 -0
- package/build/lib/voice/esphomeEntities.js.map +1 -1
- package/build/lib/voice/esphomeProto.js.map +1 -1
- package/build/lib/voice/ffmpegStatic.js +137 -0
- package/build/lib/voice/ffmpegStatic.js.map +1 -0
- package/build/main.js +315 -34
- package/build/main.js.map +1 -1
- package/io-package.json +28 -27
- 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,6 +107,8 @@ 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',
|
|
@@ -318,6 +324,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
318
324
|
localLlmLoading = null;
|
|
319
325
|
/** Last reported local-LLM download percent (to throttle state writes / avoid log spam). */
|
|
320
326
|
lastLlmPct = -1;
|
|
327
|
+
/** A running "Download ffmpeg" - a second click waits for the same download. */
|
|
328
|
+
ffmpegDownload = null;
|
|
321
329
|
/** UDP voice server for satellites (only when voiceEnabled); null otherwise. */
|
|
322
330
|
voice = null;
|
|
323
331
|
/** Wyoming TCP endpoint (only when wyomingEnabled); null otherwise. */
|
|
@@ -652,6 +660,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
652
660
|
sttMs: origin.sttMs,
|
|
653
661
|
audioMs: origin.audioMs,
|
|
654
662
|
vad: origin.vad,
|
|
663
|
+
button: origin.button,
|
|
655
664
|
}),
|
|
656
665
|
onSpoken: (device, info) => this.reportSpoken(device, info),
|
|
657
666
|
getHints: () => this.buildSttHints(),
|
|
@@ -816,11 +825,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
816
825
|
}
|
|
817
826
|
/**
|
|
818
827
|
* True when the assistant's reply asks the user something, so a satellite should re-open its mic to
|
|
819
|
-
* capture the answer without a wake word (→ the `listen` flag of the voice response).
|
|
820
|
-
* 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}.
|
|
821
829
|
*/
|
|
822
830
|
expectsFollowUp(answer) {
|
|
823
|
-
return
|
|
831
|
+
return (0, ask_1.endsWithQuestion)(answer);
|
|
824
832
|
}
|
|
825
833
|
/** A transport synthesised the reply to `source`'s latest request — the journal shows it with that request. */
|
|
826
834
|
reportSpoken(source, info) {
|
|
@@ -1433,7 +1441,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1433
1441
|
await this.ensureState(`${id}.inProgress`, 'Update running', 'boolean', 'indicator', false);
|
|
1434
1442
|
await this.ensureState(`${id}.progress`, 'Update progress', 'number', 'value', false);
|
|
1435
1443
|
await this.ensureState(`${id}.install`, 'Install the update', 'boolean', 'button', true);
|
|
1444
|
+
// A device looks for a new version only now and then (an http_request update: at boot and every
|
|
1445
|
+
// 6 h); until then latestVersion shows the old one and install does nothing.
|
|
1446
|
+
await this.ensureState(`${id}.check`, 'Check for an update now', 'boolean', 'button', true);
|
|
1436
1447
|
this.subscribeStates(`${id}.install`);
|
|
1448
|
+
this.subscribeStates(`${id}.check`);
|
|
1437
1449
|
}
|
|
1438
1450
|
else if (entity.kind === 'event') {
|
|
1439
1451
|
await this.setObjectNotExistsAsync(id, {
|
|
@@ -1469,6 +1481,20 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1469
1481
|
: { name, type: 'string', role: 'text', read: true, write: false, def: '' };
|
|
1470
1482
|
await this.setObjectNotExistsAsync(id, { type: 'state', common, native: {} });
|
|
1471
1483
|
}
|
|
1484
|
+
else if (entity.kind === 'text') {
|
|
1485
|
+
if (entity.password) {
|
|
1486
|
+
// A secret would sit in a state every script and admin user can read.
|
|
1487
|
+
this.log.debug(`Satellite ${device}: "${name}" is a password text — not mirrored`);
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
// Labels and units of a satellite with a screen, set from here instead of in the firmware build.
|
|
1491
|
+
await this.setObjectNotExistsAsync(id, {
|
|
1492
|
+
type: 'state',
|
|
1493
|
+
common: { name, type: 'string', role: 'text', read: true, write: true, def: '' },
|
|
1494
|
+
native: {},
|
|
1495
|
+
});
|
|
1496
|
+
this.subscribeStates(id);
|
|
1497
|
+
}
|
|
1472
1498
|
else {
|
|
1473
1499
|
const common = entity.kind === 'switch'
|
|
1474
1500
|
? { name, type: 'boolean', role: 'switch', read: true, write: true, def: false }
|
|
@@ -1551,7 +1577,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1551
1577
|
}
|
|
1552
1578
|
/** Write one entity's current value into its state(s). Always acked — this is the device talking. */
|
|
1553
1579
|
async writeSatelliteControl(satId, entity) {
|
|
1554
|
-
if (!isControlId(entity.objectId)) {
|
|
1580
|
+
if (!isControlId(entity.objectId) || (entity.kind === 'text' && entity.password)) {
|
|
1555
1581
|
return; // never created (see buildSatelliteControls)
|
|
1556
1582
|
}
|
|
1557
1583
|
const base = `satellites.${satId}.controls.${entity.objectId}`;
|
|
@@ -1594,11 +1620,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1594
1620
|
// Compound entities address a leaf: `<objectId>.volume`, `<objectId>.install`, …
|
|
1595
1621
|
const [objectId, leaf] = path.split('.');
|
|
1596
1622
|
let payload = value;
|
|
1597
|
-
if (leaf === 'install') {
|
|
1623
|
+
if (leaf === 'install' || leaf === 'check') {
|
|
1598
1624
|
if (!value) {
|
|
1599
1625
|
return; // button released
|
|
1600
1626
|
}
|
|
1601
|
-
payload =
|
|
1627
|
+
payload = leaf; // UpdateCommandRequest: install = UPDATE, check = CHECK
|
|
1602
1628
|
}
|
|
1603
1629
|
else if (leaf === 'volume' || leaf === 'command') {
|
|
1604
1630
|
payload = value;
|
|
@@ -1995,21 +2021,57 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1995
2021
|
decodeAudioToPcm(src) {
|
|
1996
2022
|
return this.runFfmpegDecode(src);
|
|
1997
2023
|
}
|
|
1998
|
-
/**
|
|
1999
|
-
|
|
2000
|
-
|
|
2024
|
+
/**
|
|
2025
|
+
* Decode an in-memory audio buffer (uploaded mp3/wav, a Telegram voice message) to mono 16-bit PCM via ffmpeg
|
|
2026
|
+
* (stdin pipe). `sampleRate` 16 kHz is what speech recognition wants; playback keeps 24 kHz.
|
|
2027
|
+
*/
|
|
2028
|
+
decodeAudioBufferToPcm(buf, sampleRate = 24000) {
|
|
2029
|
+
return this.runFfmpegDecode('pipe:0', buf, sampleRate);
|
|
2030
|
+
}
|
|
2031
|
+
/** The ffmpeg binary: the configured path, else the downloaded one, else `ffmpeg` from the search path. */
|
|
2032
|
+
ffmpegBin() {
|
|
2033
|
+
return this.config.ffmpegPath?.trim() || this.downloadedFfmpeg() || 'ffmpeg';
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* The static ffmpeg from "Download ffmpeg" on the Voice tab, '' if there is none. Looked up every time (one
|
|
2037
|
+
* `stat`), so deleting the file falls back to the search path without a restart.
|
|
2038
|
+
*/
|
|
2039
|
+
downloadedFfmpeg() {
|
|
2040
|
+
const file = (0, ffmpegStatic_1.ffmpegStaticPath)(this.instanceDataDir());
|
|
2041
|
+
return fs.existsSync(file) ? file : '';
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* "Download ffmpeg" on the Voice tab: the static build of ffmpeg-static into the data dir, then the same check
|
|
2045
|
+
* as "Check ffmpeg". It is used from now on, unless a path is entered.
|
|
2046
|
+
*
|
|
2047
|
+
* @param configuredPath the path from the (unsaved) form
|
|
2048
|
+
*/
|
|
2049
|
+
async downloadFfmpeg(configuredPath) {
|
|
2050
|
+
let file;
|
|
2051
|
+
try {
|
|
2052
|
+
this.ffmpegDownload ??= (0, ffmpegStatic_1.downloadFfmpegStatic)(this.instanceDataDir(), { log: this.log }).finally(() => (this.ffmpegDownload = null));
|
|
2053
|
+
file = await this.ffmpegDownload;
|
|
2054
|
+
}
|
|
2055
|
+
catch (e) {
|
|
2056
|
+
this.log.warn(`Download of ffmpeg failed: ${e.message}`);
|
|
2057
|
+
return { error: `Download failed: ${e.message}` };
|
|
2058
|
+
}
|
|
2059
|
+
const check = await this.testFfmpeg(file);
|
|
2060
|
+
if (check.error || !configuredPath.trim()) {
|
|
2061
|
+
return check;
|
|
2062
|
+
}
|
|
2063
|
+
return { result: `${check.result} — not used while a path to ffmpeg is entered` };
|
|
2001
2064
|
}
|
|
2002
2065
|
/** Run ffmpeg to decode `input` (a path/URL, or `pipe:0` with `stdinData`) to mono 16-bit PCM. */
|
|
2003
|
-
runFfmpegDecode(input, stdinData) {
|
|
2066
|
+
runFfmpegDecode(input, stdinData, sampleRate = 24000) {
|
|
2004
2067
|
return new Promise((resolve, reject) => {
|
|
2005
|
-
const sampleRate = 24000;
|
|
2006
2068
|
// prettier-ignore
|
|
2007
2069
|
const args = ['-hide_banner', '-loglevel', 'error', '-i', input, '-ac', '1', '-ar', String(sampleRate), '-f', 's16le', '-'];
|
|
2008
|
-
const proc = (0, node_child_process_1.spawn)(
|
|
2070
|
+
const proc = (0, node_child_process_1.spawn)(this.ffmpegBin(), args);
|
|
2009
2071
|
const chunks = [];
|
|
2010
2072
|
proc.stdout.on('data', (d) => chunks.push(d));
|
|
2011
2073
|
proc.stderr.on('data', (d) => this.log.debug(`ffmpeg: ${String(d).trim()}`));
|
|
2012
|
-
proc.on('error', e => reject(new Error(`ffmpeg failed: ${e.message}
|
|
2074
|
+
proc.on('error', e => reject(new Error(`ffmpeg failed: ${e.message}${FFMPEG_HINT}`)));
|
|
2013
2075
|
proc.on('close', code => code === 0
|
|
2014
2076
|
? resolve({ pcm: Buffer.concat(chunks), sampleRate })
|
|
2015
2077
|
: reject(new Error(`ffmpeg exited with code ${code}`)));
|
|
@@ -2036,11 +2098,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2036
2098
|
? 48000
|
|
2037
2099
|
: target.sampleRate;
|
|
2038
2100
|
const args = [...input, '-ar', String(rate), '-ac', String(target.channels), ...output, '-'];
|
|
2039
|
-
const proc = (0, node_child_process_1.spawn)(
|
|
2101
|
+
const proc = (0, node_child_process_1.spawn)(this.ffmpegBin(), args);
|
|
2040
2102
|
const chunks = [];
|
|
2041
2103
|
proc.stdout.on('data', (d) => chunks.push(d));
|
|
2042
2104
|
proc.stderr.on('data', (d) => this.log.debug(`ffmpeg: ${String(d).trim()}`));
|
|
2043
|
-
proc.on('error', e => reject(new Error(`ffmpeg failed: ${e.message}`)));
|
|
2105
|
+
proc.on('error', e => reject(new Error(`ffmpeg failed: ${e.message}${FFMPEG_HINT}`)));
|
|
2044
2106
|
proc.on('close', code => code === 0 && chunks.length
|
|
2045
2107
|
? resolve(Buffer.concat(chunks))
|
|
2046
2108
|
: reject(new Error(`ffmpeg exited with code ${code}`)));
|
|
@@ -2048,6 +2110,32 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2048
2110
|
proc.stdin.end(pcm);
|
|
2049
2111
|
});
|
|
2050
2112
|
}
|
|
2113
|
+
/**
|
|
2114
|
+
* "Check ffmpeg" on the Voice tab: start the binary at `path` ('' = from the search path), report its version and
|
|
2115
|
+
* whether it can write MP3 and Opus (ESPHome devices that play nothing else). Decoding needs no extra library.
|
|
2116
|
+
*/
|
|
2117
|
+
async testFfmpeg(path) {
|
|
2118
|
+
const bin = path.trim() || this.downloadedFfmpeg() || 'ffmpeg';
|
|
2119
|
+
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;
|
|
2120
|
+
try {
|
|
2121
|
+
const firstLine = (await run(['-version'])).split(/\r?\n/)[0];
|
|
2122
|
+
const version = firstLine.replace(/\s+Copyright.*$/i, '').trim();
|
|
2123
|
+
const encoders = await run(['-hide_banner', '-encoders']).catch(() => '');
|
|
2124
|
+
const has = (name) => new RegExp(`\\s${name}\\s`).test(encoders);
|
|
2125
|
+
const missing = ['libmp3lame', 'libopus'].filter(e => !has(e));
|
|
2126
|
+
return {
|
|
2127
|
+
result: `${version} (${bin})${missing.length ? ` — without ${missing.join(', ')}: MP3/Opus replies for ESPHome devices are not possible` : ''}`,
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
catch (e) {
|
|
2131
|
+
const err = e;
|
|
2132
|
+
return {
|
|
2133
|
+
error: err.code === 'ENOENT'
|
|
2134
|
+
? `${bin} not found${path.trim() ? '' : ' in the search path'} — install ffmpeg, download it or enter the full path`
|
|
2135
|
+
: `${bin} failed: ${err.message}`,
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2051
2139
|
/** Vendor and key of the cloud LLM: speech of the same vendor (OpenAI, OpenRouter) reuses the key. */
|
|
2052
2140
|
async speechLlmKey() {
|
|
2053
2141
|
const provider = this.config.provider || 'openai';
|
|
@@ -2652,6 +2740,24 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2652
2740
|
}
|
|
2653
2741
|
return;
|
|
2654
2742
|
}
|
|
2743
|
+
// Voice tab: "Check ffmpeg" with the (unsaved) path from the form. message = { path }.
|
|
2744
|
+
if (obj.command === 'testFfmpeg') {
|
|
2745
|
+
const raw = obj.message?.path;
|
|
2746
|
+
const result = await this.testFfmpeg(typeof raw === 'string' ? raw : this.config.ffmpegPath);
|
|
2747
|
+
if (obj.callback) {
|
|
2748
|
+
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2749
|
+
}
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
// Voice tab: "Download ffmpeg". message = { path } (the unsaved path, only for the result text).
|
|
2753
|
+
if (obj.command === 'downloadFfmpeg') {
|
|
2754
|
+
const raw = obj.message?.path;
|
|
2755
|
+
const result = await this.downloadFfmpeg(typeof raw === 'string' ? raw : this.config.ffmpegPath || '');
|
|
2756
|
+
if (obj.callback) {
|
|
2757
|
+
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2758
|
+
}
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2655
2761
|
// Satellites tab: forget a satellite that is gone. message = { id } (the state id under `satellites.`).
|
|
2656
2762
|
if (obj.command === 'deleteSatellite') {
|
|
2657
2763
|
const raw = obj.message?.id;
|
|
@@ -2879,8 +2985,49 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2879
2985
|
const source = (typeof message === 'string' ? '' : message?.source?.trim()) || 'chat';
|
|
2880
2986
|
// Who sent it (javascript.0, telegram.0, admin.0 for the chat tab) — the way to find a script gone wrong.
|
|
2881
2987
|
const caller = String(obj.from || '').replace(/^system\.adapter\./, '') || 'unknown';
|
|
2882
|
-
|
|
2883
|
-
|
|
2988
|
+
let question = String(text ?? '').trim();
|
|
2989
|
+
// A recording instead of text (a Telegram voice message): recognise it first, then answer as usual.
|
|
2990
|
+
const audio = typeof message === 'string' ? undefined : message?.audio;
|
|
2991
|
+
let heard;
|
|
2992
|
+
if (!question && typeof audio === 'string' && audio) {
|
|
2993
|
+
if (!this.ready) {
|
|
2994
|
+
if (obj.callback) {
|
|
2995
|
+
this.sendTo(obj.from, obj.command, { error: 'agent not ready' }, obj.callback);
|
|
2996
|
+
}
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
try {
|
|
3000
|
+
const res = await this.transcribeAsk(message);
|
|
3001
|
+
if (!res.text) {
|
|
3002
|
+
this.log.info(`Voice Q (ask from ${caller}, ${source}): (empty) — speech recognition returned no text for ${res.audioMs} ms of audio`);
|
|
3003
|
+
if (obj.callback) {
|
|
3004
|
+
this.sendTo(obj.from, obj.command, { heard: '', error: this.nothingHeardText() }, obj.callback);
|
|
3005
|
+
}
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
question = res.text;
|
|
3009
|
+
heard = { sttMs: res.sttMs, audioMs: res.audioMs };
|
|
3010
|
+
}
|
|
3011
|
+
catch (e) {
|
|
3012
|
+
this.log.warn(`Voice message from ${caller} (${source}) not recognised: ${e.message}`);
|
|
3013
|
+
if (obj.callback) {
|
|
3014
|
+
this.sendTo(obj.from, obj.command, { error: e.message }, obj.callback);
|
|
3015
|
+
}
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
// Pictures (a Telegram photo or album) go to the cloud model; a video only as the place it was stored.
|
|
3020
|
+
const attachments = this.askAttachments(message, caller, source);
|
|
3021
|
+
if (typeof attachments === 'string') {
|
|
3022
|
+
if (obj.callback) {
|
|
3023
|
+
this.sendTo(obj.from, obj.command, { error: attachments }, obj.callback);
|
|
3024
|
+
}
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
if (!question && attachments.images.length) {
|
|
3028
|
+
question = (0, media_1.imageOnlyQuestion)(this.answerLanguage(), attachments.images.length);
|
|
3029
|
+
}
|
|
3030
|
+
if (!question && !attachments.video) {
|
|
2884
3031
|
this.reportEmptyAsk(caller);
|
|
2885
3032
|
if (obj.callback) {
|
|
2886
3033
|
this.sendTo(obj.from, obj.command, { error: 'empty question' }, obj.callback);
|
|
@@ -2893,25 +3040,101 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2893
3040
|
}
|
|
2894
3041
|
return;
|
|
2895
3042
|
}
|
|
2896
|
-
|
|
2897
|
-
this.
|
|
3043
|
+
const shown = (0, media_1.historyText)(question, attachments.images.length, !!attachments.video);
|
|
3044
|
+
this.log.info(`${heard ? 'Voice Q' : 'Q'} (${obj.command} from ${caller}): ${shown}${attachments.video ? ` (video ${attachments.video.adapter}/${attachments.video.path})` : ''}`);
|
|
3045
|
+
this.setStateAsync('text.request', { val: shown, ack: true }).catch(() => { });
|
|
2898
3046
|
this.setQuerySource(source);
|
|
2899
3047
|
try {
|
|
2900
|
-
const answer = await this.answer(question, source);
|
|
3048
|
+
const answer = await this.answer(question, source, heard ?? {}, attachments);
|
|
2901
3049
|
this.log.info(`A: ${answer}`);
|
|
2902
3050
|
this.setStateAsync('text.response', { val: answer, ack: true }).catch(() => { });
|
|
2903
3051
|
if (obj.callback) {
|
|
2904
3052
|
// `journaled`: the admin chat gets this exchange from the journal and must not add it twice.
|
|
2905
|
-
|
|
3053
|
+
// `heard`: what was recognised in a recording, so the caller can show it with the answer.
|
|
3054
|
+
this.sendTo(obj.from, obj.command, { answer, journaled: !!this.journal?.enabled, ...(heard ? { heard: question } : {}) }, obj.callback);
|
|
2906
3055
|
}
|
|
2907
3056
|
}
|
|
2908
3057
|
catch (e) {
|
|
2909
3058
|
this.log.error(`Assistant error: ${e.message}`);
|
|
2910
3059
|
if (obj.callback) {
|
|
2911
|
-
this.sendTo(obj.from, obj.command, {
|
|
3060
|
+
this.sendTo(obj.from, obj.command, {
|
|
3061
|
+
error: e.message,
|
|
3062
|
+
journaled: !!this.journal?.enabled,
|
|
3063
|
+
...(heard ? { heard: question } : {}),
|
|
3064
|
+
}, obj.callback);
|
|
2912
3065
|
}
|
|
2913
3066
|
}
|
|
2914
3067
|
}
|
|
3068
|
+
/**
|
|
3069
|
+
* The pictures and the video of an `ask`, or why they are refused (then nothing is answered: a question about a
|
|
3070
|
+
* picture without the picture would be answered with a guess).
|
|
3071
|
+
*/
|
|
3072
|
+
askAttachments(message, caller, source) {
|
|
3073
|
+
if (typeof message === 'string' || !message) {
|
|
3074
|
+
return NO_ATTACHMENTS;
|
|
3075
|
+
}
|
|
3076
|
+
const { images, error } = (0, media_1.parseAskImages)(message.images);
|
|
3077
|
+
if (error) {
|
|
3078
|
+
this.log.warn(`Pictures from ${caller} (${source}) refused: ${error}`);
|
|
3079
|
+
return `pictures: ${error}`;
|
|
3080
|
+
}
|
|
3081
|
+
const video = (0, media_1.parseAskVideo)(message.video);
|
|
3082
|
+
if ((0, media_1.isVideoError)(video)) {
|
|
3083
|
+
this.log.warn(`Video from ${caller} (${source}) refused: ${video.error}`);
|
|
3084
|
+
return video.error;
|
|
3085
|
+
}
|
|
3086
|
+
return images.length || video ? { images, video } : NO_ATTACHMENTS;
|
|
3087
|
+
}
|
|
3088
|
+
/** Pictures and video of the request being answered */
|
|
3089
|
+
attachments() {
|
|
3090
|
+
return this.requestOrigin.getStore()?.attachments ?? NO_ATTACHMENTS;
|
|
3091
|
+
}
|
|
3092
|
+
/** Language of fixed replies: the voice language, else the system language. */
|
|
3093
|
+
answerLanguage() {
|
|
3094
|
+
return String(this.config.voiceLanguage || this.language || 'en');
|
|
3095
|
+
}
|
|
3096
|
+
/**
|
|
3097
|
+
* Speech recognition for an `ask` that carries a recording instead of text. `audio` is base64; `format` says what
|
|
3098
|
+
* it is: `wav` and `pcm` (16-bit mono, `sampleRate`, default 16 kHz) are read directly, anything else (`ogg` from
|
|
3099
|
+
* Telegram, mp3, m4a …) goes through ffmpeg. Uses the STT engine of the Voice tab, so voice must be enabled.
|
|
3100
|
+
*/
|
|
3101
|
+
async transcribeAsk(msg) {
|
|
3102
|
+
if (!this.config.voiceEnabled) {
|
|
3103
|
+
throw new Error('speech recognition is off — enable voice on the Voice tab of the assistant');
|
|
3104
|
+
}
|
|
3105
|
+
const engines = await this.getSpeechEngines();
|
|
3106
|
+
if (!engines) {
|
|
3107
|
+
throw new Error('speech recognition is not configured — check the Voice tab of the assistant');
|
|
3108
|
+
}
|
|
3109
|
+
const data = Buffer.from(msg.audio || '', 'base64');
|
|
3110
|
+
const format = (msg.format || '').toLowerCase().replace(/^\./, '');
|
|
3111
|
+
let pcm;
|
|
3112
|
+
let sampleRate;
|
|
3113
|
+
if (format === 'pcm') {
|
|
3114
|
+
pcm = data;
|
|
3115
|
+
sampleRate = Number(msg.sampleRate) || 16000;
|
|
3116
|
+
}
|
|
3117
|
+
else if (data.toString('latin1', 0, 4) === 'RIFF') {
|
|
3118
|
+
({ pcm, sampleRate } = (0, openrouter_1.pcmFromResponse)(data, ''));
|
|
3119
|
+
}
|
|
3120
|
+
else {
|
|
3121
|
+
({ pcm, sampleRate } = await this.decodeAudioBufferToPcm(data, 16000));
|
|
3122
|
+
}
|
|
3123
|
+
const audioMs = Math.round((pcm.length / 2 / sampleRate) * 1000);
|
|
3124
|
+
const language = msg.language || engines.language;
|
|
3125
|
+
const start = Date.now();
|
|
3126
|
+
const text = (await engines.stt.transcribe(pcm, sampleRate, language, await this.buildSttHints())).trim();
|
|
3127
|
+
return { text, sttMs: Date.now() - start, audioMs };
|
|
3128
|
+
}
|
|
3129
|
+
/** Reply to a voice message in which speech recognition found nothing. */
|
|
3130
|
+
nothingHeardText() {
|
|
3131
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
3132
|
+
return lang === 'ru'
|
|
3133
|
+
? 'В голосовом сообщении я ничего не разобрала.'
|
|
3134
|
+
: lang === 'de'
|
|
3135
|
+
? 'In der Sprachnachricht habe ich nichts verstanden.'
|
|
3136
|
+
: "I couldn't make out anything in the voice message.";
|
|
3137
|
+
}
|
|
2915
3138
|
/**
|
|
2916
3139
|
* Answer a question. Tier 0: the built-in rule-based NLU handles simple device commands offline/free;
|
|
2917
3140
|
* anything it can't resolve falls through to the LLM (Tier 2, and later the local model as Tier 1a).
|
|
@@ -2921,19 +3144,38 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2921
3144
|
* is applied to the cloud LLM tier; every completed exchange (whichever tier answered) is recorded so
|
|
2922
3145
|
* later turns have the full thread.
|
|
2923
3146
|
*/
|
|
2924
|
-
answer(question, source = '', origin = {}) {
|
|
2925
|
-
if (!question.trim()) {
|
|
3147
|
+
answer(question, source = '', origin = {}, attachments = NO_ATTACHMENTS) {
|
|
3148
|
+
if (!question.trim() && !attachments.video) {
|
|
2926
3149
|
// Nothing to answer — and a model asked with nothing but the time context still costs a request.
|
|
2927
3150
|
return Promise.resolve('');
|
|
2928
3151
|
}
|
|
2929
3152
|
const voice = VOICE_TRANSPORTS.has(origin.transport || '');
|
|
2930
|
-
|
|
2931
|
-
|
|
3153
|
+
// The journal shows "[picture] What is in this picture?", like the conversation history
|
|
3154
|
+
const shown = (0, media_1.historyText)(question, attachments.images.length, !!attachments.video);
|
|
3155
|
+
return this.requestOrigin.run({ source, room: origin.room || '', voice, attachments }, () => this.journal
|
|
3156
|
+
? this.journal.run(shown, source, origin, () => {
|
|
2932
3157
|
this.traceSpeechInput(question, origin);
|
|
3158
|
+
this.traceAttachments(attachments);
|
|
2933
3159
|
return this.answerTracked(question, source);
|
|
2934
3160
|
})
|
|
2935
3161
|
: this.answerTracked(question, source));
|
|
2936
3162
|
}
|
|
3163
|
+
/** Pictures and video of the request at the head of its trace (sizes only, not the base64). */
|
|
3164
|
+
traceAttachments({ images, video }) {
|
|
3165
|
+
if (!images.length && !video) {
|
|
3166
|
+
return;
|
|
3167
|
+
}
|
|
3168
|
+
const kb = (base64) => Math.round((base64.length * 3) / 4 / 1024);
|
|
3169
|
+
const parts = [
|
|
3170
|
+
images.length ? `${images.length} picture(s), ${images.reduce((n, i) => n + kb(i.data), 0)} KB` : '',
|
|
3171
|
+
video ? `video ${video.adapter}/${video.path}` : '',
|
|
3172
|
+
].filter(Boolean);
|
|
3173
|
+
this.journal?.step({
|
|
3174
|
+
stage: 'request',
|
|
3175
|
+
title: `Attachments: ${parts.join(', ')}`,
|
|
3176
|
+
data: { images: images.map(i => ({ mimeType: i.mimeType, kb: kb(i.data) })), video: video || undefined },
|
|
3177
|
+
});
|
|
3178
|
+
}
|
|
2937
3179
|
/** Speech recognition happened before the request: put it at the head of its trace. */
|
|
2938
3180
|
traceSpeechInput(question, origin) {
|
|
2939
3181
|
if (origin.sttMs === undefined) {
|
|
@@ -2978,7 +3220,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2978
3220
|
const followUp = /\[\[LISTEN\]\]/i.test(raw);
|
|
2979
3221
|
this.followUps.set(source, followUp);
|
|
2980
3222
|
// A script conversation goes on while the cloud model asks back: the reply goes to it as well.
|
|
2981
|
-
if (cloudOnly && followUp) {
|
|
3223
|
+
if ((cloudOnly === 'script' || cloudOnly === 'scriptReply') && followUp) {
|
|
2982
3224
|
this.scriptTopics.set(source, Date.now());
|
|
2983
3225
|
}
|
|
2984
3226
|
else {
|
|
@@ -3007,7 +3249,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3007
3249
|
data: { answer: result, raw: raw !== result ? raw : undefined, followUp, tone: toned || undefined },
|
|
3008
3250
|
});
|
|
3009
3251
|
if (useCtx && (result || toned)) {
|
|
3010
|
-
|
|
3252
|
+
// The pictures are not sent again in later turns; a marker says there were some
|
|
3253
|
+
const { images, video } = this.attachments();
|
|
3254
|
+
this.context.add(source, (0, media_1.historyText)(question, images.length, !!video), result || this.doneText());
|
|
3011
3255
|
}
|
|
3012
3256
|
return result;
|
|
3013
3257
|
}
|
|
@@ -3063,7 +3307,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3063
3307
|
return 'script';
|
|
3064
3308
|
}
|
|
3065
3309
|
const since = this.scriptTopics.get(source);
|
|
3066
|
-
|
|
3310
|
+
if (since !== undefined && Date.now() - since < SCRIPT_TOPIC_MS) {
|
|
3311
|
+
return 'scriptReply';
|
|
3312
|
+
}
|
|
3313
|
+
// Rules and the local model are blind: the caption of a photo is about the photo, not a command
|
|
3314
|
+
return this.attachments().images.length ? 'image' : null;
|
|
3067
3315
|
}
|
|
3068
3316
|
/**
|
|
3069
3317
|
* Should the satellite behind `source` re-open its mic after this reply? Primarily the LLM's own
|
|
@@ -3222,6 +3470,14 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3222
3470
|
}
|
|
3223
3471
|
async produceAnswer(question, history, source = '', cloudOnly = null) {
|
|
3224
3472
|
this.log.debug(`answer: source='${source}', context=${history.length} turn(s)`);
|
|
3473
|
+
const { images, video } = this.attachments();
|
|
3474
|
+
if (!question.trim() && video) {
|
|
3475
|
+
// A video without a question: nothing looks at videos yet
|
|
3476
|
+
this.log.info(`Video from '${source}' (${video.adapter}/${video.path}) not looked at: not supported yet.`);
|
|
3477
|
+
this.journal?.note({ tier: 'none' });
|
|
3478
|
+
this.journal?.step({ stage: 'none', title: 'A video without a question: videos are not looked at yet' });
|
|
3479
|
+
return (0, media_1.videoNotSupportedText)(this.answerLanguage());
|
|
3480
|
+
}
|
|
3225
3481
|
// A ringing timer/alarm is silenced by a stop word ("stop"/"halt"/"aufhören"/…) — checked first so
|
|
3226
3482
|
// it always wins over the normal pipeline while something is ringing.
|
|
3227
3483
|
if (this.rings.length && (0, nlu_1.isStopCommand)(question)) {
|
|
@@ -3248,10 +3504,22 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3248
3504
|
return '';
|
|
3249
3505
|
}
|
|
3250
3506
|
}
|
|
3507
|
+
if (images.length && !this.agent && !this.piAgent) {
|
|
3508
|
+
this.log.info(`Pictures from '${source}' not looked at: no cloud model is configured.`);
|
|
3509
|
+
this.journal?.note({ tier: 'none' });
|
|
3510
|
+
this.journal?.step({
|
|
3511
|
+
stage: 'none',
|
|
3512
|
+
level: 'warn',
|
|
3513
|
+
title: 'Pictures came with the request, but no cloud model is configured to look at them',
|
|
3514
|
+
});
|
|
3515
|
+
return (0, media_1.noVisionText)(this.answerLanguage());
|
|
3516
|
+
}
|
|
3251
3517
|
if (cloudOnly) {
|
|
3252
3518
|
const why = cloudOnly === 'script'
|
|
3253
3519
|
? 'it is about a script'
|
|
3254
|
-
:
|
|
3520
|
+
: cloudOnly === 'scriptReply'
|
|
3521
|
+
? 'it answers a question of the cloud model about a script'
|
|
3522
|
+
: 'pictures came with it, only the cloud model can see them';
|
|
3255
3523
|
this.log.info(`Routines, NLU and local LLM skipped (source='${source}'): ${why}.`);
|
|
3256
3524
|
this.journal?.step({
|
|
3257
3525
|
stage: 'nlu',
|
|
@@ -3331,7 +3599,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3331
3599
|
// every time voice and text requests alternate.
|
|
3332
3600
|
const time = this.buildTimeContext();
|
|
3333
3601
|
const lengthHint = this.spokenLengthHint();
|
|
3334
|
-
|
|
3602
|
+
// The pictures themselves go along; only a video needs a word, the model gets nothing of it
|
|
3603
|
+
const questionForLlm = [time, weather, question, (0, media_1.attachmentNote)(0, !!video), lengthHint]
|
|
3604
|
+
.filter(Boolean)
|
|
3605
|
+
.join('\n\n');
|
|
3335
3606
|
this.journal?.note({
|
|
3336
3607
|
tier: 'cloud',
|
|
3337
3608
|
detail: `${this.llm?.provider || this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
|
|
@@ -3351,10 +3622,12 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3351
3622
|
devices: deviceLines,
|
|
3352
3623
|
time,
|
|
3353
3624
|
weather: weather || undefined,
|
|
3625
|
+
images: images.length || undefined,
|
|
3626
|
+
video: video ? `${video.adapter}/${video.path}` : undefined,
|
|
3354
3627
|
},
|
|
3355
3628
|
});
|
|
3356
3629
|
try {
|
|
3357
|
-
return await this.agent.ask(questionForLlm, sys, history);
|
|
3630
|
+
return await this.agent.ask(questionForLlm, sys, history, images);
|
|
3358
3631
|
}
|
|
3359
3632
|
catch (e) {
|
|
3360
3633
|
// Offline, quota, outage: the local model is the fallback — unless it was already asked above.
|
|
@@ -3387,7 +3660,15 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3387
3660
|
if (dropped) {
|
|
3388
3661
|
this.log.debug(`pi-agent: ${dropped} context block(s) left out, the instructions would be too long.`);
|
|
3389
3662
|
}
|
|
3390
|
-
|
|
3663
|
+
// An older pi-agent drops the pictures: the note at least tells the model that there were some
|
|
3664
|
+
const { images, video } = this.attachments();
|
|
3665
|
+
const text = [
|
|
3666
|
+
this.buildTimeContext(),
|
|
3667
|
+
weather,
|
|
3668
|
+
this.buildPresenceContext(),
|
|
3669
|
+
question,
|
|
3670
|
+
(0, media_1.attachmentNote)(images.length, !!video),
|
|
3671
|
+
]
|
|
3391
3672
|
.filter(Boolean)
|
|
3392
3673
|
.join('\n\n');
|
|
3393
3674
|
this.log.info(`Cloud tier ${harness.instance} (source='${source}') — asking pi-agent…`);
|
|
@@ -3396,7 +3677,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3396
3677
|
// pi-agent asks its questions to confirm a write ("… Say yes or no.") in this language, not in the system
|
|
3397
3678
|
// language: a satellite listening in Russian does not hear a German "Ja"
|
|
3398
3679
|
const language = String(this.config.voiceLanguage || this.language || '');
|
|
3399
|
-
return await harness.ask({ text, conversation: source, instructions, language });
|
|
3680
|
+
return await harness.ask({ text, conversation: source, instructions, language, images });
|
|
3400
3681
|
}
|
|
3401
3682
|
catch (e) {
|
|
3402
3683
|
if (localFallback) {
|