iobroker.assistant 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/admin/custom/.vite/manifest.json +18 -18
- package/admin/custom/assets/Components-x2Dfv8qE.js +2 -0
- package/admin/custom/assets/{_virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-C5NPM0Of.js → _virtual_mf-localSharedImportMap___mfe_internal__ConfigCustomAssistant__mf_owner__1-B4YbqhBb.js} +1 -1
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-Cdyjt5KT.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-o4EU_Ut5.js} +10 -10
- package/admin/custom/assets/{_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare__shared-Dsw5zWS8.js → _virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__loadShare__shared--be2hWbV.js} +1 -1
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-BsTz_EWE.js +122 -0
- package/admin/custom/assets/{hostInit-jJe8bIfV.js → hostInit-Blh7a-Qe.js} +1 -1
- package/admin/custom/assets/{lib-CL8mXvI6.js → lib-DBbNtolG.js} +1 -1
- package/admin/custom/assets/{mf-entry-bootstrap-0-ab227aa6.js → mf-entry-bootstrap-0-7abd30d3.js} +2 -2
- package/admin/custom/assets/{pendingShares-wrGtfliQ.js → pendingShares-U0D1MYK4.js} +1 -1
- package/admin/custom/customComponents.js +2 -2
- package/admin/custom/i18n/de.json +16 -0
- package/admin/custom/i18n/en.json +16 -0
- package/admin/custom/i18n/es.json +16 -0
- package/admin/custom/i18n/fr.json +16 -0
- package/admin/custom/i18n/it.json +16 -0
- package/admin/custom/i18n/nl.json +16 -0
- package/admin/custom/i18n/pl.json +16 -0
- package/admin/custom/i18n/pt.json +16 -0
- package/admin/custom/i18n/ru.json +16 -0
- package/admin/custom/i18n/uk.json +16 -0
- package/admin/custom/i18n/zh-cn.json +16 -0
- package/admin/custom/index.html +2 -2
- package/admin/custom/mf-manifest.json +1 -1
- package/admin/custom/mf-stats.json +1 -1
- package/admin/i18n/de.json +25 -2
- package/admin/i18n/en.json +25 -2
- package/admin/i18n/es.json +25 -2
- package/admin/i18n/fr.json +25 -2
- package/admin/i18n/it.json +25 -2
- package/admin/i18n/nl.json +25 -2
- package/admin/i18n/pl.json +25 -2
- package/admin/i18n/pt.json +25 -2
- package/admin/i18n/ru.json +25 -2
- package/admin/i18n/uk.json +25 -2
- package/admin/i18n/zh-cn.json +25 -2
- package/admin/jsonConfig.json +197 -13
- package/build/lib/credentials.js +11 -5
- package/build/lib/credentials.js.map +1 -1
- package/build/lib/llm.js +17 -3
- package/build/lib/llm.js.map +1 -1
- package/build/lib/nlu.js +249 -9
- package/build/lib/nlu.js.map +1 -1
- package/build/lib/piAgent.js +89 -0
- package/build/lib/piAgent.js.map +1 -0
- package/build/lib/systemAi.js +124 -0
- package/build/lib/systemAi.js.map +1 -0
- package/build/lib/voice/engines.js +31 -3
- package/build/lib/voice/engines.js.map +1 -1
- package/build/lib/voice/openrouter.js +220 -0
- package/build/lib/voice/openrouter.js.map +1 -0
- package/build/main.js +573 -59
- package/build/main.js.map +1 -1
- package/io-package.json +27 -17
- package/package.json +1 -1
- package/admin/custom/assets/Components-BSewCBfS.js +0 -2
- package/admin/custom/assets/_virtual_mf___mfe_internal__ConfigCustomAssistant__mf_owner__163539842885036__prebuild___mf_0_iobroker_mf_1_json_mf_2_config__prebuild__-SgSagTOv.js +0 -122
package/build/main.js
CHANGED
|
@@ -66,6 +66,9 @@ const mediaServer_1 = require("./lib/voice/mediaServer");
|
|
|
66
66
|
const wakeWords_1 = require("./lib/voice/wakeWords");
|
|
67
67
|
const tone_1 = require("./lib/voice/tone");
|
|
68
68
|
const engines_1 = require("./lib/voice/engines");
|
|
69
|
+
const openrouter_1 = require("./lib/voice/openrouter");
|
|
70
|
+
const piAgent_1 = require("./lib/piAgent");
|
|
71
|
+
const systemAi_1 = require("./lib/systemAi");
|
|
69
72
|
/**
|
|
70
73
|
* How long a rendered weather context line stays valid. Weather adapters refresh every few minutes at best,
|
|
71
74
|
* so re-reading their whole state tree on every single request would be pure overhead.
|
|
@@ -89,6 +92,36 @@ const LOCAL_LLM_MAX_TOKENS = 256;
|
|
|
89
92
|
* The replies the assistant speaks verbatim, whatever the question was. They are pre-synthesised into
|
|
90
93
|
* the TTS cache at startup; everything else is cached on first use.
|
|
91
94
|
*/
|
|
95
|
+
/** Fields of the Voice tab the voice test takes from the unsaved form (everything an STT or TTS engine is built from). */
|
|
96
|
+
const VOICE_FORM_FIELDS = [
|
|
97
|
+
'voiceLanguage',
|
|
98
|
+
'sttProvider',
|
|
99
|
+
'sttModel',
|
|
100
|
+
'openrouterSttModel',
|
|
101
|
+
'voskModel',
|
|
102
|
+
'ttsProvider',
|
|
103
|
+
'ttsVoice',
|
|
104
|
+
'ttsModel',
|
|
105
|
+
'voiceCredentialType',
|
|
106
|
+
'voiceApiKey',
|
|
107
|
+
'voiceCredentialId',
|
|
108
|
+
'openrouterSpeechKey',
|
|
109
|
+
'openrouterCredentialId',
|
|
110
|
+
'openrouterTtsModel',
|
|
111
|
+
'openrouterVoice',
|
|
112
|
+
'azureSpeechKey',
|
|
113
|
+
'azureSpeechRegion',
|
|
114
|
+
'azureCredentialId',
|
|
115
|
+
'azureVoice',
|
|
116
|
+
'awsAccessKeyId',
|
|
117
|
+
'awsSecretAccessKey',
|
|
118
|
+
'awsRegion',
|
|
119
|
+
'awsCredentialId',
|
|
120
|
+
'awsVoice',
|
|
121
|
+
'piperVoice',
|
|
122
|
+
];
|
|
123
|
+
/** Of those, the protected ones: the browser never gets them, so an empty value there means "keep the saved one" */
|
|
124
|
+
const VOICE_FORM_SECRETS = ['voiceApiKey', 'openrouterSpeechKey', 'azureSpeechKey', 'awsSecretAccessKey'];
|
|
92
125
|
/** How long to wait for a device to confirm a write, and how often to look. */
|
|
93
126
|
const ACK_TIMEOUT_MS = 2000;
|
|
94
127
|
const ACK_POLL_MS = 150;
|
|
@@ -197,8 +230,18 @@ function describeIntent(intent) {
|
|
|
197
230
|
else if (intent.hour !== undefined) {
|
|
198
231
|
text += ` ${intent.hour}:${String(intent.minute ?? 0).padStart(2, '0')}`;
|
|
199
232
|
}
|
|
233
|
+
if (intent.delaySec !== undefined) {
|
|
234
|
+
text += ` in ${intent.delaySec} s`;
|
|
235
|
+
}
|
|
236
|
+
else if (intent.at) {
|
|
237
|
+
text += ` at ${intent.at.day ? `+${intent.at.day}d ` : ''}${intent.at.hour}:${String(intent.at.minute).padStart(2, '0')}`;
|
|
238
|
+
}
|
|
200
239
|
return text;
|
|
201
240
|
}
|
|
241
|
+
/** A device command for later ("in 2 Stunden", "um 22 Uhr") — goes into the calendar, not to the device */
|
|
242
|
+
function isDeferred(intent) {
|
|
243
|
+
return intent.delaySec !== undefined || !!intent.at;
|
|
244
|
+
}
|
|
202
245
|
const FIXED_REPLIES = {
|
|
203
246
|
de: ['Ok.', 'Erledigt.'],
|
|
204
247
|
en: ['Okay.', 'Done.'],
|
|
@@ -228,6 +271,12 @@ function languageLabel(lang) {
|
|
|
228
271
|
class Assistant extends adapter_core_1.Adapter {
|
|
229
272
|
/** Cloud LLM; `null` with provider "none" (local tiers only) or before `onReady` finished. */
|
|
230
273
|
agent = null;
|
|
274
|
+
/** Provider "pi-agent": the cloud tier is a pi-agent instance instead of {@link agent}. */
|
|
275
|
+
piAgent = null;
|
|
276
|
+
/** The cloud LLM of the instance (own or system-wide settings); `null` without one. */
|
|
277
|
+
llm = null;
|
|
278
|
+
/** What of `system.ai` this instance depends on, while it takes the system-wide AI settings. */
|
|
279
|
+
systemAiWatch = null;
|
|
231
280
|
/** Set once `onReady` initialised the pipeline — with or without a cloud {@link agent}. */
|
|
232
281
|
ready = false;
|
|
233
282
|
mcp = null;
|
|
@@ -330,15 +379,30 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
330
379
|
// alarms, the local model) answer. The ioBroker tools are still built: the NLU switches through them.
|
|
331
380
|
const provider = cfg.provider || 'openai';
|
|
332
381
|
const localOnly = provider === 'none';
|
|
333
|
-
|
|
382
|
+
// Provider "pi-agent": the adapter pi-agent is the cloud tier, with its own model, key and tools.
|
|
383
|
+
const harness = provider === 'pi-agent';
|
|
384
|
+
// Provider "system" (or no key of its own): provider, model and key of the system-wide AI settings.
|
|
385
|
+
if (!localOnly && !harness && (0, systemAi_1.usesSystemAi)(cfg)) {
|
|
386
|
+
await this.watchSystemAi();
|
|
387
|
+
}
|
|
388
|
+
const resolved = localOnly || harness ? null : await (0, systemAi_1.resolveLlm)(this, cfg);
|
|
389
|
+
this.llm = resolved && !('error' in resolved) ? resolved : null;
|
|
390
|
+
// speech of the same vendor reuses the key of the LLM
|
|
391
|
+
const llmKey = this.llm ? { provider: this.llm.provider, apiKey: this.llm.apiKey } : null;
|
|
334
392
|
await this.setupJournal();
|
|
335
|
-
if (
|
|
336
|
-
this.log.warn(
|
|
337
|
-
|
|
338
|
-
|
|
393
|
+
if (harness && !(cfg.piAgentInstance || '').trim()) {
|
|
394
|
+
this.log.warn('Provider "pi-agent" without an instance — choose the pi-agent instance in the adapter settings.');
|
|
395
|
+
await this.setStateAsync('info.connection', { val: false, ack: true });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (resolved && 'error' in resolved) {
|
|
399
|
+
this.log.warn(resolved.error);
|
|
339
400
|
await this.setStateAsync('info.connection', { val: false, ack: true });
|
|
340
401
|
return;
|
|
341
402
|
}
|
|
403
|
+
if (this.llm?.system) {
|
|
404
|
+
this.log.info(`LLM from the system-wide AI settings: ${this.llm.provider} · ${this.llm.model || 'auto'}`);
|
|
405
|
+
}
|
|
342
406
|
try {
|
|
343
407
|
this.mcp = this.journalMcp(await (0, mcp_server_1.createInProcessMcp)({
|
|
344
408
|
adapter: this,
|
|
@@ -359,6 +423,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
359
423
|
if ((cfg.weatherInstance || '').trim()) {
|
|
360
424
|
tools.push(this.buildWeatherTool());
|
|
361
425
|
}
|
|
426
|
+
if ((cfg.calendarInstance || '').trim()) {
|
|
427
|
+
tools.push(...this.buildCalendarTools());
|
|
428
|
+
}
|
|
362
429
|
if (cfg.voiceEnabled) {
|
|
363
430
|
// Only useful with satellites: without voice there is nothing to speak on.
|
|
364
431
|
tools.push(this.buildAnnounceTool());
|
|
@@ -382,13 +449,15 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
382
449
|
// Bust the device/room cache when room/function memberships change (new device shows within TTL anyway).
|
|
383
450
|
this.subscribeForeignObjects('enum.rooms.*');
|
|
384
451
|
this.subscribeForeignObjects('enum.functions.*');
|
|
385
|
-
if (
|
|
386
|
-
|
|
452
|
+
if (harness) {
|
|
453
|
+
this.piAgent = new piAgent_1.PiAgentHarness(this, cfg.piAgentInstance.trim(), Math.max(5, Number(cfg.piAgentTimeoutSec) || piAgent_1.PI_AGENT_TIMEOUT_SEC) * 1000);
|
|
454
|
+
}
|
|
455
|
+
else if (this.llm) {
|
|
387
456
|
this.agent = new llm_1.LlmAgent({
|
|
388
|
-
provider,
|
|
389
|
-
apiKey,
|
|
390
|
-
model:
|
|
391
|
-
baseUrl:
|
|
457
|
+
provider: this.llm.provider,
|
|
458
|
+
apiKey: this.llm.apiKey,
|
|
459
|
+
model: this.llm.model,
|
|
460
|
+
baseUrl: this.llm.baseUrl,
|
|
392
461
|
systemPrompt: cfg.systemPrompt || '',
|
|
393
462
|
maxTokens: cfg.maxTokens || 1024,
|
|
394
463
|
tools: tools.map(t => this.journalTool(t)),
|
|
@@ -438,8 +507,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
438
507
|
await this.setupPresence();
|
|
439
508
|
await this.setupTriggers();
|
|
440
509
|
this.log.info(this.agent
|
|
441
|
-
? `Assistant ready (provider=${
|
|
442
|
-
:
|
|
510
|
+
? `Assistant ready (provider=${this.llm?.provider}, model=${this.agent.model || 'auto'}).`
|
|
511
|
+
: this.piAgent
|
|
512
|
+
? `Assistant ready (cloud tier: ${this.piAgent.instance}).`
|
|
513
|
+
: 'Assistant ready without an LLM provider — answering locally only.');
|
|
443
514
|
if (cfg.useLocalLlm) {
|
|
444
515
|
void this.ensureLocalLlm(); // background: model load/download must not block onReady
|
|
445
516
|
}
|
|
@@ -448,20 +519,20 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
448
519
|
// Subscribe regardless of the UDP server so writes are always handled (and logged if unroutable).
|
|
449
520
|
this.subscribeStates('tts.text');
|
|
450
521
|
if (cfg.udpServerEnabled) {
|
|
451
|
-
await this.startVoiceServer(
|
|
522
|
+
await this.startVoiceServer(llmKey);
|
|
452
523
|
}
|
|
453
524
|
}
|
|
454
525
|
if (cfg.wyomingEnabled) {
|
|
455
|
-
await this.startWyoming(
|
|
526
|
+
await this.startWyoming(llmKey);
|
|
456
527
|
}
|
|
457
528
|
if (cfg.esphomeEnabled) {
|
|
458
|
-
await this.startEsphome(
|
|
529
|
+
await this.startEsphome(llmKey);
|
|
459
530
|
}
|
|
460
531
|
}
|
|
461
532
|
/** Start the Wyoming TCP endpoint, bridging to the same STT/answer/TTS pipeline as the UDP server. */
|
|
462
|
-
async startWyoming(
|
|
533
|
+
async startWyoming(llmKey) {
|
|
463
534
|
const cfg = this.config;
|
|
464
|
-
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg,
|
|
535
|
+
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, llmKey);
|
|
465
536
|
const ctx = this.voiceContext(cfg, creds);
|
|
466
537
|
const language = cfg.voiceLanguage || this.language || '';
|
|
467
538
|
let stt;
|
|
@@ -502,14 +573,14 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
502
573
|
* linux-voice-assistant). The adapter is the client here — the devices listen on TCP 6053 — and the
|
|
503
574
|
* spoken reply is handed over as a URL, so the little media server comes up together with them.
|
|
504
575
|
*/
|
|
505
|
-
async startEsphome(
|
|
576
|
+
async startEsphome(llmKey) {
|
|
506
577
|
const cfg = this.config;
|
|
507
578
|
const devices = (cfg.esphomeDevices || []).filter(d => d?.ip?.trim());
|
|
508
579
|
if (!devices.length) {
|
|
509
580
|
this.log.warn('ESPHome satellites are enabled, but no device is configured.');
|
|
510
581
|
return;
|
|
511
582
|
}
|
|
512
|
-
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg,
|
|
583
|
+
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, llmKey);
|
|
513
584
|
const ctx = this.voiceContext(cfg, creds);
|
|
514
585
|
const language = cfg.voiceLanguage || this.language || '';
|
|
515
586
|
let stt;
|
|
@@ -597,6 +668,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
597
668
|
azure: (cfg.azureVoice || '').trim(),
|
|
598
669
|
aws: (cfg.awsVoice || '').trim(),
|
|
599
670
|
piper: (cfg.piperVoice || '').trim(),
|
|
671
|
+
openrouter: (cfg.openrouterVoice || '').trim(),
|
|
600
672
|
},
|
|
601
673
|
sttFallback: cfg.sttFallback || '',
|
|
602
674
|
ttsFallback: cfg.ttsFallback || '',
|
|
@@ -605,6 +677,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
605
677
|
voskModel: (cfg.voskModel || '').trim(),
|
|
606
678
|
sttModel: (cfg.sttModel || '').trim(),
|
|
607
679
|
ttsModel: (cfg.ttsModel || '').trim(),
|
|
680
|
+
openrouterSttModel: (cfg.openrouterSttModel || '').trim(),
|
|
681
|
+
openrouterTtsModel: (cfg.openrouterTtsModel || '').trim(),
|
|
682
|
+
decodeAudio: audio => this.decodeAudioBufferToPcm(audio),
|
|
608
683
|
};
|
|
609
684
|
}
|
|
610
685
|
/**
|
|
@@ -612,9 +687,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
612
687
|
* AWS cloud, or Vosk / Piper locally); credentials come from the encrypted config fields or the
|
|
613
688
|
* central credential store (voiceCredentialType).
|
|
614
689
|
*/
|
|
615
|
-
async startVoiceServer(
|
|
690
|
+
async startVoiceServer(llmKey) {
|
|
616
691
|
const cfg = this.config;
|
|
617
|
-
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg,
|
|
692
|
+
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, llmKey);
|
|
618
693
|
const ctx = this.voiceContext(cfg, creds);
|
|
619
694
|
// Raw language (e.g. 'de' or 'zh-cn') — each engine normalises it (ISO for cloud, model per lang for local).
|
|
620
695
|
const language = cfg.voiceLanguage || this.language || '';
|
|
@@ -721,6 +796,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
721
796
|
azure: cfg.azureVoice,
|
|
722
797
|
aws: cfg.awsVoice,
|
|
723
798
|
piper: cfg.piperVoice,
|
|
799
|
+
openrouter: cfg.openrouterVoice,
|
|
724
800
|
};
|
|
725
801
|
this.journal?.spoken(source, {
|
|
726
802
|
...info,
|
|
@@ -740,8 +816,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
740
816
|
return this.speechEngines;
|
|
741
817
|
}
|
|
742
818
|
const cfg = this.config;
|
|
743
|
-
const
|
|
744
|
-
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainKey);
|
|
819
|
+
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
|
|
745
820
|
const ctx = this.voiceContext(cfg, creds);
|
|
746
821
|
const language = cfg.voiceLanguage || this.language || '';
|
|
747
822
|
try {
|
|
@@ -829,8 +904,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
829
904
|
const cfg = { ...this.config, ...overrides };
|
|
830
905
|
const provider = msg.ttsProvider || cfg.ttsProvider || 'openai';
|
|
831
906
|
const language = msg.language || cfg.voiceLanguage || this.language || '';
|
|
832
|
-
const
|
|
833
|
-
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, mainKey);
|
|
907
|
+
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
|
|
834
908
|
return await (0, engines_1.listVoices)(provider, this.voiceContext(cfg, creds), language);
|
|
835
909
|
}
|
|
836
910
|
catch (e) {
|
|
@@ -1745,7 +1819,36 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
1745
1819
|
});
|
|
1746
1820
|
}
|
|
1747
1821
|
/** Invalidate the cached device/room/function listings when a room/function enum changes. */
|
|
1748
|
-
|
|
1822
|
+
/** Vendor and key of the cloud LLM: speech of the same vendor (OpenAI, OpenRouter) reuses the key. */
|
|
1823
|
+
async speechLlmKey() {
|
|
1824
|
+
const provider = this.config.provider || 'openai';
|
|
1825
|
+
const llm = this.llm ?? (provider === 'none' || provider === 'pi-agent' ? null : await (0, systemAi_1.resolveLlm)(this, this.config));
|
|
1826
|
+
return llm && !('error' in llm) ? { provider: llm.provider, apiKey: llm.apiKey } : null;
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Restart when the system-wide AI settings this instance takes change — also when they are still empty, so
|
|
1830
|
+
* setting them up in admin starts the instance. The credential is watched too: a new key needs no new id.
|
|
1831
|
+
*/
|
|
1832
|
+
async watchSystemAi() {
|
|
1833
|
+
const native = await (0, systemAi_1.readSystemAi)(this);
|
|
1834
|
+
const credentialId = (0, systemAi_1.systemAiSelection)(native)?.credentialId || '';
|
|
1835
|
+
this.systemAiWatch = { fingerprint: (0, systemAi_1.systemAiFingerprint)(native), credentialId };
|
|
1836
|
+
await this.subscribeForeignObjectsAsync(systemAi_1.SYSTEM_AI_ID);
|
|
1837
|
+
if (credentialId) {
|
|
1838
|
+
await this.subscribeForeignObjectsAsync(credentialId);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
onObjectChange(id, obj) {
|
|
1842
|
+
if (this.systemAiWatch && (id === systemAi_1.SYSTEM_AI_ID || id === this.systemAiWatch.credentialId)) {
|
|
1843
|
+
// A credential has no fingerprint: a change of it is a new key.
|
|
1844
|
+
if (id === systemAi_1.SYSTEM_AI_ID && (0, systemAi_1.systemAiFingerprint)(obj?.native) === this.systemAiWatch.fingerprint) {
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
this.log.info(`The system-wide AI settings changed (${id}) — restarting.`);
|
|
1848
|
+
this.systemAiWatch = null;
|
|
1849
|
+
this.restart();
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1749
1852
|
if (this.listCache && (id.startsWith('enum.rooms.') || id.startsWith('enum.functions.'))) {
|
|
1750
1853
|
this.listCache.clear();
|
|
1751
1854
|
this.log.debug(`list cache cleared (object changed: ${id})`);
|
|
@@ -2003,14 +2106,23 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2003
2106
|
}
|
|
2004
2107
|
return;
|
|
2005
2108
|
}
|
|
2006
|
-
// STT model
|
|
2007
|
-
if (obj.command === 'getSttModels') {
|
|
2109
|
+
// STT/TTS model dropdowns (Vosk, OpenRouter): suggest model names for the selected provider.
|
|
2110
|
+
if (obj.command === 'getSttModels' || obj.command === 'getTtsModels') {
|
|
2008
2111
|
const msg = (obj.message || {});
|
|
2009
2112
|
const cfg = this.config;
|
|
2010
|
-
const provider = msg.sttProvider || cfg.sttProvider || 'openai';
|
|
2011
2113
|
const language = msg.language || cfg.voiceLanguage || this.language || '';
|
|
2114
|
+
let models = [];
|
|
2115
|
+
try {
|
|
2116
|
+
models =
|
|
2117
|
+
obj.command === 'getSttModels'
|
|
2118
|
+
? await (0, engines_1.listSttModels)(msg.sttProvider || cfg.sttProvider || 'openai', language)
|
|
2119
|
+
: await (0, engines_1.listTtsModels)(msg.ttsProvider || cfg.ttsProvider || 'openai');
|
|
2120
|
+
}
|
|
2121
|
+
catch (e) {
|
|
2122
|
+
this.log.warn(`${obj.command} failed: ${e.message}`);
|
|
2123
|
+
}
|
|
2012
2124
|
if (obj.callback) {
|
|
2013
|
-
this.sendTo(obj.from, obj.command,
|
|
2125
|
+
this.sendTo(obj.from, obj.command, models, obj.callback);
|
|
2014
2126
|
}
|
|
2015
2127
|
return;
|
|
2016
2128
|
}
|
|
@@ -2385,10 +2497,20 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2385
2497
|
}
|
|
2386
2498
|
return;
|
|
2387
2499
|
}
|
|
2388
|
-
// Chat: synthesize
|
|
2500
|
+
// Chat and the voice test of the settings: synthesize to speech (WAV) via the configured TTS engine,
|
|
2501
|
+
// or via the unsaved settings of the Voice tab (`config`).
|
|
2389
2502
|
if (obj.command === 'tts') {
|
|
2390
|
-
const { text, language } = (obj.message || {});
|
|
2391
|
-
const
|
|
2503
|
+
const { text, language, config } = (obj.message || {});
|
|
2504
|
+
const cfg = config ? this.withVoiceForm(config) : this.config;
|
|
2505
|
+
const result = await this.synthesizeToWav(String(text ?? ''), language, cfg);
|
|
2506
|
+
if (obj.callback) {
|
|
2507
|
+
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2508
|
+
}
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
// Voice test of the settings: recognize a recording with the (unsaved) speech-to-text settings.
|
|
2512
|
+
if (obj.command === 'stt') {
|
|
2513
|
+
const result = await this.transcribeForTest((obj.message || {}));
|
|
2392
2514
|
if (obj.callback) {
|
|
2393
2515
|
this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
2394
2516
|
}
|
|
@@ -2567,7 +2689,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2567
2689
|
data: {
|
|
2568
2690
|
provider,
|
|
2569
2691
|
fallback: cfg.sttFallback || undefined,
|
|
2570
|
-
model: (cfg.sttModel || cfg.voskModel || '').trim() ||
|
|
2692
|
+
model: (provider === 'openrouter' ? cfg.openrouterSttModel : cfg.sttModel || cfg.voskModel || '').trim() ||
|
|
2693
|
+
undefined,
|
|
2571
2694
|
language: cfg.voiceLanguage || this.language || '',
|
|
2572
2695
|
audioMs: origin.audioMs,
|
|
2573
2696
|
wakeWord: origin.wakeWord,
|
|
@@ -2595,19 +2718,52 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2595
2718
|
if (followUp) {
|
|
2596
2719
|
this.journal?.note({ followUp: true });
|
|
2597
2720
|
}
|
|
2598
|
-
|
|
2721
|
+
let result = raw.replace(/\s*\[\[(?:LISTEN|DONE)\]\]\s*/gi, ' ').trim();
|
|
2722
|
+
// The LLM answers a switch command that worked with [[DONE]] alone (see doneHint): a beep on the satellite
|
|
2723
|
+
// instead of a sentence. Where nothing can beep (chat, Telegram), a short word. With more text, that is said.
|
|
2724
|
+
let toned = false;
|
|
2725
|
+
if (!result && /\[\[DONE\]\]/i.test(raw)) {
|
|
2726
|
+
toned = await this.playConfirmationTone(source);
|
|
2727
|
+
if (!toned) {
|
|
2728
|
+
result = this.doneText();
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2599
2731
|
this.journal?.step({
|
|
2600
2732
|
stage: 'answer',
|
|
2601
|
-
title:
|
|
2602
|
-
?
|
|
2603
|
-
:
|
|
2604
|
-
|
|
2733
|
+
title: toned
|
|
2734
|
+
? 'Confirmed with a tone instead of words'
|
|
2735
|
+
: result
|
|
2736
|
+
? `Answer: ${result.length} characters${followUp ? ', waits for a reply' : ''}`
|
|
2737
|
+
: 'No spoken answer',
|
|
2738
|
+
data: { answer: result, raw: raw !== result ? raw : undefined, followUp, tone: toned || undefined },
|
|
2605
2739
|
});
|
|
2606
|
-
if (useCtx && result) {
|
|
2607
|
-
this.context.add(source, question, result);
|
|
2740
|
+
if (useCtx && (result || toned)) {
|
|
2741
|
+
this.context.add(source, question, result || this.doneText());
|
|
2608
2742
|
}
|
|
2609
2743
|
return result;
|
|
2610
2744
|
}
|
|
2745
|
+
/**
|
|
2746
|
+
* System-prompt instruction for `confirmWithTone`: a switch command that worked is answered with [[DONE]]
|
|
2747
|
+
* alone, which becomes a beep on the satellite. Empty when the option (or voice) is off.
|
|
2748
|
+
*/
|
|
2749
|
+
doneHint() {
|
|
2750
|
+
if (!this.config.confirmWithTone || !this.config.voiceEnabled) {
|
|
2751
|
+
return '';
|
|
2752
|
+
}
|
|
2753
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
2754
|
+
if (lang === 'ru') {
|
|
2755
|
+
return 'Если запрос был только командой включить, выключить или установить устройства на значение и всё получилось, ответь только управляющим маркером [[DONE]] (вместо фразы прозвучит короткий сигнал). На вопросы, при ошибках или если нужно что-то сообщить, отвечай как обычно.';
|
|
2756
|
+
}
|
|
2757
|
+
if (lang === 'de') {
|
|
2758
|
+
return 'Wenn die Anfrage nur ein Befehl war, Geräte ein- oder auszuschalten oder auf einen Wert zu stellen, und alles geklappt hat, antworte ausschließlich mit dem Steuer-Marker [[DONE]] (statt eines Satzes ertönt ein kurzer Bestätigungston). Bei Fragen, bei Fehlern oder wenn du etwas mitteilen musst, antworte normal.';
|
|
2759
|
+
}
|
|
2760
|
+
return 'If the request was only a command to switch devices on or off or to set them to a value, and everything worked, answer with the control marker [[DONE]] alone (a short beep plays instead of a sentence). For questions, errors or when you have something to tell, answer normally.';
|
|
2761
|
+
}
|
|
2762
|
+
/** What [[DONE]] says where no beep can be played */
|
|
2763
|
+
doneText() {
|
|
2764
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
2765
|
+
return lang === 'ru' ? 'Готово.' : lang === 'de' ? 'Erledigt.' : 'Done.';
|
|
2766
|
+
}
|
|
2611
2767
|
/** Per source: whether its last {@link answer} asked the user something (LLM-signalled via [[LISTEN]]). */
|
|
2612
2768
|
followUps = new Map();
|
|
2613
2769
|
/**
|
|
@@ -2800,8 +2956,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2800
2956
|
// the thread and the tools) handle follow-ups instead. Without a cloud LLM there is nobody to hand
|
|
2801
2957
|
// follow-ups to, so the local model takes them too.
|
|
2802
2958
|
const useLocal = !!(this.config.useLocalLlm && this.localLlm);
|
|
2803
|
-
const
|
|
2804
|
-
|
|
2959
|
+
const cloud = !!(this.agent || this.piAgent);
|
|
2960
|
+
const localFirst = useLocal && (!cloud || !!this.config.localLlmFirst);
|
|
2961
|
+
if (localFirst && (!history.length || !cloud)) {
|
|
2805
2962
|
const ans = await this.askLocalLlm(question, weather, source);
|
|
2806
2963
|
if (ans) {
|
|
2807
2964
|
return ans;
|
|
@@ -2822,6 +2979,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2822
2979
|
}
|
|
2823
2980
|
// Tier 2: cloud LLM with full tool access. Inject a compact device list into the system prompt so
|
|
2824
2981
|
// the model can act without a first `list_devices` round-trip (kept cached via prompt caching).
|
|
2982
|
+
if (this.piAgent) {
|
|
2983
|
+
return this.askPiAgent(this.piAgent, question, weather, source, useLocal && !localFirst);
|
|
2984
|
+
}
|
|
2825
2985
|
if (!this.agent) {
|
|
2826
2986
|
// Provider "none": no tier further down. Say so instead of failing — over voice an error is
|
|
2827
2987
|
// read out as a technical message.
|
|
@@ -2838,7 +2998,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2838
2998
|
const mem = this.buildMemoryContext();
|
|
2839
2999
|
// Only ask the model to emit the follow-up marker when satellites can actually re-open the mic.
|
|
2840
3000
|
const followUp = this.config.voiceEnabled ? this.followUpHint() : '';
|
|
2841
|
-
const
|
|
3001
|
+
const done = this.doneHint();
|
|
3002
|
+
const parts = [this.config.systemPrompt || '', followUp, done, mem, ctx].filter(Boolean);
|
|
2842
3003
|
const sys = parts.length ? parts.join('\n\n') : undefined;
|
|
2843
3004
|
this.log.info(`Cloud LLM [${this.agent.model || 'auto'}] (source='${source}', context=${history.length} turn(s)${mem ? ', memory' : ''}${weather ? ', weather' : ''}${this.presence?.configured ? ', presence' : ''}) — running tool loop…`);
|
|
2844
3005
|
// Prepend the current date/time (and the weather line, if a source is configured) to the user turn —
|
|
@@ -2847,7 +3008,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2847
3008
|
const questionForLlm = [time, weather, question].filter(Boolean).join('\n\n');
|
|
2848
3009
|
this.journal?.note({
|
|
2849
3010
|
tier: 'cloud',
|
|
2850
|
-
detail: `${this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
|
|
3011
|
+
detail: `${this.llm?.provider || this.config.provider || 'openai'} · ${this.agent.model || 'auto'}`,
|
|
2851
3012
|
});
|
|
2852
3013
|
// What went into the prompt, piece by piece. The device list is only counted: the model's opening
|
|
2853
3014
|
// step carries the whole system prompt, and with it the list.
|
|
@@ -2858,6 +3019,7 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2858
3019
|
data: {
|
|
2859
3020
|
systemPrompt: this.config.systemPrompt || '',
|
|
2860
3021
|
followUpHint: followUp || undefined,
|
|
3022
|
+
doneHint: done || undefined,
|
|
2861
3023
|
memory: mem || undefined,
|
|
2862
3024
|
devices: deviceLines,
|
|
2863
3025
|
time,
|
|
@@ -2884,6 +3046,39 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2884
3046
|
throw e;
|
|
2885
3047
|
}
|
|
2886
3048
|
}
|
|
3049
|
+
/**
|
|
3050
|
+
* Cloud tier through the adapter pi-agent: one pi-agent conversation per source. The style, the
|
|
3051
|
+
* follow-up marker, the memory and the device list are the instructions of that conversation (pi-agent
|
|
3052
|
+
* re-configures it only when they change); time, weather and presence change every turn and go in front
|
|
3053
|
+
* of the question. The history stays in pi-agent, so nothing of {@link ConversationStore} is sent.
|
|
3054
|
+
*/
|
|
3055
|
+
async askPiAgent(harness, question, weather, source, localFallback) {
|
|
3056
|
+
const followUp = this.config.voiceEnabled ? this.followUpHint() : '';
|
|
3057
|
+
// The device list maps names to state ids, so pi-agent can act without searching first. It is the
|
|
3058
|
+
// first to go when the instructions get too long — pi-agent can still search.
|
|
3059
|
+
const { text: instructions, dropped } = (0, piAgent_1.composeInstructions)([this.config.systemPrompt || '', followUp, this.doneHint()], [this.buildMemoryContext(), await this.buildDeviceContext()]);
|
|
3060
|
+
if (dropped) {
|
|
3061
|
+
this.log.debug(`pi-agent: ${dropped} context block(s) left out, the instructions would be too long.`);
|
|
3062
|
+
}
|
|
3063
|
+
const text = [this.buildTimeContext(), weather, this.buildPresenceContext(), question]
|
|
3064
|
+
.filter(Boolean)
|
|
3065
|
+
.join('\n\n');
|
|
3066
|
+
this.log.info(`Cloud tier ${harness.instance} (source='${source}') — asking pi-agent…`);
|
|
3067
|
+
this.journal?.note({ tier: 'cloud', detail: harness.instance });
|
|
3068
|
+
try {
|
|
3069
|
+
return await harness.ask({ text, conversation: source, instructions });
|
|
3070
|
+
}
|
|
3071
|
+
catch (e) {
|
|
3072
|
+
if (localFallback) {
|
|
3073
|
+
this.log.warn(`pi-agent failed (${e.message}) — asking the local LLM instead.`);
|
|
3074
|
+
const ans = await this.askLocalLlm(question, weather, source);
|
|
3075
|
+
if (ans) {
|
|
3076
|
+
return ans;
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
throw e;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
2887
3082
|
/**
|
|
2888
3083
|
* Ask the local model, bounded by `localLlmTimeoutSec`. Returns its answer, or null when it handed off,
|
|
2889
3084
|
* timed out or failed — the caller then goes on to the next tier.
|
|
@@ -2974,10 +3169,11 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2974
3169
|
const room = d.room ? `${d.room}, ` : '';
|
|
2975
3170
|
lines.push(`- ${d.name} (${room}${d.type}): ${ids.join(', ')}`);
|
|
2976
3171
|
}
|
|
3172
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
3173
|
+
lines.push(...this.satelliteVolumeLines(lang));
|
|
2977
3174
|
if (!lines.length) {
|
|
2978
3175
|
return '';
|
|
2979
3176
|
}
|
|
2980
|
-
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
2981
3177
|
const header = lang === 'ru'
|
|
2982
3178
|
? 'Известные устройства — «имя (комната, тип): stateId». Используй эти stateId напрямую с set_state/get_states; вызывай list_devices только если нужного устройства здесь нет.'
|
|
2983
3179
|
: lang === 'de'
|
|
@@ -2985,6 +3181,41 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2985
3181
|
: 'Known devices — "name (room, type): stateId". Use these stateIds directly with set_state/get_states; only call list_devices if a needed device is missing here.';
|
|
2986
3182
|
return `${header}\n${lines.join('\n')}`;
|
|
2987
3183
|
}
|
|
3184
|
+
/**
|
|
3185
|
+
* The playback volume of every voice satellite as a line of the device list. Without it, "Lautstärke auf 50
|
|
3186
|
+
* Prozent" that the rules miss reaches the LLM (or pi-agent) with only the media devices of the room in view, and
|
|
3187
|
+
* the model turns those up instead of the speaker that heard the request. The scales differ per transport.
|
|
3188
|
+
*/
|
|
3189
|
+
satelliteVolumeLines(lang) {
|
|
3190
|
+
const label = (satId) => lang === 'ru'
|
|
3191
|
+
? `Громкость голосового спутника ${satId}`
|
|
3192
|
+
: lang === 'de'
|
|
3193
|
+
? `Lautstärke des Sprachsatelliten ${satId}`
|
|
3194
|
+
: `Volume of the voice satellite ${satId}`;
|
|
3195
|
+
const lines = [];
|
|
3196
|
+
const seen = new Set();
|
|
3197
|
+
for (const [satId, instance] of this.nativeSatFrom) {
|
|
3198
|
+
seen.add(satId);
|
|
3199
|
+
lines.push(`- ${label(satId)} (${satId}, level.volume 0-100): ${instance}.volume`);
|
|
3200
|
+
}
|
|
3201
|
+
for (const [satId, device] of this.satDeviceById) {
|
|
3202
|
+
if (seen.has(satId)) {
|
|
3203
|
+
continue;
|
|
3204
|
+
}
|
|
3205
|
+
const player = this.esphome?.entities(device).find(e => e.kind === 'mediaPlayer');
|
|
3206
|
+
if (player) {
|
|
3207
|
+
seen.add(satId);
|
|
3208
|
+
const id = `${this.namespace}.satellites.${satId}.controls.${player.objectId}.volume`;
|
|
3209
|
+
lines.push(`- ${label(satId)} (${satId}, level.volume 0-1): ${id}`);
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
for (const satId of this.satVolumeEnsured) {
|
|
3213
|
+
if (!seen.has(satId)) {
|
|
3214
|
+
lines.push(`- ${label(satId)} (${satId}, level.volume 0-100): ${this.namespace}.satellites.${satId}.volume`);
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
return lines;
|
|
3218
|
+
}
|
|
2988
3219
|
/**
|
|
2989
3220
|
* List a port we listen on in the host's used-resources registry (js-controller ≥ 8), so the admin shows it
|
|
2990
3221
|
* as occupied when someone configures another instance. `common.declareUsedResources` is set because the
|
|
@@ -3266,7 +3497,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3266
3497
|
if (this.config.confirmWithTone &&
|
|
3267
3498
|
answer &&
|
|
3268
3499
|
!failed &&
|
|
3269
|
-
|
|
3500
|
+
// a command for later says when it will run — a beep would hide that
|
|
3501
|
+
intents.every(i => CONTROL_ACTIONS.has(i.action) && !isDeferred(i)) &&
|
|
3270
3502
|
(await this.playConfirmationTone(source))) {
|
|
3271
3503
|
this.log.debug(`NLU confirmed with a tone instead of "${answer}".`);
|
|
3272
3504
|
this.journal?.step({ stage: 'nlu', title: 'Confirmed with a tone instead of words', data: { answer } });
|
|
@@ -3301,6 +3533,9 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3301
3533
|
if (!deviceCount) {
|
|
3302
3534
|
return 'no devices known';
|
|
3303
3535
|
}
|
|
3536
|
+
if (isDeferred(intent) && !(this.config.calendarInstance || '').trim()) {
|
|
3537
|
+
return 'a command for later needs a calendar (fullcalendar) instance in the settings';
|
|
3538
|
+
}
|
|
3304
3539
|
return ['on', 'off', 'level', 'color'].includes(intent.action) && !this.config.allowWriteStates
|
|
3305
3540
|
? 'writing states is not allowed (allowWriteStates)'
|
|
3306
3541
|
: '';
|
|
@@ -3325,8 +3560,226 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3325
3560
|
case 'volumeSet':
|
|
3326
3561
|
return this.executeVolumeIntent(intent, source);
|
|
3327
3562
|
default:
|
|
3328
|
-
return this.executeIntent(intent);
|
|
3563
|
+
return isDeferred(intent) ? this.executeDeferredIntent(intent) : this.executeIntent(intent);
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
/**
|
|
3567
|
+
* Send a message to the calendar instance (fullcalendar). Its answer `{error}` becomes an exception.
|
|
3568
|
+
*
|
|
3569
|
+
* @param command `addEvent`, `listEvents`, `deleteEvent`, …
|
|
3570
|
+
* @param message the parameters
|
|
3571
|
+
*/
|
|
3572
|
+
async calendarCall(command, message) {
|
|
3573
|
+
const instance = (this.config.calendarInstance || '').trim().replace(/^system\.adapter\./, '');
|
|
3574
|
+
if (!instance) {
|
|
3575
|
+
throw new Error('No calendar instance configured in the assistant settings');
|
|
3576
|
+
}
|
|
3577
|
+
const alive = await this.getForeignStateAsync(`system.adapter.${instance}.alive`);
|
|
3578
|
+
if (!alive?.val) {
|
|
3579
|
+
throw new Error(`The calendar ${instance} is not running`);
|
|
3580
|
+
}
|
|
3581
|
+
const res = (await this.sendToAsync(instance, command, message, { timeout: 10000 }));
|
|
3582
|
+
if (!res) {
|
|
3583
|
+
throw new Error(`The calendar ${instance} did not answer`);
|
|
3584
|
+
}
|
|
3585
|
+
if (res.error) {
|
|
3586
|
+
throw new Error(res.error);
|
|
3587
|
+
}
|
|
3588
|
+
return res;
|
|
3589
|
+
}
|
|
3590
|
+
/**
|
|
3591
|
+
* Whether the per-device ACL forbids writing this state: an explicit entry, else the default of the device
|
|
3592
|
+
* type (locks are read-only). States outside the device list are not restricted (like `set_state`).
|
|
3593
|
+
*/
|
|
3594
|
+
async writeForbidden(stateId) {
|
|
3595
|
+
const { devices } = await this.getNluDevices();
|
|
3596
|
+
const device = devices.find(d => Object.values(d.controls).includes(stateId));
|
|
3597
|
+
if (!device) {
|
|
3598
|
+
return false;
|
|
3599
|
+
}
|
|
3600
|
+
const entry = this.config.deviceAcl?.[(0, tools_1.deviceKey)(Object.values(device.controls))];
|
|
3601
|
+
return entry ? entry.write === false : device.type === 'lock';
|
|
3602
|
+
}
|
|
3603
|
+
/** "22:30", "morgen um 22:30", "30.10. um 7:00" — when a planned command runs, for the spoken answer */
|
|
3604
|
+
describeWhen(time, lang) {
|
|
3605
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
3606
|
+
const clock = `${time.getHours()}:${pad(time.getMinutes())}`;
|
|
3607
|
+
const now = new Date();
|
|
3608
|
+
const days = Math.round((new Date(time.getFullYear(), time.getMonth(), time.getDate()).getTime() -
|
|
3609
|
+
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()) /
|
|
3610
|
+
86_400_000);
|
|
3611
|
+
const date = `${pad(time.getDate())}.${pad(time.getMonth() + 1)}.`;
|
|
3612
|
+
if (lang === 'ru') {
|
|
3613
|
+
return days === 0 ? `в ${clock}` : days === 1 ? `завтра в ${clock}` : `${date} в ${clock}`;
|
|
3614
|
+
}
|
|
3615
|
+
if (lang === 'de') {
|
|
3616
|
+
return days === 0 ? `um ${clock}` : days === 1 ? `morgen um ${clock}` : `am ${date} um ${clock}`;
|
|
3329
3617
|
}
|
|
3618
|
+
return days === 0 ? `at ${clock}` : days === 1 ? `tomorrow at ${clock}` : `on ${date} at ${clock}`;
|
|
3619
|
+
}
|
|
3620
|
+
/**
|
|
3621
|
+
* A device command for later ("Licht in 2 Stunden aus", "выключи свет через 2 часа"): one event in the calendar
|
|
3622
|
+
* (fullcalendar) per write — the calendar shows it, survives restarts and can be cancelled there.
|
|
3623
|
+
*/
|
|
3624
|
+
async executeDeferredIntent(intent) {
|
|
3625
|
+
const device = intent.device;
|
|
3626
|
+
if (!device || !intent.stateId) {
|
|
3627
|
+
throw new Error('deferred intent without a device');
|
|
3628
|
+
}
|
|
3629
|
+
const lang = String(this.config.voiceLanguage || this.language || 'en');
|
|
3630
|
+
const pick = (rus, ger, eng) => lang === 'ru' ? rus : lang === 'de' ? ger : eng;
|
|
3631
|
+
const room = intent.room || device.room;
|
|
3632
|
+
const where = room ? ` (${room})` : '';
|
|
3633
|
+
const dev = device.name;
|
|
3634
|
+
if (await this.writeForbidden(intent.stateId)) {
|
|
3635
|
+
return pick(`${dev}${where} только для чтения.`, `${dev}${where} ist schreibgeschützt.`, `${dev}${where} is read-only.`);
|
|
3636
|
+
}
|
|
3637
|
+
let time;
|
|
3638
|
+
if (intent.delaySec !== undefined) {
|
|
3639
|
+
time = new Date(Date.now() + intent.delaySec * 1000);
|
|
3640
|
+
}
|
|
3641
|
+
else {
|
|
3642
|
+
const { hour, minute, day } = intent.at;
|
|
3643
|
+
const now = new Date();
|
|
3644
|
+
time = new Date(now.getFullYear(), now.getMonth(), now.getDate() + (day ?? 0), hour, minute, 0, 0);
|
|
3645
|
+
if (time.getTime() <= now.getTime()) {
|
|
3646
|
+
if (day !== undefined) {
|
|
3647
|
+
return pick(`${this.describeWhen(time, lang)} уже прошло.`, `${this.describeWhen(time, lang).replace(/^um /, '')} ist schon vorbei.`, `${this.describeWhen(time, lang).replace(/^at /, '')} has already passed.`);
|
|
3648
|
+
}
|
|
3649
|
+
time.setDate(time.getDate() + 1); // "um 7 Uhr" after 7 o'clock: tomorrow
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
const name = `${dev}${where}`;
|
|
3653
|
+
await this.calendarCall('addEvent', { oid: intent.stateId, value: intent.value, start: time.getTime(), name });
|
|
3654
|
+
// a dimmer with its own switch: the switch goes into the calendar, too
|
|
3655
|
+
if (intent.also) {
|
|
3656
|
+
await this.calendarCall('addEvent', {
|
|
3657
|
+
oid: intent.also.stateId,
|
|
3658
|
+
value: intent.also.value,
|
|
3659
|
+
start: time.getTime(),
|
|
3660
|
+
name,
|
|
3661
|
+
});
|
|
3662
|
+
}
|
|
3663
|
+
this.log.info(`Planned in the calendar: ${intent.stateId} = ${JSON.stringify(intent.value)} at ${time.toString()}`);
|
|
3664
|
+
const when = this.describeWhen(time, lang);
|
|
3665
|
+
switch (intent.action) {
|
|
3666
|
+
case 'on':
|
|
3667
|
+
return pick(`Хорошо, ${when} включу: ${dev}${where}.`, `Okay, ${dev}${where} wird ${when} eingeschaltet.`, `Okay, ${dev}${where} will be turned on ${when}.`);
|
|
3668
|
+
case 'off':
|
|
3669
|
+
return pick(`Хорошо, ${when} выключу: ${dev}${where}.`, `Okay, ${dev}${where} wird ${when} ausgeschaltet.`, `Okay, ${dev}${where} will be turned off ${when}.`);
|
|
3670
|
+
case 'level':
|
|
3671
|
+
return pick(`Хорошо, ${when} поставлю ${dev}${where} на ${String(intent.value)} %.`, `Okay, ${dev}${where} wird ${when} auf ${String(intent.value)} % gestellt.`, `Okay, ${dev}${where} will be set to ${String(intent.value)} % ${when}.`);
|
|
3672
|
+
default:
|
|
3673
|
+
return pick(`Хорошо, запланировано ${when}: ${dev}${where}.`, `Okay, für ${dev}${where} ${when} eingeplant.`, `Okay, planned for ${dev}${where} ${when}.`);
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
/**
|
|
3677
|
+
* LLM tools for the calendar (fullcalendar): plan a state change for later, list what is planned, cancel it.
|
|
3678
|
+
* Planning respects the same write rules as `set_state` (the coarse switch and the per-device ACL).
|
|
3679
|
+
*/
|
|
3680
|
+
buildCalendarTools() {
|
|
3681
|
+
const fail = (e) => JSON.stringify({ ok: false, error: e.message });
|
|
3682
|
+
const tools = [
|
|
3683
|
+
{
|
|
3684
|
+
name: 'list_scheduled',
|
|
3685
|
+
description: 'List the planned events of the calendar (state changes at a time, also recurring ones), in local time of the house. Optional "days" ahead (default 1 = the next 24 hours) or a "from"/"to" range as local time "YYYY-MM-DDTHH:mm".',
|
|
3686
|
+
parameters: {
|
|
3687
|
+
type: 'object',
|
|
3688
|
+
properties: {
|
|
3689
|
+
days: { type: 'number', description: 'How many days ahead, default 1' },
|
|
3690
|
+
from: { type: 'string', description: 'Start, local time YYYY-MM-DDTHH:mm' },
|
|
3691
|
+
to: { type: 'string', description: 'End, local time YYYY-MM-DDTHH:mm' },
|
|
3692
|
+
},
|
|
3693
|
+
additionalProperties: false,
|
|
3694
|
+
},
|
|
3695
|
+
run: async (args) => {
|
|
3696
|
+
try {
|
|
3697
|
+
const res = await this.calendarCall('listEvents', {
|
|
3698
|
+
days: typeof args.days === 'number' ? args.days : 1,
|
|
3699
|
+
from: args.from,
|
|
3700
|
+
to: args.to,
|
|
3701
|
+
max: 50,
|
|
3702
|
+
});
|
|
3703
|
+
return JSON.stringify({ ok: true, events: res.events });
|
|
3704
|
+
}
|
|
3705
|
+
catch (e) {
|
|
3706
|
+
return fail(e);
|
|
3707
|
+
}
|
|
3708
|
+
},
|
|
3709
|
+
},
|
|
3710
|
+
];
|
|
3711
|
+
if (!this.config.allowWriteStates) {
|
|
3712
|
+
return tools;
|
|
3713
|
+
}
|
|
3714
|
+
tools.push({
|
|
3715
|
+
name: 'schedule_state',
|
|
3716
|
+
description: 'Plan a state change for later in the calendar: write "value" to state "id" after "in_minutes" or at "at" (local time of the house, "YYYY-MM-DDTHH:mm"). Use it for "in 2 hours", "at 10 pm", "tomorrow morning" — never set_state for something meant for later. With "duration_minutes" the state gets "end_value" after that time (for a boolean the opposite by default). Tell the user the planned time.',
|
|
3717
|
+
parameters: {
|
|
3718
|
+
type: 'object',
|
|
3719
|
+
properties: {
|
|
3720
|
+
id: { type: 'string', description: 'State ID' },
|
|
3721
|
+
value: { type: ['boolean', 'number', 'string'], description: 'Value to write' },
|
|
3722
|
+
in_minutes: { type: 'number', description: 'Delay in minutes from now' },
|
|
3723
|
+
at: { type: 'string', description: 'Local time YYYY-MM-DDTHH:mm' },
|
|
3724
|
+
duration_minutes: { type: 'number', description: 'Optional: write end_value this much later' },
|
|
3725
|
+
end_value: { type: ['boolean', 'number', 'string'], description: 'Value after the duration' },
|
|
3726
|
+
name: {
|
|
3727
|
+
type: 'string',
|
|
3728
|
+
description: 'Short title for the calendar, e.g. "Licht Wohnzimmer aus"',
|
|
3729
|
+
},
|
|
3730
|
+
},
|
|
3731
|
+
required: ['id', 'value'],
|
|
3732
|
+
additionalProperties: false,
|
|
3733
|
+
},
|
|
3734
|
+
run: async (args) => {
|
|
3735
|
+
try {
|
|
3736
|
+
const id = typeof args.id === 'string' ? args.id : '';
|
|
3737
|
+
if (await this.writeForbidden(id)) {
|
|
3738
|
+
return JSON.stringify({
|
|
3739
|
+
ok: false,
|
|
3740
|
+
error: `Writing "${id}" is not allowed: the device is read-only in the assistant settings.`,
|
|
3741
|
+
});
|
|
3742
|
+
}
|
|
3743
|
+
if (args.in_minutes === undefined && !args.at) {
|
|
3744
|
+
return JSON.stringify({ ok: false, error: 'Give "in_minutes" or "at"' });
|
|
3745
|
+
}
|
|
3746
|
+
const res = await this.calendarCall('addEvent', {
|
|
3747
|
+
oid: id,
|
|
3748
|
+
value: args.value,
|
|
3749
|
+
in: typeof args.in_minutes === 'number' ? Math.round(args.in_minutes * 60) : undefined,
|
|
3750
|
+
start: args.in_minutes === undefined ? args.at : undefined,
|
|
3751
|
+
duration: typeof args.duration_minutes === 'number'
|
|
3752
|
+
? Math.round(args.duration_minutes * 60)
|
|
3753
|
+
: undefined,
|
|
3754
|
+
endValue: args.end_value,
|
|
3755
|
+
name: typeof args.name === 'string' ? args.name : undefined,
|
|
3756
|
+
});
|
|
3757
|
+
return JSON.stringify({ ok: true, eventId: res.id, start: res.start });
|
|
3758
|
+
}
|
|
3759
|
+
catch (e) {
|
|
3760
|
+
return fail(e);
|
|
3761
|
+
}
|
|
3762
|
+
},
|
|
3763
|
+
}, {
|
|
3764
|
+
name: 'cancel_scheduled',
|
|
3765
|
+
description: 'Delete a planned event from the calendar, by its "id" from list_scheduled or schedule_state.',
|
|
3766
|
+
parameters: {
|
|
3767
|
+
type: 'object',
|
|
3768
|
+
properties: { id: { type: 'string', description: 'Event ID' } },
|
|
3769
|
+
required: ['id'],
|
|
3770
|
+
additionalProperties: false,
|
|
3771
|
+
},
|
|
3772
|
+
run: async (args) => {
|
|
3773
|
+
try {
|
|
3774
|
+
await this.calendarCall('deleteEvent', { id: typeof args.id === 'string' ? args.id : '' });
|
|
3775
|
+
return JSON.stringify({ ok: true });
|
|
3776
|
+
}
|
|
3777
|
+
catch (e) {
|
|
3778
|
+
return fail(e);
|
|
3779
|
+
}
|
|
3780
|
+
},
|
|
3781
|
+
});
|
|
3782
|
+
return tools;
|
|
3330
3783
|
}
|
|
3331
3784
|
/**
|
|
3332
3785
|
* Speak the weather straight from the configured adapter. Throws when the report has nothing for the
|
|
@@ -5916,13 +6369,52 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
5916
6369
|
return { ok: false, error: e.message };
|
|
5917
6370
|
}
|
|
5918
6371
|
}
|
|
5919
|
-
/**
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
6372
|
+
/**
|
|
6373
|
+
* The saved configuration with the speech settings of an unsaved form on top. Only the speech fields count,
|
|
6374
|
+
* and a key only when one was typed: protected fields never reach the browser, so they are empty there.
|
|
6375
|
+
* Without fallbacks: a test must show whether the chosen engine works, not whether the reserve does.
|
|
6376
|
+
*/
|
|
6377
|
+
withVoiceForm(form) {
|
|
6378
|
+
const cfg = { ...this.config, sttFallback: '', ttsFallback: '' };
|
|
6379
|
+
for (const key of VOICE_FORM_FIELDS) {
|
|
6380
|
+
const value = form[key];
|
|
6381
|
+
if (typeof value !== 'string' || (VOICE_FORM_SECRETS.includes(key) && !value.trim())) {
|
|
6382
|
+
continue;
|
|
6383
|
+
}
|
|
6384
|
+
cfg[key] = value;
|
|
6385
|
+
}
|
|
6386
|
+
return cfg;
|
|
6387
|
+
}
|
|
6388
|
+
/** Build the TTS engine of a configuration (throws when no voice key is available). */
|
|
6389
|
+
async buildTtsEngine(cfg = this.config) {
|
|
6390
|
+
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
|
|
5924
6391
|
return (0, engines_1.createTtsEngine)(cfg.ttsProvider || 'openai', this.voiceContext(cfg, creds));
|
|
5925
6392
|
}
|
|
6393
|
+
/**
|
|
6394
|
+
* Speech recognition for the voice test of the settings: a recording of the browser, an uploaded file or the
|
|
6395
|
+
* test sentence the TTS just spoke. WAV is read directly, anything else goes through ffmpeg.
|
|
6396
|
+
*/
|
|
6397
|
+
async transcribeForTest(msg) {
|
|
6398
|
+
try {
|
|
6399
|
+
if (!msg.audio) {
|
|
6400
|
+
return { error: 'no audio' };
|
|
6401
|
+
}
|
|
6402
|
+
const cfg = msg.config ? this.withVoiceForm(msg.config) : this.config;
|
|
6403
|
+
const data = Buffer.from(msg.audio, 'base64');
|
|
6404
|
+
const { pcm, sampleRate } = (msg.format || 'wav') === 'wav' && data.toString('latin1', 0, 4) === 'RIFF'
|
|
6405
|
+
? (0, openrouter_1.pcmFromResponse)(data, '')
|
|
6406
|
+
: await this.decodeAudioBufferToPcm(data);
|
|
6407
|
+
const creds = await (0, credentials_1.resolveVoiceCredentials)(this, cfg, await this.speechLlmKey());
|
|
6408
|
+
const stt = (0, engines_1.createSttEngine)(cfg.sttProvider || 'openai', this.voiceContext(cfg, creds));
|
|
6409
|
+
const language = msg.language || cfg.voiceLanguage || this.language || '';
|
|
6410
|
+
const start = Date.now();
|
|
6411
|
+
const text = await stt.transcribe(pcm, sampleRate, language, await this.buildSttHints());
|
|
6412
|
+
return { text, ms: Date.now() - start, audioMs: Math.round((pcm.length / 2 / sampleRate) * 1000) };
|
|
6413
|
+
}
|
|
6414
|
+
catch (e) {
|
|
6415
|
+
return { error: e.message };
|
|
6416
|
+
}
|
|
6417
|
+
}
|
|
5926
6418
|
/** True if backend TTS can run (a voice key is configured) — drives the Chat play button. */
|
|
5927
6419
|
async isTtsAvailable() {
|
|
5928
6420
|
try {
|
|
@@ -5934,15 +6426,23 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
5934
6426
|
}
|
|
5935
6427
|
}
|
|
5936
6428
|
/** Synthesize `text` to a base64 WAV via the configured TTS engine (used by the Chat play button). */
|
|
5937
|
-
async synthesizeToWav(text, language) {
|
|
6429
|
+
async synthesizeToWav(text, language, cfg = this.config) {
|
|
5938
6430
|
if (!text.trim()) {
|
|
5939
6431
|
return { error: 'empty text' };
|
|
5940
6432
|
}
|
|
5941
6433
|
try {
|
|
5942
|
-
const tts = await this.buildTtsEngine();
|
|
5943
|
-
const lang = language ||
|
|
6434
|
+
const tts = await this.buildTtsEngine(cfg);
|
|
6435
|
+
const lang = language || cfg.voiceLanguage || this.language || '';
|
|
6436
|
+
const start = Date.now();
|
|
5944
6437
|
const { pcm, sampleRate } = await tts.synthesize(text, lang);
|
|
5945
|
-
return {
|
|
6438
|
+
return {
|
|
6439
|
+
audio: this.pcmToWav(pcm, sampleRate).toString('base64'),
|
|
6440
|
+
mime: 'audio/wav',
|
|
6441
|
+
sampleRate,
|
|
6442
|
+
// a wrong rate shows up as an implausible length of the spoken text
|
|
6443
|
+
audioMs: Math.round((pcm.length / 2 / sampleRate) * 1000),
|
|
6444
|
+
ms: Date.now() - start,
|
|
6445
|
+
};
|
|
5946
6446
|
}
|
|
5947
6447
|
catch (e) {
|
|
5948
6448
|
return { error: e.message };
|
|
@@ -5974,7 +6474,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
5974
6474
|
async getModels(msg) {
|
|
5975
6475
|
const cfg = this.config;
|
|
5976
6476
|
const provider = msg.provider || cfg.provider || 'openai';
|
|
5977
|
-
|
|
6477
|
+
// `system`: the model is chosen in the system settings of admin
|
|
6478
|
+
if (provider === 'none' || provider === 'pi-agent' || provider === 'system') {
|
|
5978
6479
|
return [];
|
|
5979
6480
|
}
|
|
5980
6481
|
const apiKey = (0, llm_1.effectiveApiKey)(provider, await (0, credentials_1.resolveApiKey)(this, cfg, {
|
|
@@ -6009,6 +6510,19 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
6009
6510
|
if (provider === 'none') {
|
|
6010
6511
|
return { error: 'No LLM provider selected.' };
|
|
6011
6512
|
}
|
|
6513
|
+
if (provider === 'pi-agent') {
|
|
6514
|
+
return { error: 'pi-agent has its own connection test in its instance settings.' };
|
|
6515
|
+
}
|
|
6516
|
+
if (provider === 'system') {
|
|
6517
|
+
const llm = await (0, systemAi_1.resolveSystemLlm)(this, await (0, systemAi_1.readSystemAi)(this), true);
|
|
6518
|
+
if ('error' in llm) {
|
|
6519
|
+
return { error: llm.error };
|
|
6520
|
+
}
|
|
6521
|
+
const res = await new llm_1.LlmAgent({ ...llm, maxTokens: 16, tools: [], log: this.log }).testConnection();
|
|
6522
|
+
return res.ok
|
|
6523
|
+
? { result: `Connection OK (${llm.provider} · ${llm.model || 'auto'})` }
|
|
6524
|
+
: { error: res.error || 'unknown error' };
|
|
6525
|
+
}
|
|
6012
6526
|
const apiKey = (0, llm_1.effectiveApiKey)(provider, await (0, credentials_1.resolveApiKey)(this, cfg, {
|
|
6013
6527
|
credentialType: msg.credentialType,
|
|
6014
6528
|
apiKey: msg.apiKey,
|