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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-DKJI_o9J.js";import{t as I}from"./index-CSKTxEh5.js";/*!-----------------------------------------------------------------------------
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
@@ -175,7 +175,7 @@
175
175
  }
176
176
  })();
177
177
  </script>
178
- <script type="module" crossorigin src="/assets/index-CSKTxEh5.js"></script>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.51.2",
3
+ "version": "1.51.3",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
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
- function init(store) {
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
- max_tokens: 4096,
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
- const message = json.content?.[0]?.text ?? '';
155
- return { message, usage };
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; // once the model has used tools, stop offering them
180
-
181
- for (let round = 0; round < 6; round++) {
182
- // After the first round of tool calls, don't offer tools again.
183
- // This forces a plain-text response rather than letting the model
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, roundTools) : await callOpenAICompat(ai, msgs, roundTools);
224
+ result = isAnthropic ? await callAnthropic(ai, msgs, tools) : await callOpenAICompat(ai, msgs, tools);
191
225
  } catch (e) {
192
- if (round === 0 && roundTools) {
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
- // If the model returned empty content after using tools, nudge it once
203
- if (!result.message && toolsUsed) {
204
- const nudgeMsgs = [...msgs, { role: 'user', content: 'Based on the information retrieved above, please now provide your complete response.' }];
205
- const nudged = isAnthropic ? await callAnthropic(ai, nudgeMsgs) : await callOpenAICompat(ai, nudgeMsgs);
206
- return { message: nudged.message ?? '', usage: nudged.usage };
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
- // Strip text blocks when tool_use blocks are present (same draft-reproduction issue)
215
- const anthropicAssistantContent = result.assistantMsg.some((b) => b.type === 'tool_use') ? result.assistantMsg.filter((b) => b.type !== 'text') : result.assistantMsg;
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
- // Fallback (should not normally be reached)
249
- const fallback = isAnthropic ? await callAnthropic(ai, msgs) : await callOpenAICompat(ai, msgs);
250
- return { message: fallback.message ?? '', usage: fallback.usage };
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({ models: [] }); // no public list endpoint
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
- res.json(result);
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
- const onToken = (t) => send({ token: t });
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 } };
@@ -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 (capped at 50).',
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
- results.push(`${topic}: ${JSON.stringify(obj.val)}`);
227
- if (results.length >= 50) {
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
  }