smart-home-engine 1.51.2 → 1.51.3
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/dist/web/assets/{index-CSKTxEh5.js → index-D6H4drtd.js} +113 -113
- package/dist/web/assets/{tsMode-4A8DZYIU.js → tsMode-sNUSGo5H.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/index.js +1 -1
- package/src/web/ai-api.js +124 -32
- package/src/web/ai-tools.js +15 -8
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-
|
|
1
|
+
import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-D6H4drtd.js";/*!-----------------------------------------------------------------------------
|
|
2
2
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
3
|
* Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
|
|
4
4
|
* Released under the MIT license
|
package/dist/web/index.html
CHANGED
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
}
|
|
176
176
|
})();
|
|
177
177
|
</script>
|
|
178
|
-
<script type="module" crossorigin src="/assets/index-
|
|
178
|
+
<script type="module" crossorigin src="/assets/index-D6H4drtd.js"></script>
|
|
179
179
|
<link rel="modulepreload" crossorigin href="/assets/monaco-langs-DKJI_o9J.js">
|
|
180
180
|
<link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
|
|
181
181
|
<link rel="stylesheet" crossorigin href="/assets/index-DPDW78ru.css">
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -448,7 +448,7 @@ require('./web/mqtt-api').init(store, () => mqtt);
|
|
|
448
448
|
require('./web/services-api').init(store, () => mqtt, {
|
|
449
449
|
getMqttConfig: () => ({ url: config.url, username: config.mqttUsername, password: config.mqttPassword }),
|
|
450
450
|
});
|
|
451
|
-
require('./web/ai-api').init(store);
|
|
451
|
+
require('./web/ai-api').init(store, log);
|
|
452
452
|
|
|
453
453
|
// MQTT message rate counter — reset on each stats poll
|
|
454
454
|
let _mqttMsgCount = 0;
|
package/src/web/ai-api.js
CHANGED
|
@@ -30,7 +30,14 @@ let _store = null;
|
|
|
30
30
|
/**
|
|
31
31
|
* @param {import('../lib/state-store')} store
|
|
32
32
|
*/
|
|
33
|
-
|
|
33
|
+
let _log = console;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} store
|
|
37
|
+
* @param {{ error: Function, warn: Function }} [log] — the daemon's logger; console until init
|
|
38
|
+
*/
|
|
39
|
+
function init(store, log) {
|
|
40
|
+
if (log) _log = log;
|
|
34
41
|
_store = store;
|
|
35
42
|
}
|
|
36
43
|
|
|
@@ -122,7 +129,8 @@ async function callAnthropic(config, messages, tools) {
|
|
|
122
129
|
model: config.model,
|
|
123
130
|
system: systemMsg?.content || '',
|
|
124
131
|
messages: userMessages,
|
|
125
|
-
|
|
132
|
+
// thinking tokens count against this on the current models; 4096 cut answers short
|
|
133
|
+
max_tokens: 16384,
|
|
126
134
|
};
|
|
127
135
|
if (tools?.length) body.tools = tools;
|
|
128
136
|
|
|
@@ -151,8 +159,36 @@ async function callAnthropic(config, messages, tools) {
|
|
|
151
159
|
return { toolCalls, assistantMsg: json.content, usage };
|
|
152
160
|
}
|
|
153
161
|
|
|
154
|
-
|
|
155
|
-
|
|
162
|
+
// an answer without any text block (e.g. only a thinking block) is reported, not shown as nothing:
|
|
163
|
+
// the caller nudges once and otherwise turns `detail` into the error
|
|
164
|
+
const message = answerText(json.content);
|
|
165
|
+
const detail = message ? undefined : `stop_reason ${json.stop_reason}, content blocks: ${(json.content || []).map((b) => b.type).join(',') || 'none'}`;
|
|
166
|
+
return { message, usage, detail, assistantMsg: json.content };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* What a provider may see of a conversation: role and content only. The chat page stores more on its messages
|
|
171
|
+
* (tool events, timestamps) and Anthropic rejects unknown fields; empty messages are left out too (Anthropic
|
|
172
|
+
* rejects empty text, and the chat saved empty answers before B-13).
|
|
173
|
+
* @param {Array<{role:string,content:any}>} messages
|
|
174
|
+
*/
|
|
175
|
+
function providerMessages(messages) {
|
|
176
|
+
return messages
|
|
177
|
+
.filter((m) => m && typeof m.role === 'string')
|
|
178
|
+
.map((m) => ({ role: m.role, content: m.content }))
|
|
179
|
+
.filter((m) => (typeof m.content === 'string' ? m.content.trim() !== '' : Array.isArray(m.content) ? m.content.length > 0 : m.content != null));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The text of an Anthropic answer: every text block joined, other block types (thinking, tool_use) ignored.
|
|
184
|
+
* @param {Array<{type:string,text?:string}>|undefined} content
|
|
185
|
+
*/
|
|
186
|
+
function answerText(content) {
|
|
187
|
+
if (!Array.isArray(content)) return '';
|
|
188
|
+
return content
|
|
189
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
190
|
+
.map((b) => b.text)
|
|
191
|
+
.join('');
|
|
156
192
|
}
|
|
157
193
|
|
|
158
194
|
// ---------------------------------------------------------------------------
|
|
@@ -176,20 +212,18 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
|
|
|
176
212
|
const isAnthropic = ai.provider === 'anthropic';
|
|
177
213
|
const tools = isAnthropic ? TOOL_DEFINITIONS_ANTHROPIC : TOOL_DEFINITIONS;
|
|
178
214
|
let msgs = messages;
|
|
179
|
-
let toolsUsed = false;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// keep calling tools indefinitely (and some models return empty
|
|
185
|
-
// content when given tools but no reason to call them).
|
|
186
|
-
const roundTools = toolsUsed ? undefined : tools;
|
|
215
|
+
let toolsUsed = false;
|
|
216
|
+
// The tools stay on offer in every round (up to MAX_TOOL_ROUNDS): a search that was cut short can be refined,
|
|
217
|
+
// and a history with tool_use blocks is only valid when the tools are defined. The round cap ends a model that
|
|
218
|
+
// keeps calling tools; an empty final answer is nudged once below.
|
|
219
|
+
const MAX_TOOL_ROUNDS = 8;
|
|
187
220
|
|
|
221
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
188
222
|
let result;
|
|
189
223
|
try {
|
|
190
|
-
result = isAnthropic ? await callAnthropic(ai, msgs,
|
|
224
|
+
result = isAnthropic ? await callAnthropic(ai, msgs, tools) : await callOpenAICompat(ai, msgs, tools);
|
|
191
225
|
} catch (e) {
|
|
192
|
-
if (round === 0
|
|
226
|
+
if (round === 0) {
|
|
193
227
|
// Model may not support tool calling — retry without tools
|
|
194
228
|
result = isAnthropic ? await callAnthropic(ai, msgs) : await callOpenAICompat(ai, msgs);
|
|
195
229
|
} else {
|
|
@@ -199,11 +233,21 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
|
|
|
199
233
|
|
|
200
234
|
// No tool calls → we have the final answer
|
|
201
235
|
if (!result.toolCalls?.length) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const nudgeMsgs = [
|
|
205
|
-
|
|
206
|
-
|
|
236
|
+
if (!result.message) {
|
|
237
|
+
// An answer without text (a thinking block only, or nothing): ask once for the answer itself.
|
|
238
|
+
const nudgeMsgs = [
|
|
239
|
+
...msgs,
|
|
240
|
+
{
|
|
241
|
+
role: 'user',
|
|
242
|
+
content: toolsUsed
|
|
243
|
+
? 'Based on the information retrieved above, please now provide your complete response.'
|
|
244
|
+
: 'Please provide your complete response as text.',
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
const nudged = isAnthropic
|
|
248
|
+
? await callAnthropic(ai, nudgeMsgs, toolsUsed ? tools : undefined)
|
|
249
|
+
: await callOpenAICompat(ai, nudgeMsgs, toolsUsed ? tools : undefined);
|
|
250
|
+
return { message: nudged.message ?? '', usage: nudged.usage, detail: nudged.message ? undefined : nudged.detail || result.detail };
|
|
207
251
|
}
|
|
208
252
|
return { message: result.message ?? '', usage: result.usage };
|
|
209
253
|
}
|
|
@@ -211,9 +255,8 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
|
|
|
211
255
|
// Execute tool calls and append results to message history
|
|
212
256
|
toolsUsed = true;
|
|
213
257
|
if (isAnthropic) {
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
msgs = [...msgs, { role: 'assistant', content: anthropicAssistantContent }];
|
|
258
|
+
// the assistant turn goes back unchanged: the current models bind their thinking blocks to the turn
|
|
259
|
+
msgs = [...msgs, { role: 'assistant', content: result.assistantMsg }];
|
|
217
260
|
const toolResultBlocks = [];
|
|
218
261
|
for (const tc of result.toolCalls) {
|
|
219
262
|
const args = tc.input || {};
|
|
@@ -245,9 +288,11 @@ async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
|
|
|
245
288
|
}
|
|
246
289
|
}
|
|
247
290
|
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
291
|
+
// The round cap was reached: ask for the answer with what was gathered (the tools stay defined - the history
|
|
292
|
+
// holds tool_use blocks - but the request asks for text).
|
|
293
|
+
const finalMsgs = [...msgs, { role: 'user', content: 'Please answer now with the information gathered so far; do not call any more tools.' }];
|
|
294
|
+
const fallback = isAnthropic ? await callAnthropic(ai, finalMsgs, tools) : await callOpenAICompat(ai, finalMsgs, tools);
|
|
295
|
+
return { message: fallback.message ?? '', usage: fallback.usage, detail: fallback.message ? undefined : fallback.detail };
|
|
251
296
|
}
|
|
252
297
|
|
|
253
298
|
// ---------------------------------------------------------------------------
|
|
@@ -363,6 +408,42 @@ router.get('/config', (req, res) => {
|
|
|
363
408
|
});
|
|
364
409
|
});
|
|
365
410
|
|
|
411
|
+
// Shown when the Anthropic models endpoint cannot be reached (no key, offline, 401): the current families.
|
|
412
|
+
const ANTHROPIC_FALLBACK_MODELS = ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-haiku-4-5'];
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The models the configured Anthropic key may use: GET /v1/models, every page, newest first.
|
|
416
|
+
* On failure the static fallback list with the error, so the chat still offers a choice.
|
|
417
|
+
* @param {{ apiKey?: string }} ai
|
|
418
|
+
* @returns {Promise<{ models: string[], names: Record<string,string>, error?: string }>}
|
|
419
|
+
*/
|
|
420
|
+
async function listAnthropicModels(ai) {
|
|
421
|
+
const headers = { 'x-api-key': ai.apiKey || '', 'anthropic-version': '2023-06-01' };
|
|
422
|
+
const all = [];
|
|
423
|
+
try {
|
|
424
|
+
let after = null;
|
|
425
|
+
for (let page = 0; page < 20; page++) {
|
|
426
|
+
const url = 'https://api.anthropic.com/v1/models?limit=100' + (after ? '&after_id=' + encodeURIComponent(after) : '');
|
|
427
|
+
const r = await fetch(url, { headers });
|
|
428
|
+
if (!r.ok) {
|
|
429
|
+
const text = await r.text().catch(() => '');
|
|
430
|
+
throw new Error(`Anthropic API error ${r.status}: ${text.slice(0, 200)}`);
|
|
431
|
+
}
|
|
432
|
+
const json = await r.json();
|
|
433
|
+
for (const m of json.data || []) if (m && m.id) all.push(m);
|
|
434
|
+
if (!json.has_more || !json.last_id) break;
|
|
435
|
+
after = json.last_id;
|
|
436
|
+
}
|
|
437
|
+
} catch (e) {
|
|
438
|
+
_log.warn('ai models: ' + e.message);
|
|
439
|
+
return { models: ANTHROPIC_FALLBACK_MODELS.slice(), names: {}, error: e.message };
|
|
440
|
+
}
|
|
441
|
+
all.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
|
442
|
+
const names = {};
|
|
443
|
+
for (const m of all) if (m.display_name) names[m.id] = m.display_name;
|
|
444
|
+
return { models: all.map((m) => m.id), names };
|
|
445
|
+
}
|
|
446
|
+
|
|
366
447
|
// GET /she/ai/models — list available models for the configured provider
|
|
367
448
|
router.get('/models', async (req, res) => {
|
|
368
449
|
const ai = readAiConfig(req.app.locals.configPath);
|
|
@@ -381,7 +462,7 @@ router.get('/models', async (req, res) => {
|
|
|
381
462
|
.sort();
|
|
382
463
|
return res.json({ models });
|
|
383
464
|
} else if (ai.provider === 'anthropic') {
|
|
384
|
-
return res.json(
|
|
465
|
+
return res.json(await listAnthropicModels(ai));
|
|
385
466
|
} else {
|
|
386
467
|
// OpenAI / LM Studio / etc. — try /v1/models
|
|
387
468
|
const h = { 'Content-Type': 'application/json' };
|
|
@@ -457,8 +538,9 @@ router.post('/chat', async (req, res) => {
|
|
|
457
538
|
if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
|
|
458
539
|
|
|
459
540
|
const aiWithModel = { ...ai, model: effectiveModel };
|
|
541
|
+
_log.debug(`ai chat: ${ai.provider} ${effectiveModel}${modelOverride ? ' (chosen in the chat)' : ' (config)'}`);
|
|
460
542
|
const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store, extraFiles || []);
|
|
461
|
-
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
543
|
+
const fullMessages = [{ role: 'system', content: systemPrompt }, ...providerMessages(messages)];
|
|
462
544
|
|
|
463
545
|
try {
|
|
464
546
|
let result;
|
|
@@ -470,8 +552,10 @@ router.post('/chat', async (req, res) => {
|
|
|
470
552
|
} else {
|
|
471
553
|
result = await callOpenAICompat(aiWithModel, fullMessages);
|
|
472
554
|
}
|
|
473
|
-
|
|
555
|
+
if (!result.message || !String(result.message).trim()) throw new Error('The model returned an empty answer' + (result.detail ? ` (${result.detail})` : ''));
|
|
556
|
+
res.json({ message: result.message, usage: result.usage });
|
|
474
557
|
} catch (e) {
|
|
558
|
+
_log.error('ai chat: ' + e.message);
|
|
475
559
|
res.status(500).json({ error: e.message });
|
|
476
560
|
}
|
|
477
561
|
});
|
|
@@ -488,6 +572,7 @@ router.post('/chat/stream', async (req, res) => {
|
|
|
488
572
|
if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
|
|
489
573
|
|
|
490
574
|
const aiWithModel = { ...ai, model: effectiveModel };
|
|
575
|
+
_log.debug(`ai chat: ${ai.provider} ${effectiveModel}${modelOverride ? ' (chosen in the chat)' : ' (config)'}`);
|
|
491
576
|
|
|
492
577
|
// Build system prompt BEFORE flushing headers so errors can still return a proper HTTP status
|
|
493
578
|
let systemPrompt;
|
|
@@ -506,27 +591,34 @@ router.post('/chat/stream', async (req, res) => {
|
|
|
506
591
|
|
|
507
592
|
const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
508
593
|
|
|
509
|
-
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
594
|
+
const fullMessages = [{ role: 'system', content: systemPrompt }, ...providerMessages(messages)];
|
|
510
595
|
|
|
511
596
|
try {
|
|
512
597
|
if (context.tools) {
|
|
513
598
|
// Tool-calling mode: resolve tools non-streaming (emitting events), then
|
|
514
599
|
// send the final answer as a single token so the client sees it immediately.
|
|
515
600
|
const toolContext = { store: _store, scriptDir: req.app.locals.scriptDir || null };
|
|
516
|
-
const { message } = await resolveAndGetAnswer(aiWithModel, fullMessages, toolContext, send);
|
|
601
|
+
const { message, detail } = await resolveAndGetAnswer(aiWithModel, fullMessages, toolContext, send);
|
|
602
|
+
if (!message || !message.trim()) throw new Error('The model returned an empty answer' + (detail ? ` (${detail})` : ''));
|
|
517
603
|
send({ token: message });
|
|
518
604
|
} else {
|
|
519
|
-
|
|
605
|
+
let tokens = 0;
|
|
606
|
+
const onToken = (t) => {
|
|
607
|
+
tokens++;
|
|
608
|
+
send({ token: t });
|
|
609
|
+
};
|
|
520
610
|
if (ai.provider === 'anthropic') {
|
|
521
611
|
await streamAnthropic(aiWithModel, fullMessages, onToken);
|
|
522
612
|
} else {
|
|
523
613
|
await streamOpenAICompat(aiWithModel, fullMessages, onToken);
|
|
524
614
|
}
|
|
615
|
+
if (tokens === 0) throw new Error('The model returned an empty answer');
|
|
525
616
|
}
|
|
526
617
|
|
|
527
618
|
res.write('data: [DONE]\n\n');
|
|
528
619
|
res.end();
|
|
529
620
|
} catch (e) {
|
|
621
|
+
_log.error('ai chat: ' + e.message);
|
|
530
622
|
send({ error: e.message });
|
|
531
623
|
res.end();
|
|
532
624
|
}
|
|
@@ -607,4 +699,4 @@ router.delete('/conversations/:id', (req, res) => {
|
|
|
607
699
|
res.json({ ok: true });
|
|
608
700
|
});
|
|
609
701
|
|
|
610
|
-
module.exports = { router, init };
|
|
702
|
+
module.exports = { router, init, _internal: { callAnthropic, answerText, readAiConfig, listAnthropicModels, ANTHROPIC_FALLBACK_MODELS, providerMessages } };
|
package/src/web/ai-tools.js
CHANGED
|
@@ -34,7 +34,12 @@ const TOOL_DEFINITIONS = [
|
|
|
34
34
|
properties: {
|
|
35
35
|
query: {
|
|
36
36
|
type: 'string',
|
|
37
|
-
description: 'Case-insensitive substring to match against topic names. Pass empty string to list all topics
|
|
37
|
+
description: 'Case-insensitive substring to match against topic names. Pass empty string to list all topics.',
|
|
38
|
+
},
|
|
39
|
+
limit: {
|
|
40
|
+
type: 'integer',
|
|
41
|
+
description:
|
|
42
|
+
'Maximum number of topics to return (1-500, default 50). The result says how many matched in total; raise the limit or narrow the query to see the rest.',
|
|
38
43
|
},
|
|
39
44
|
},
|
|
40
45
|
required: ['query'],
|
|
@@ -217,19 +222,21 @@ async function executeTool(name, args, ctx) {
|
|
|
217
222
|
// Individual tools
|
|
218
223
|
// ---------------------------------------------------------------------------
|
|
219
224
|
|
|
220
|
-
function toolSearchMqttTopics({ query = '' }, store) {
|
|
225
|
+
function toolSearchMqttTopics({ query = '', limit = 50 }, store) {
|
|
221
226
|
if (!store) return 'MQTT state store not available.';
|
|
222
|
-
const q = query.toLowerCase();
|
|
227
|
+
const q = String(query).toLowerCase();
|
|
228
|
+
const cap = Math.min(Math.max(1, Number(limit) || 50), 500);
|
|
223
229
|
const results = [];
|
|
230
|
+
let total = 0;
|
|
224
231
|
for (const [topic, obj] of store.mqttEntries()) {
|
|
225
232
|
if (!q || topic.toLowerCase().includes(q)) {
|
|
226
|
-
|
|
227
|
-
if (results.length
|
|
228
|
-
results.push('… (truncated to 50 results, use a more specific query)');
|
|
229
|
-
break;
|
|
230
|
-
}
|
|
233
|
+
total++;
|
|
234
|
+
if (results.length < cap) results.push(`${topic}: ${JSON.stringify(obj.val)}`);
|
|
231
235
|
}
|
|
232
236
|
}
|
|
237
|
+
if (total > results.length) {
|
|
238
|
+
results.push(`… ${results.length} of ${total} matching topics shown; raise the limit (up to 500) or narrow the query for the rest.`);
|
|
239
|
+
}
|
|
233
240
|
if (results.length === 0) {
|
|
234
241
|
return q ? `No MQTT topics found matching "${query}".` : 'No MQTT topics tracked yet.';
|
|
235
242
|
}
|