smart-home-engine 0.13.0 → 0.14.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/dist/web/assets/{index-CxNH_rV4.css → index-BcOZhXqD.css} +1 -1
- package/dist/web/assets/index-oMmhHXuR.js +220 -0
- package/dist/web/assets/{tsMode-DcNPXUSe.js → tsMode-BcZhguVQ.js} +1 -1
- package/dist/web/index.html +5 -4
- package/package.json +85 -87
- package/src/config.js +4 -0
- package/src/index.js +50 -3
- package/src/matter/controller.js +161 -23
- package/src/sandbox/matter-sandbox.js +20 -15
- package/src/sandbox/stdlib.js +17 -0
- package/src/web/ai-api.js +137 -222
- package/src/web/ai-context.js +139 -0
- package/src/web/ai-tools.js +210 -0
- package/src/web/log-ws.js +16 -1
- package/src/web/matter-api.js +7 -2
- package/src/web/prompts/db-doc.md +17 -0
- package/src/web/prompts/db-view.md +30 -0
- package/src/web/prompts/scripts-base.md +18 -0
- package/src/web/prompts/she-api-ref.md +73 -0
- package/src/web/scripts-api.js +4 -8
- package/src/web/server.js +8 -1
- package/dist/web/assets/index-UyOLwDd5.js +0 -220
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* she.matter.send(nodeId, endpointId, clusterName, command, args?)
|
|
16
16
|
* → Promise<result>
|
|
17
17
|
*
|
|
18
|
+
* nodeId — decimal NodeId string (e.g. '4') OR device name (e.g. 'Matterbridge')
|
|
19
|
+
* endpointId — numeric endpoint id (e.g. 43) OR endpoint name (e.g. 'Licht Werkstatt')
|
|
20
|
+
* Name matching uses basicInformation.nodeLabel / productName for devices and
|
|
21
|
+
* bridgedDeviceBasicInformation.nodeLabel / basicInformation.nodeLabel for endpoints.
|
|
22
|
+
*
|
|
18
23
|
* All subscriptions registered by a script are automatically cancelled on
|
|
19
24
|
* hot-reload (cleanup() is called from unloadScript() in index.js).
|
|
20
25
|
*/
|
|
@@ -26,11 +31,11 @@ module.exports = function (she, { scriptDomain, scriptName }) {
|
|
|
26
31
|
/**
|
|
27
32
|
* Subscribe to an attribute change on a paired Matter device.
|
|
28
33
|
*
|
|
29
|
-
* @param {string}
|
|
30
|
-
* @param {number}
|
|
31
|
-
* @param {string}
|
|
32
|
-
* @param {string}
|
|
33
|
-
* @param {Function}
|
|
34
|
+
* @param {string|number} nodeId Decimal NodeId string/number OR device name
|
|
35
|
+
* @param {number|string} endpointId Numeric endpoint id OR endpoint name
|
|
36
|
+
* @param {string} clusterName camelCase cluster name, e.g. "onOff"
|
|
37
|
+
* @param {string} attrName camelCase attribute name, e.g. "onOff"
|
|
38
|
+
* @param {Function} callback (value, oldValue) => void
|
|
34
39
|
* @returns {number} listenerId
|
|
35
40
|
*/
|
|
36
41
|
sub(nodeId, endpointId, clusterName, attrName, callback) {
|
|
@@ -53,10 +58,10 @@ module.exports = function (she, { scriptDomain, scriptName }) {
|
|
|
53
58
|
/**
|
|
54
59
|
* Read a single attribute value from a paired Matter device.
|
|
55
60
|
*
|
|
56
|
-
* @param {string} nodeId
|
|
57
|
-
* @param {number} endpointId
|
|
58
|
-
* @param {string}
|
|
59
|
-
* @param {string}
|
|
61
|
+
* @param {string|number} nodeId Decimal NodeId string/number OR device name
|
|
62
|
+
* @param {number|string} endpointId Numeric endpoint id OR endpoint name
|
|
63
|
+
* @param {string} clusterName camelCase cluster name
|
|
64
|
+
* @param {string} attrName camelCase attribute name
|
|
60
65
|
* @returns {Promise<unknown>}
|
|
61
66
|
*/
|
|
62
67
|
get(nodeId, endpointId, clusterName, attrName) {
|
|
@@ -66,15 +71,15 @@ module.exports = function (she, { scriptDomain, scriptName }) {
|
|
|
66
71
|
/**
|
|
67
72
|
* Invoke a cluster command on a paired Matter device.
|
|
68
73
|
*
|
|
69
|
-
* @param {string}
|
|
70
|
-
* @param {number}
|
|
71
|
-
* @param {string}
|
|
72
|
-
* @param {string}
|
|
73
|
-
* @param {object}
|
|
74
|
+
* @param {string|number} nodeId Decimal NodeId string/number OR device name
|
|
75
|
+
* @param {number|string} endpointId Numeric endpoint id OR endpoint name
|
|
76
|
+
* @param {string} clusterName camelCase cluster name
|
|
77
|
+
* @param {string} command camelCase command name
|
|
78
|
+
* @param {object} [args] Command arguments (omit for void commands)
|
|
74
79
|
* @returns {Promise<unknown>}
|
|
75
80
|
*/
|
|
76
81
|
send(nodeId, endpointId, clusterName, command, args) {
|
|
77
|
-
return controller.sendCommand(nodeId, endpointId, clusterName, command, args
|
|
82
|
+
return controller.sendCommand(nodeId, endpointId, clusterName, command, args);
|
|
78
83
|
},
|
|
79
84
|
};
|
|
80
85
|
};
|
package/src/sandbox/stdlib.js
CHANGED
|
@@ -129,4 +129,21 @@ module.exports = function (she) {
|
|
|
129
129
|
/** Register a callback for MQTT connection lifecycle events ('connect' or 'disconnect'). */
|
|
130
130
|
on: (event, cb) => she._registerMqttEvent(event, cb),
|
|
131
131
|
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Fetch a URL and return a Promise that resolves to the response body.
|
|
135
|
+
* Resolves to parsed JSON when the Content-Type is application/json, plain text otherwise.
|
|
136
|
+
* Rejects on non-2xx responses.
|
|
137
|
+
* @method fetch
|
|
138
|
+
* @param {string} url
|
|
139
|
+
* @param {RequestInit} [options]
|
|
140
|
+
* @returns {Promise<string|object>}
|
|
141
|
+
*/
|
|
142
|
+
she.fetch = function Sandbox_fetch(url, options) {
|
|
143
|
+
return fetch(url, options).then((r) => {
|
|
144
|
+
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
|
|
145
|
+
const ct = r.headers.get('content-type') || '';
|
|
146
|
+
return ct.includes('json') ? r.json() : r.text();
|
|
147
|
+
});
|
|
148
|
+
};
|
|
132
149
|
};
|
package/src/web/ai-api.js
CHANGED
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
const express = require('express');
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
|
|
22
|
+
const { buildSystemPrompt } = require('./ai-context');
|
|
23
|
+
const { TOOL_DEFINITIONS, TOOL_DEFINITIONS_ANTHROPIC, executeTool } = require('./ai-tools');
|
|
24
|
+
|
|
22
25
|
const router = express.Router();
|
|
23
26
|
let _store = null;
|
|
24
27
|
|
|
@@ -29,71 +32,6 @@ function init(store) {
|
|
|
29
32
|
_store = store;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// she API reference — injected into the system prompt when requested
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
const SHE_API_REF = `## she sandbox API
|
|
36
|
-
|
|
37
|
-
Scripts run in a sandboxed VM. The \`she\` object is injected automatically.
|
|
38
|
-
|
|
39
|
-
### Script conventions
|
|
40
|
-
- First lines: /* global she */ then 'use strict';
|
|
41
|
-
- No require() — the module system is not available
|
|
42
|
-
- All subscriptions and schedules persist across reconnects
|
|
43
|
-
|
|
44
|
-
### MQTT
|
|
45
|
-
she.mqtt.sub(topic, [opts], cb) Subscribe; wildcards: + (1 level) # (multi)
|
|
46
|
-
+//sensor → +/status/sensor shorthand
|
|
47
|
-
opts.change: true = only fire when value changes
|
|
48
|
-
she.mqtt.pub(topic, payload, [opts]) Publish; opts: { qos, retain }
|
|
49
|
-
she.mqtt.get(topic) Current retained value (sync)
|
|
50
|
-
she.mqtt.set(topic, val) Publish as retained
|
|
51
|
-
she.mqtt.link(src, target, [fn]) Forward src changes to target; optional transform
|
|
52
|
-
she.mqtt.age(topic) Seconds since topic last received a message
|
|
53
|
-
she.mqtt.on('connect'|'disconnect', cb) MQTT lifecycle events
|
|
54
|
-
|
|
55
|
-
### Scheduling
|
|
56
|
-
she.schedule(pattern, [opts], cb)
|
|
57
|
-
pattern: cron string | Date | suncalc event name
|
|
58
|
-
suncalc events: 'sunrise' 'sunset' 'dawn' 'dusk'
|
|
59
|
-
'nauticalDawn' 'nauticalDusk' 'solarNoon' 'night'
|
|
60
|
-
opts.shift: seconds offset (e.g. -1800 = 30 min before event)
|
|
61
|
-
opts.random: max random delay in seconds added to the trigger time
|
|
62
|
-
|
|
63
|
-
### Universal key-value API
|
|
64
|
-
she.on(key, cb) Subscribe. Key prefixes: mqtt:: var:: matter::
|
|
65
|
-
she.set(key, val) Set value (mqtt:: or var:: namespaces)
|
|
66
|
-
she.get(key) Current value
|
|
67
|
-
she.getObject(key) Current { val, ts, lc } state object
|
|
68
|
-
|
|
69
|
-
### Variable system (var:: namespace)
|
|
70
|
-
Topics prefixed with "var" (default) are persisted as retained MQTT messages
|
|
71
|
-
and available across scripts via she.get('var::name') / she.set('var::name', v).
|
|
72
|
-
|
|
73
|
-
### sheDB
|
|
74
|
-
she.db.get(id) Get document (undefined if not found)
|
|
75
|
-
she.db.set(id, doc) Create or overwrite document
|
|
76
|
-
she.db.extend(id, partial) Deep-merge partial into existing document
|
|
77
|
-
she.db.delete(id) Delete document
|
|
78
|
-
she.db.sub(pattern, cb) Subscribe to document changes (MQTT wildcard)
|
|
79
|
-
she.db.query(filter, mapFn, [reduceFn]) Synchronous ad-hoc query → Array
|
|
80
|
-
|
|
81
|
-
### Matter
|
|
82
|
-
she.matter.sub(nodeId, endpointId, cluster, attr, cb) Subscribe to attribute
|
|
83
|
-
she.matter.unsub(listenerId)
|
|
84
|
-
she.matter.get(nodeId, endpointId, cluster, attr) → Promise<value>
|
|
85
|
-
she.matter.send(nodeId, endpointId, cluster, cmd, [args]) → Promise<result>
|
|
86
|
-
|
|
87
|
-
### Helpers
|
|
88
|
-
she.timer(src, target, ms) Pulse target=1 for ms after src goes truthy
|
|
89
|
-
she.combineBool(srcs[], target) Publish OR of source values to target
|
|
90
|
-
she.combineMax(srcs[], target) Publish maximum of source values to target
|
|
91
|
-
she.link(src, target, [fn]) Alias for she.mqtt.link
|
|
92
|
-
she.age(topic) Alias for she.mqtt.age
|
|
93
|
-
she.now() Current timestamp in ms
|
|
94
|
-
she.debug / .info / .warn / .error Structured logging (prefixed with script name)
|
|
95
|
-
she.global Shared mutable object across all scripts`;
|
|
96
|
-
|
|
97
35
|
// ---------------------------------------------------------------------------
|
|
98
36
|
// Helpers
|
|
99
37
|
// ---------------------------------------------------------------------------
|
|
@@ -114,142 +52,6 @@ function readAiConfig(configPath) {
|
|
|
114
52
|
}
|
|
115
53
|
}
|
|
116
54
|
|
|
117
|
-
/**
|
|
118
|
-
* Build the full system prompt, including optional context sections.
|
|
119
|
-
*
|
|
120
|
-
* @param {object} requestCtx { apiref, mqtt, shedb, matter, sampleDocs }
|
|
121
|
-
* @param {{ path?: string, content?: string }|null} currentScript
|
|
122
|
-
* @param {{ id?: string, filter?: string, map?: string, reduce?: string }|null} currentView
|
|
123
|
-
* @param {import('../lib/state-store')|null} store
|
|
124
|
-
* @returns {string}
|
|
125
|
-
*/
|
|
126
|
-
function buildSystemPrompt(requestCtx, currentScript, currentView, store) {
|
|
127
|
-
const isViewMode = !!(currentView?.id);
|
|
128
|
-
|
|
129
|
-
const basePrompt = isViewMode
|
|
130
|
-
? `You are SHE Assistant, helping write sheDB MapReduce view definitions for she (smart-home-engine).
|
|
131
|
-
|
|
132
|
-
A view has three optional parts:
|
|
133
|
-
1. **Filter** — an MQTT-style topic wildcard (e.g. \`devices/#\`) that selects which document IDs enter the view. Plain string, no code.
|
|
134
|
-
2. **Map** — a JavaScript function body. \`this\` is the current document. Call \`emit(value)\` to include a value in the result array. No \`return\`.
|
|
135
|
-
3. **Reduce** — a JavaScript function body that receives \`result\` (the array from map) and must \`return\` a transformed value.
|
|
136
|
-
|
|
137
|
-
When proposing view parts, use these exact formats (include only the parts that change):
|
|
138
|
-
|
|
139
|
-
\`\`\`filter
|
|
140
|
-
devices/#
|
|
141
|
-
\`\`\`
|
|
142
|
-
|
|
143
|
-
\`\`\`javascript
|
|
144
|
-
// @view-map
|
|
145
|
-
if (this.temperature !== undefined) emit(this.temperature);
|
|
146
|
-
\`\`\`
|
|
147
|
-
|
|
148
|
-
\`\`\`javascript
|
|
149
|
-
// @view-reduce
|
|
150
|
-
return result.reduce((a, b) => a + b, 0) / result.length;
|
|
151
|
-
\`\`\`
|
|
152
|
-
|
|
153
|
-
Keep the \`// @view-map\` / \`// @view-reduce\` comment as the very first line of each block — the UI uses it to detect which field to fill in.`
|
|
154
|
-
: `You are SHE Assistant, an expert AI pair programmer for she (smart-home-engine).
|
|
155
|
-
she is a Node.js daemon that runs user JavaScript scripts in a sandboxed VM for home automation.
|
|
156
|
-
When proposing changes to a script, always output the COMPLETE new file content in a single fenced \`\`\`javascript code block. Never output partial diffs or fragments — the user applies the full file at once.
|
|
157
|
-
Keep any existing header comments and the 'use strict'; directive.
|
|
158
|
-
When the user asks you to CREATE a new script (not modify the current one), place a special hint as the very first line INSIDE the code block (right after the opening \`\`\`javascript fence line), like this:
|
|
159
|
-
\`\`\`javascript
|
|
160
|
-
// @new-file: descriptive-name.js
|
|
161
|
-
/* global she */
|
|
162
|
-
'use strict';
|
|
163
|
-
// ... rest of script
|
|
164
|
-
\`\`\`
|
|
165
|
-
Use a short kebab-case filename. Do NOT put the hint outside or before the code block. The UI will detect it and offer to save the file.`;
|
|
166
|
-
|
|
167
|
-
const parts = [basePrompt];
|
|
168
|
-
|
|
169
|
-
if (requestCtx.apiref) {
|
|
170
|
-
parts.push(SHE_API_REF);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (currentScript?.path && typeof currentScript.content === 'string') {
|
|
174
|
-
parts.push(`## Current script: ${currentScript.path}\n\`\`\`javascript\n${currentScript.content}\n\`\`\``);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (currentView?.id) {
|
|
178
|
-
const filterStr = (currentView.filter || '').trim();
|
|
179
|
-
const mapBody = (currentView.map || '').trim();
|
|
180
|
-
const reduceBody = (currentView.reduce || '').trim();
|
|
181
|
-
const viewLines = [`## Current view: ${currentView.id}`];
|
|
182
|
-
viewLines.push(`Filter: ${filterStr || '(none)'}`);
|
|
183
|
-
viewLines.push(`Map:\n\`\`\`javascript\n${mapBody || '// (empty)'}\n\`\`\``);
|
|
184
|
-
if (reduceBody) {
|
|
185
|
-
viewLines.push(`Reduce:\n\`\`\`javascript\n${reduceBody}\n\`\`\``);
|
|
186
|
-
} else {
|
|
187
|
-
viewLines.push('Reduce: (none)');
|
|
188
|
-
}
|
|
189
|
-
parts.push(viewLines.join('\n'));
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (requestCtx.mqtt && store) {
|
|
193
|
-
const topics = [];
|
|
194
|
-
for (const [topic, obj] of store.mqttEntries()) {
|
|
195
|
-
topics.push(`${topic}: ${JSON.stringify(obj.val)}`);
|
|
196
|
-
if (topics.length >= 100) {
|
|
197
|
-
topics.push('… (truncated)');
|
|
198
|
-
break;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
if (topics.length > 0) {
|
|
202
|
-
parts.push(`## Current MQTT state\n${topics.join('\n')}`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (requestCtx.shedb) {
|
|
207
|
-
try {
|
|
208
|
-
const core = require('./shedb').getCore();
|
|
209
|
-
if (core) {
|
|
210
|
-
const ids = Object.keys(core.docs).sort();
|
|
211
|
-
if (ids.length > 0) {
|
|
212
|
-
parts.push(`## sheDB document IDs (${ids.length} total)\n${ids.slice(0, 200).join('\n')}`);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
} catch {
|
|
216
|
-
// shedb not initialised — skip silently
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (requestCtx.sampleDocs) {
|
|
221
|
-
try {
|
|
222
|
-
const core = require('./shedb').getCore();
|
|
223
|
-
if (core) {
|
|
224
|
-
const ids = Object.keys(core.docs).sort().slice(0, 10);
|
|
225
|
-
if (ids.length > 0) {
|
|
226
|
-
const sample = ids.map((id) => `### ${id}\n${JSON.stringify(core.docs[id], null, 2)}`).join('\n\n');
|
|
227
|
-
parts.push(`## Sample sheDB documents (${ids.length} shown)\n${sample}`);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
} catch {
|
|
231
|
-
// shedb not initialised — skip silently
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (requestCtx.matter) {
|
|
236
|
-
try {
|
|
237
|
-
const controller = require('../matter/controller');
|
|
238
|
-
if (typeof controller.listPaired === 'function') {
|
|
239
|
-
const nodes = controller.listPaired();
|
|
240
|
-
if (nodes.length > 0) {
|
|
241
|
-
const list = nodes.map((n) => ` nodeId ${n.nodeId}: ${n.label || 'unnamed'}`).join('\n');
|
|
242
|
-
parts.push(`## Paired Matter devices\n${list}`);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
} catch {
|
|
246
|
-
// matter not initialised — skip silently
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
return parts.join('\n\n');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
55
|
// ---------------------------------------------------------------------------
|
|
254
56
|
// Provider adapters — non-streaming
|
|
255
57
|
// ---------------------------------------------------------------------------
|
|
@@ -257,17 +59,22 @@ Use a short kebab-case filename. Do NOT put the hint outside or before the code
|
|
|
257
59
|
/**
|
|
258
60
|
* @param {{ baseUrl?: string, model: string, apiKey?: string }} config
|
|
259
61
|
* @param {Array<{role:string,content:string}>} messages
|
|
62
|
+
* @param {Array|undefined} [tools] — OpenAI tool definitions; omit to disable tool calling
|
|
63
|
+
* @returns {{ message?: string, usage?: object, toolCalls?: Array, assistantMsg?: object }}
|
|
260
64
|
*/
|
|
261
|
-
async function callOpenAICompat(config, messages) {
|
|
65
|
+
async function callOpenAICompat(config, messages, tools) {
|
|
262
66
|
const base = (config.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
|
|
263
67
|
const url = `${base}/v1/chat/completions`;
|
|
264
68
|
const headers = { 'Content-Type': 'application/json' };
|
|
265
69
|
if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
266
70
|
|
|
71
|
+
const body = { model: config.model, messages, stream: false };
|
|
72
|
+
if (tools?.length) body.tools = tools;
|
|
73
|
+
|
|
267
74
|
const res = await fetch(url, {
|
|
268
75
|
method: 'POST',
|
|
269
76
|
headers,
|
|
270
|
-
body: JSON.stringify(
|
|
77
|
+
body: JSON.stringify(body),
|
|
271
78
|
});
|
|
272
79
|
|
|
273
80
|
if (!res.ok) {
|
|
@@ -277,21 +84,29 @@ async function callOpenAICompat(config, messages) {
|
|
|
277
84
|
|
|
278
85
|
const json = await res.json();
|
|
279
86
|
const choice = json.choices?.[0];
|
|
280
|
-
const message = choice?.message?.content ?? choice?.text ?? '';
|
|
281
87
|
const usage = json.usage
|
|
282
88
|
? {
|
|
283
89
|
prompt_tokens: json.usage.prompt_tokens,
|
|
284
90
|
completion_tokens: json.usage.completion_tokens,
|
|
285
91
|
}
|
|
286
92
|
: undefined;
|
|
93
|
+
|
|
94
|
+
// Detect tool call response
|
|
95
|
+
if (choice?.finish_reason === 'tool_calls' && choice.message?.tool_calls?.length) {
|
|
96
|
+
return { toolCalls: choice.message.tool_calls, assistantMsg: choice.message, usage };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const message = choice?.message?.content ?? choice?.text ?? '';
|
|
287
100
|
return { message, usage };
|
|
288
101
|
}
|
|
289
102
|
|
|
290
103
|
/**
|
|
291
104
|
* @param {{ model: string, apiKey?: string }} config
|
|
292
105
|
* @param {Array<{role:string,content:string}>} messages — first may be role:'system'
|
|
106
|
+
* @param {Array|undefined} [tools] — Anthropic tool definitions; omit to disable tool calling
|
|
107
|
+
* @returns {{ message?: string, usage?: object, toolCalls?: Array, assistantMsg?: Array }}
|
|
293
108
|
*/
|
|
294
|
-
async function callAnthropic(config, messages) {
|
|
109
|
+
async function callAnthropic(config, messages, tools) {
|
|
295
110
|
const systemMsg = messages.find((m) => m.role === 'system');
|
|
296
111
|
const userMessages = messages.filter((m) => m.role !== 'system');
|
|
297
112
|
|
|
@@ -301,15 +116,18 @@ async function callAnthropic(config, messages) {
|
|
|
301
116
|
'anthropic-version': '2023-06-01',
|
|
302
117
|
};
|
|
303
118
|
|
|
119
|
+
const body = {
|
|
120
|
+
model: config.model,
|
|
121
|
+
system: systemMsg?.content || '',
|
|
122
|
+
messages: userMessages,
|
|
123
|
+
max_tokens: 4096,
|
|
124
|
+
};
|
|
125
|
+
if (tools?.length) body.tools = tools;
|
|
126
|
+
|
|
304
127
|
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
305
128
|
method: 'POST',
|
|
306
129
|
headers,
|
|
307
|
-
body: JSON.stringify(
|
|
308
|
-
model: config.model,
|
|
309
|
-
system: systemMsg?.content || '',
|
|
310
|
-
messages: userMessages,
|
|
311
|
-
max_tokens: 4096,
|
|
312
|
-
}),
|
|
130
|
+
body: JSON.stringify(body),
|
|
313
131
|
});
|
|
314
132
|
|
|
315
133
|
if (!res.ok) {
|
|
@@ -318,16 +136,103 @@ async function callAnthropic(config, messages) {
|
|
|
318
136
|
}
|
|
319
137
|
|
|
320
138
|
const json = await res.json();
|
|
321
|
-
const message = json.content?.[0]?.text ?? '';
|
|
322
139
|
const usage = json.usage
|
|
323
140
|
? {
|
|
324
141
|
prompt_tokens: json.usage.input_tokens,
|
|
325
142
|
completion_tokens: json.usage.output_tokens,
|
|
326
143
|
}
|
|
327
144
|
: undefined;
|
|
145
|
+
|
|
146
|
+
// Detect tool use response
|
|
147
|
+
if (json.stop_reason === 'tool_use') {
|
|
148
|
+
const toolCalls = (json.content || []).filter((b) => b.type === 'tool_use');
|
|
149
|
+
return { toolCalls, assistantMsg: json.content, usage };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const message = json.content?.[0]?.text ?? '';
|
|
328
153
|
return { message, usage };
|
|
329
154
|
}
|
|
330
155
|
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Tool-calling resolver
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run the tool-calling loop: call the LLM, execute any tool calls, repeat
|
|
162
|
+
* until the model produces a plain text answer (no more tool calls).
|
|
163
|
+
*
|
|
164
|
+
* Emits { type:'tool_call', name, args } and { type:'tool_result', name, content }
|
|
165
|
+
* events via onEvent (used for SSE feedback to the client).
|
|
166
|
+
*
|
|
167
|
+
* @param {{ provider: string, baseUrl?: string, model: string, apiKey?: string }} ai
|
|
168
|
+
* @param {Array} messages — initial message list (system prompt already included)
|
|
169
|
+
* @param {{ store: any, scriptDir: string|null }} toolContext
|
|
170
|
+
* @param {((event: object) => void)|undefined} onEvent
|
|
171
|
+
* @returns {Promise<{ message: string, usage?: object }>}
|
|
172
|
+
*/
|
|
173
|
+
async function resolveAndGetAnswer(ai, messages, toolContext, onEvent) {
|
|
174
|
+
const isAnthropic = ai.provider === 'anthropic';
|
|
175
|
+
const tools = isAnthropic ? TOOL_DEFINITIONS_ANTHROPIC : TOOL_DEFINITIONS;
|
|
176
|
+
let msgs = messages;
|
|
177
|
+
|
|
178
|
+
for (let round = 0; round < 6; round++) {
|
|
179
|
+
// On the final safety round, don't send tools to avoid infinite loops
|
|
180
|
+
const roundTools = round < 5 ? tools : undefined;
|
|
181
|
+
|
|
182
|
+
let result;
|
|
183
|
+
try {
|
|
184
|
+
result = isAnthropic
|
|
185
|
+
? await callAnthropic(ai, msgs, roundTools)
|
|
186
|
+
: await callOpenAICompat(ai, msgs, roundTools);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
if (round === 0 && roundTools) {
|
|
189
|
+
// Model may not support tool calling — retry without tools
|
|
190
|
+
result = isAnthropic
|
|
191
|
+
? await callAnthropic(ai, msgs)
|
|
192
|
+
: await callOpenAICompat(ai, msgs);
|
|
193
|
+
} else {
|
|
194
|
+
throw e;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// No tool calls → we have the final answer
|
|
199
|
+
if (!result.toolCalls?.length) {
|
|
200
|
+
return { message: result.message ?? '', usage: result.usage };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Execute tool calls and append results to message history
|
|
204
|
+
if (isAnthropic) {
|
|
205
|
+
msgs = [...msgs, { role: 'assistant', content: result.assistantMsg }];
|
|
206
|
+
const toolResultBlocks = [];
|
|
207
|
+
for (const tc of result.toolCalls) {
|
|
208
|
+
const args = tc.input || {};
|
|
209
|
+
onEvent?.({ type: 'tool_call', name: tc.name, args });
|
|
210
|
+
const content = await executeTool(tc.name, args, toolContext);
|
|
211
|
+
onEvent?.({ type: 'tool_result', name: tc.name, content });
|
|
212
|
+
toolResultBlocks.push({ type: 'tool_result', tool_use_id: tc.id, content });
|
|
213
|
+
}
|
|
214
|
+
msgs = [...msgs, { role: 'user', content: toolResultBlocks }];
|
|
215
|
+
} else {
|
|
216
|
+
msgs = [...msgs, { ...result.assistantMsg, role: 'assistant' }];
|
|
217
|
+
for (const tc of result.toolCalls) {
|
|
218
|
+
const name = tc.function.name;
|
|
219
|
+
let args;
|
|
220
|
+
try { args = JSON.parse(tc.function.arguments || '{}'); } catch { args = {}; }
|
|
221
|
+
onEvent?.({ type: 'tool_call', name, args });
|
|
222
|
+
const content = await executeTool(name, args, toolContext);
|
|
223
|
+
onEvent?.({ type: 'tool_result', name, content });
|
|
224
|
+
msgs = [...msgs, { role: 'tool', tool_call_id: tc.id, content }];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fallback (should not normally be reached)
|
|
230
|
+
const fallback = isAnthropic
|
|
231
|
+
? await callAnthropic(ai, msgs)
|
|
232
|
+
: await callOpenAICompat(ai, msgs);
|
|
233
|
+
return { message: fallback.message ?? '', usage: fallback.usage };
|
|
234
|
+
}
|
|
235
|
+
|
|
331
236
|
// ---------------------------------------------------------------------------
|
|
332
237
|
// Provider adapters — streaming
|
|
333
238
|
// ---------------------------------------------------------------------------
|
|
@@ -506,16 +411,19 @@ router.post('/chat', async (req, res) => {
|
|
|
506
411
|
return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
|
|
507
412
|
}
|
|
508
413
|
|
|
509
|
-
const { messages = [], currentScript, currentView, context = {}, modelOverride } = req.body || {};
|
|
414
|
+
const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride } = req.body || {};
|
|
510
415
|
if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
|
|
511
416
|
|
|
512
417
|
const aiWithModel = (modelOverride && typeof modelOverride === 'string') ? { ...ai, model: modelOverride } : ai;
|
|
513
|
-
const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, _store);
|
|
418
|
+
const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store);
|
|
514
419
|
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
515
420
|
|
|
516
421
|
try {
|
|
517
422
|
let result;
|
|
518
|
-
if (
|
|
423
|
+
if (context.tools) {
|
|
424
|
+
const toolContext = { store: _store, scriptDir: req.app.locals.scriptDir || null };
|
|
425
|
+
result = await resolveAndGetAnswer(aiWithModel, fullMessages, toolContext, undefined);
|
|
426
|
+
} else if (ai.provider === 'anthropic') {
|
|
519
427
|
result = await callAnthropic(aiWithModel, fullMessages);
|
|
520
428
|
} else {
|
|
521
429
|
result = await callOpenAICompat(aiWithModel, fullMessages);
|
|
@@ -533,7 +441,7 @@ router.post('/chat/stream', async (req, res) => {
|
|
|
533
441
|
return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
|
|
534
442
|
}
|
|
535
443
|
|
|
536
|
-
const { messages = [], currentScript, currentView, context = {}, modelOverride } = req.body || {};
|
|
444
|
+
const { messages = [], currentScript, currentView, currentDoc, context = {}, modelOverride } = req.body || {};
|
|
537
445
|
if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
|
|
538
446
|
|
|
539
447
|
const aiWithModel = (modelOverride && typeof modelOverride === 'string') ? { ...ai, model: modelOverride } : ai;
|
|
@@ -547,16 +455,23 @@ router.post('/chat/stream', async (req, res) => {
|
|
|
547
455
|
|
|
548
456
|
const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
549
457
|
|
|
550
|
-
const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, _store);
|
|
458
|
+
const systemPrompt = buildSystemPrompt(context, currentScript ?? null, currentView ?? null, currentDoc ?? null, _store);
|
|
551
459
|
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
552
460
|
|
|
553
461
|
try {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
462
|
+
if (context.tools) {
|
|
463
|
+
// Tool-calling mode: resolve tools non-streaming (emitting events), then
|
|
464
|
+
// send the final answer as a single token so the client sees it immediately.
|
|
465
|
+
const toolContext = { store: _store, scriptDir: req.app.locals.scriptDir || null };
|
|
466
|
+
const { message } = await resolveAndGetAnswer(aiWithModel, fullMessages, toolContext, send);
|
|
467
|
+
send({ token: message });
|
|
558
468
|
} else {
|
|
559
|
-
|
|
469
|
+
const onToken = (t) => send({ token: t });
|
|
470
|
+
if (ai.provider === 'anthropic') {
|
|
471
|
+
await streamAnthropic(aiWithModel, fullMessages, onToken);
|
|
472
|
+
} else {
|
|
473
|
+
await streamOpenAICompat(aiWithModel, fullMessages, onToken);
|
|
474
|
+
}
|
|
560
475
|
}
|
|
561
476
|
|
|
562
477
|
res.write('data: [DONE]\n\n');
|