iobroker.assistant 1.3.0 → 1.3.1
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 +6 -0
- package/admin/custom/.vite/manifest.json +1 -1
- package/admin/custom/assets/{Components-x2Dfv8qE.js → Components-D77NmS_I.js} +1 -1
- package/admin/custom/customComponents.js +1 -1
- package/admin/custom/mf-manifest.json +1 -1
- package/admin/custom/mf-stats.json +1 -1
- package/build/lib/nlu.js +14 -0
- package/build/lib/nlu.js.map +1 -1
- package/build/lib/piAgent.js +5 -0
- package/build/lib/piAgent.js.map +1 -1
- package/build/lib/voice/esphome.js +3 -0
- package/build/lib/voice/esphome.js.map +1 -1
- package/build/main.js +119 -58
- package/build/main.js.map +1 -1
- package/io-package.json +14 -14
- package/package.json +1 -1
package/build/main.js
CHANGED
|
@@ -81,6 +81,12 @@ const WEATHER_CTX_TTL = 5 * 60 * 1000;
|
|
|
81
81
|
* that were added or renamed elsewhere.
|
|
82
82
|
*/
|
|
83
83
|
const NLU_DEVICES_TTL_MS = 60_000;
|
|
84
|
+
/**
|
|
85
|
+
* How long a question of the cloud model about a script keeps the next request of that source with the cloud model
|
|
86
|
+
* (the reply, e.g. "the kitchen light, every hour", says nothing about a script). As long as pi-agent waits for a
|
|
87
|
+
* confirmation by text.
|
|
88
|
+
*/
|
|
89
|
+
const SCRIPT_TOPIC_MS = 2 * 60_000;
|
|
84
90
|
/** Default for `localLlmTimeoutSec`: after this the local model is stopped and the next tier answers. */
|
|
85
91
|
const LOCAL_LLM_TIMEOUT_SEC = 15;
|
|
86
92
|
/**
|
|
@@ -863,6 +869,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
863
869
|
const sttStart = Date.now();
|
|
864
870
|
const text = (await engines.stt.transcribe(pcm, rate, language, await this.buildSttHints())).trim();
|
|
865
871
|
if (!text) {
|
|
872
|
+
const ms = Math.round((pcm.length / 2 / rate) * 1000);
|
|
873
|
+
this.log.info(`Voice Q (${source}): (empty) — speech recognition returned no text for ${ms} ms of audio`);
|
|
866
874
|
return { text: '', answer: '' };
|
|
867
875
|
}
|
|
868
876
|
const answer = await this.answerVoice(text, source, {
|
|
@@ -2709,12 +2717,20 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2709
2717
|
data: history,
|
|
2710
2718
|
});
|
|
2711
2719
|
}
|
|
2712
|
-
const
|
|
2720
|
+
const cloudOnly = this.cloudOnlyReason(question, source);
|
|
2721
|
+
const raw = await this.produceAnswer(question, history, source, cloudOnly);
|
|
2713
2722
|
// The LLM signals "I'm waiting for the user's reply" by appending the [[LISTEN]] control marker
|
|
2714
2723
|
// (see followUpHint). Strip it here so it's neither stored, shown nor spoken, and keep it per source:
|
|
2715
2724
|
// two satellites answered at the same time must not hand each other their mic-on.
|
|
2716
2725
|
const followUp = /\[\[LISTEN\]\]/i.test(raw);
|
|
2717
2726
|
this.followUps.set(source, followUp);
|
|
2727
|
+
// A script conversation goes on while the cloud model asks back: the reply goes to it as well.
|
|
2728
|
+
if (cloudOnly && followUp) {
|
|
2729
|
+
this.scriptTopics.set(source, Date.now());
|
|
2730
|
+
}
|
|
2731
|
+
else {
|
|
2732
|
+
this.scriptTopics.delete(source);
|
|
2733
|
+
}
|
|
2718
2734
|
if (followUp) {
|
|
2719
2735
|
this.journal?.note({ followUp: true });
|
|
2720
2736
|
}
|
|
@@ -2766,6 +2782,24 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2766
2782
|
}
|
|
2767
2783
|
/** Per source: whether its last {@link answer} asked the user something (LLM-signalled via [[LISTEN]]). */
|
|
2768
2784
|
followUps = new Map();
|
|
2785
|
+
/** Per source: when the cloud model last asked back about a script (see {@link cloudOnlyReason}). */
|
|
2786
|
+
scriptTopics = new Map();
|
|
2787
|
+
/**
|
|
2788
|
+
* Whether a request skips routines, YouTube, the rule NLU and the local model: when it is about a script, or answers
|
|
2789
|
+
* a question the cloud model asked about one. Writing or changing a script is work for the cloud model, and the
|
|
2790
|
+
* rules would read its details as commands of their own ("… that switches the kitchen light every hour" was answered
|
|
2791
|
+
* with the time). Without a cloud tier the local ones are all there is, so nothing is skipped.
|
|
2792
|
+
*/
|
|
2793
|
+
cloudOnlyReason(question, source) {
|
|
2794
|
+
if (!this.agent && !this.piAgent) {
|
|
2795
|
+
return null;
|
|
2796
|
+
}
|
|
2797
|
+
if ((0, nlu_1.mentionsScript)(question)) {
|
|
2798
|
+
return 'script';
|
|
2799
|
+
}
|
|
2800
|
+
const since = this.scriptTopics.get(source);
|
|
2801
|
+
return since !== undefined && Date.now() - since < SCRIPT_TOPIC_MS ? 'scriptReply' : null;
|
|
2802
|
+
}
|
|
2769
2803
|
/**
|
|
2770
2804
|
* Should the satellite behind `source` re-open its mic after this reply? Primarily the LLM's own
|
|
2771
2805
|
* [[LISTEN]] signal, with a question mark in the reply as the fallback (the NLU and the local tiers
|
|
@@ -2856,7 +2890,72 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2856
2890
|
const time = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(now);
|
|
2857
2891
|
return lang === 'ru' ? `Сейчас ${time}.` : lang === 'de' ? `Es ist ${time} Uhr.` : `It's ${time}.`;
|
|
2858
2892
|
}
|
|
2859
|
-
|
|
2893
|
+
/**
|
|
2894
|
+
* The tiers that answer without a model: routines, YouTube and the rule NLU. Null when none of them understood the
|
|
2895
|
+
* request.
|
|
2896
|
+
*/
|
|
2897
|
+
async answerLocally(question, source) {
|
|
2898
|
+
// A routine is a macro the user wrote down, so it wins over everything that interprets: the NLU
|
|
2899
|
+
// would find "Licht" in "Gute Nacht, Licht aus" and do half of it, and the LLM would cost a
|
|
2900
|
+
// round-trip for a decision that has already been made.
|
|
2901
|
+
const routine = (0, routines_1.matchRoutine)(question, this.routines);
|
|
2902
|
+
if (routine) {
|
|
2903
|
+
this.log.info(`Routine '${routine.name}' triggered (source='${source}').`);
|
|
2904
|
+
this.journal?.note({ tier: 'routine', detail: routine.name });
|
|
2905
|
+
this.journal?.step({ stage: 'routine', title: `Routine "${routine.name}" matched`, data: routine });
|
|
2906
|
+
await this.runRoutine(routine, source);
|
|
2907
|
+
return routine.reply;
|
|
2908
|
+
}
|
|
2909
|
+
if (this.routines.length) {
|
|
2910
|
+
this.journal?.step({
|
|
2911
|
+
stage: 'routine',
|
|
2912
|
+
title: `No routine matched (${this.routines.length} configured)`,
|
|
2913
|
+
data: { phrases: this.routines.map(r => ({ name: r.name, phrases: r.phrases })) },
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
// Tier 0: rule-based NLU (device commands) — fastest, offline, free.
|
|
2917
|
+
if (!this.config.useLocalNlu) {
|
|
2918
|
+
this.journal?.step({ stage: 'nlu', title: 'Rule-based NLU is switched off (useLocalNlu)' });
|
|
2919
|
+
return null;
|
|
2920
|
+
}
|
|
2921
|
+
// "Spiele den Kanal X ab" first: the device NLU would find the TV in "… auf dem Fernseher" and
|
|
2922
|
+
// answer with its power state.
|
|
2923
|
+
const yt = this.youtubePlayers.length ? (0, youtube_1.parseYouTubeCommand)(question, this.youtubePlayers) : null;
|
|
2924
|
+
if (yt) {
|
|
2925
|
+
this.log.info(`YouTube request understood locally: ${yt.kind} "${yt.query}" (source='${source}').`);
|
|
2926
|
+
this.journal?.note({ tier: 'nlu', detail: `youtube ${yt.kind}` });
|
|
2927
|
+
const start = Date.now();
|
|
2928
|
+
const res = await this.playYouTube(yt.query, yt.kind, yt.player);
|
|
2929
|
+
this.journal?.step({
|
|
2930
|
+
stage: 'youtube',
|
|
2931
|
+
title: `YouTube ${yt.kind} "${yt.query}"${yt.player ? ` on "${yt.player}"` : ''}`,
|
|
2932
|
+
ms: Date.now() - start,
|
|
2933
|
+
data: { parsed: yt, result: res },
|
|
2934
|
+
});
|
|
2935
|
+
return res.text;
|
|
2936
|
+
}
|
|
2937
|
+
if (this.youtubePlayers.length) {
|
|
2938
|
+
this.journal?.step({ stage: 'youtube', title: 'Not a YouTube command' });
|
|
2939
|
+
}
|
|
2940
|
+
try {
|
|
2941
|
+
const handled = await this.tryLocalNlu(question, source);
|
|
2942
|
+
if (handled !== null) {
|
|
2943
|
+
this.log.info(`Answered by rule-based NLU (source='${source}').`);
|
|
2944
|
+
return handled;
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
catch (e) {
|
|
2948
|
+
this.log.debug(`NLU skipped: ${e.message}`);
|
|
2949
|
+
this.journal?.step({
|
|
2950
|
+
stage: 'nlu',
|
|
2951
|
+
level: 'warn',
|
|
2952
|
+
title: `NLU gave up, the request goes on: ${e.message}`,
|
|
2953
|
+
data: (0, journal_1.errorInfo)(e),
|
|
2954
|
+
});
|
|
2955
|
+
}
|
|
2956
|
+
return null;
|
|
2957
|
+
}
|
|
2958
|
+
async produceAnswer(question, history, source = '', cloudOnly = null) {
|
|
2860
2959
|
this.log.debug(`answer: source='${source}', context=${history.length} turn(s)`);
|
|
2861
2960
|
// A ringing timer/alarm is silenced by a stop word ("stop"/"halt"/"aufhören"/…) — checked first so
|
|
2862
2961
|
// it always wins over the normal pipeline while something is ringing.
|
|
@@ -2884,64 +2983,22 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2884
2983
|
return '';
|
|
2885
2984
|
}
|
|
2886
2985
|
}
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
this.log.info(`Routine '${routine.name}' triggered (source='${source}').`);
|
|
2893
|
-
this.journal?.note({ tier: 'routine', detail: routine.name });
|
|
2894
|
-
this.journal?.step({ stage: 'routine', title: `Routine "${routine.name}" matched`, data: routine });
|
|
2895
|
-
await this.runRoutine(routine, source);
|
|
2896
|
-
return routine.reply;
|
|
2897
|
-
}
|
|
2898
|
-
if (this.routines.length) {
|
|
2986
|
+
if (cloudOnly) {
|
|
2987
|
+
const why = cloudOnly === 'script'
|
|
2988
|
+
? 'it is about a script'
|
|
2989
|
+
: 'it answers a question of the cloud model about a script';
|
|
2990
|
+
this.log.info(`Routines, NLU and local LLM skipped (source='${source}'): ${why}.`);
|
|
2899
2991
|
this.journal?.step({
|
|
2900
|
-
stage: '
|
|
2901
|
-
title: `
|
|
2902
|
-
data: {
|
|
2992
|
+
stage: 'nlu',
|
|
2993
|
+
title: `Routines, NLU and local LLM skipped: ${why}`,
|
|
2994
|
+
data: { reason: cloudOnly },
|
|
2903
2995
|
});
|
|
2904
2996
|
}
|
|
2905
|
-
// Tier 0: rule-based NLU (device commands) — fastest, offline, free.
|
|
2906
|
-
if (this.config.useLocalNlu) {
|
|
2907
|
-
// "Spiele den Kanal X ab" first: the device NLU would find the TV in "… auf dem Fernseher" and
|
|
2908
|
-
// answer with its power state.
|
|
2909
|
-
const yt = this.youtubePlayers.length ? (0, youtube_1.parseYouTubeCommand)(question, this.youtubePlayers) : null;
|
|
2910
|
-
if (yt) {
|
|
2911
|
-
this.log.info(`YouTube request understood locally: ${yt.kind} "${yt.query}" (source='${source}').`);
|
|
2912
|
-
this.journal?.note({ tier: 'nlu', detail: `youtube ${yt.kind}` });
|
|
2913
|
-
const start = Date.now();
|
|
2914
|
-
const res = await this.playYouTube(yt.query, yt.kind, yt.player);
|
|
2915
|
-
this.journal?.step({
|
|
2916
|
-
stage: 'youtube',
|
|
2917
|
-
title: `YouTube ${yt.kind} "${yt.query}"${yt.player ? ` on "${yt.player}"` : ''}`,
|
|
2918
|
-
ms: Date.now() - start,
|
|
2919
|
-
data: { parsed: yt, result: res },
|
|
2920
|
-
});
|
|
2921
|
-
return res.text;
|
|
2922
|
-
}
|
|
2923
|
-
if (this.youtubePlayers.length) {
|
|
2924
|
-
this.journal?.step({ stage: 'youtube', title: 'Not a YouTube command' });
|
|
2925
|
-
}
|
|
2926
|
-
try {
|
|
2927
|
-
const handled = await this.tryLocalNlu(question, source);
|
|
2928
|
-
if (handled !== null) {
|
|
2929
|
-
this.log.info(`Answered by rule-based NLU (source='${source}').`);
|
|
2930
|
-
return handled;
|
|
2931
|
-
}
|
|
2932
|
-
}
|
|
2933
|
-
catch (e) {
|
|
2934
|
-
this.log.debug(`NLU skipped: ${e.message}`);
|
|
2935
|
-
this.journal?.step({
|
|
2936
|
-
stage: 'nlu',
|
|
2937
|
-
level: 'warn',
|
|
2938
|
-
title: `NLU gave up, the request goes on: ${e.message}`,
|
|
2939
|
-
data: (0, journal_1.errorInfo)(e),
|
|
2940
|
-
});
|
|
2941
|
-
}
|
|
2942
|
-
}
|
|
2943
2997
|
else {
|
|
2944
|
-
|
|
2998
|
+
const local = await this.answerLocally(question, source);
|
|
2999
|
+
if (local !== null) {
|
|
3000
|
+
return local;
|
|
3001
|
+
}
|
|
2945
3002
|
}
|
|
2946
3003
|
// Current weather from the configured adapter — prepended to the user turn of BOTH LLM tiers so
|
|
2947
3004
|
// "how's the weather?" is answered from real data (the local model has no tools and would otherwise
|
|
@@ -2955,7 +3012,8 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
2955
3012
|
// so it would answer a follow-up like "yes" context-free and swallow it — let the cloud LLM (which has
|
|
2956
3013
|
// the thread and the tools) handle follow-ups instead. Without a cloud LLM there is nobody to hand
|
|
2957
3014
|
// follow-ups to, so the local model takes them too.
|
|
2958
|
-
|
|
3015
|
+
// A script request does not even fall back to it: a model without tools cannot write a script.
|
|
3016
|
+
const useLocal = !cloudOnly && !!(this.config.useLocalLlm && this.localLlm);
|
|
2959
3017
|
const cloud = !!(this.agent || this.piAgent);
|
|
2960
3018
|
const localFirst = useLocal && (!cloud || !!this.config.localLlmFirst);
|
|
2961
3019
|
if (localFirst && (!history.length || !cloud)) {
|
|
@@ -3066,7 +3124,10 @@ class Assistant extends adapter_core_1.Adapter {
|
|
|
3066
3124
|
this.log.info(`Cloud tier ${harness.instance} (source='${source}') — asking pi-agent…`);
|
|
3067
3125
|
this.journal?.note({ tier: 'cloud', detail: harness.instance });
|
|
3068
3126
|
try {
|
|
3069
|
-
|
|
3127
|
+
// pi-agent asks its questions to confirm a write ("… Say yes or no.") in this language, not in the system
|
|
3128
|
+
// language: a satellite listening in Russian does not hear a German "Ja"
|
|
3129
|
+
const language = String(this.config.voiceLanguage || this.language || '');
|
|
3130
|
+
return await harness.ask({ text, conversation: source, instructions, language });
|
|
3070
3131
|
}
|
|
3071
3132
|
catch (e) {
|
|
3072
3133
|
if (localFallback) {
|