smart-home-engine 0.0.1 → 0.11.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/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/web/assets/codicon-DCmgc-ay.ttf +0 -0
- package/dist/web/assets/index-BbwiXmS-.css +1 -0
- package/dist/web/assets/index-DD-XScWV.js +140 -0
- package/dist/web/assets/monaco-langs-DZ6hB11b.js +1423 -0
- package/dist/web/assets/monaco-langs-DyX1CsEw.css +1 -0
- package/dist/web/assets/tsMode-DxTbjAE2.js +16 -0
- package/dist/web/index.html +164 -0
- package/dist/web/monacoeditorwork/editor.worker.bundle.js +13519 -0
- package/dist/web/monacoeditorwork/ts.worker.bundle.js +256353 -0
- package/package.json +85 -10
- package/src/config.js +56 -0
- package/src/elastic.js +19 -0
- package/src/index.js +1192 -0
- package/src/influx.js +25 -0
- package/src/lib/mqtt-wildcards.js +34 -0
- package/src/lib/parse-payload.js +29 -0
- package/src/lib/redis.js +74 -0
- package/src/lib/shedb-core.js +447 -0
- package/src/lib/shedb-worker.js +126 -0
- package/src/lib/state-store.js +97 -0
- package/src/lib/storage.js +74 -0
- package/src/matter/controller.js +307 -0
- package/src/sandbox/api.js +57 -0
- package/src/sandbox/elastic-sandbox.js +88 -0
- package/src/sandbox/influx-sandbox.js +107 -0
- package/src/sandbox/matter-sandbox.js +92 -0
- package/src/sandbox/shedb-sandbox.js +89 -0
- package/src/sandbox/stdlib.js +132 -0
- package/src/scripts/hello.js +3 -0
- package/src/web/ai-api.js +443 -0
- package/src/web/auth.js +186 -0
- package/src/web/config-api.js +34 -0
- package/src/web/deps-api.js +138 -0
- package/src/web/git-api.js +188 -0
- package/src/web/log-ws.js +78 -0
- package/src/web/matter-api.js +102 -0
- package/src/web/mqtt-api.js +65 -0
- package/src/web/scripts-api.js +192 -0
- package/src/web/server.js +139 -0
- package/src/web/shedb-api.js +140 -0
- package/src/web/shedb.js +168 -0
- package/index.js +0 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AI Assistant REST API — Express router mounted at /she/ai
|
|
5
|
+
*
|
|
6
|
+
* Proxies chat requests to a configured LLM provider (Ollama, LM Studio,
|
|
7
|
+
* OpenAI, or Anthropic), assembling context (MQTT state, sheDB doc IDs,
|
|
8
|
+
* Matter devices, she API reference) server-side based on per-request flags.
|
|
9
|
+
*
|
|
10
|
+
* Routes:
|
|
11
|
+
* GET /she/ai/config → { configured, provider, model, baseUrl }
|
|
12
|
+
* POST /she/ai/chat → { message, usage? } (non-streaming)
|
|
13
|
+
* POST /she/ai/chat/stream → SSE data: {"token":"..."} (streaming)
|
|
14
|
+
* data: [DONE]
|
|
15
|
+
*
|
|
16
|
+
* Call init(store) once after the state store is created.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const express = require('express');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
|
|
22
|
+
const router = express.Router();
|
|
23
|
+
let _store = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {import('../lib/state-store')} store
|
|
27
|
+
*/
|
|
28
|
+
function init(store) {
|
|
29
|
+
_store = store;
|
|
30
|
+
}
|
|
31
|
+
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Helpers
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read the ai config section from config.json.
|
|
103
|
+
* Returns null if unavailable.
|
|
104
|
+
* @param {string|undefined} configPath
|
|
105
|
+
* @returns {{ provider?: string, baseUrl?: string, model?: string, apiKey?: string }|null}
|
|
106
|
+
*/
|
|
107
|
+
function readAiConfig(configPath) {
|
|
108
|
+
if (!configPath) return null;
|
|
109
|
+
try {
|
|
110
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
111
|
+
return cfg.ai || null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the full system prompt, including optional context sections.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} requestCtx { apiref, mqtt, shedb, matter }
|
|
121
|
+
* @param {{ path?: string, content?: string }|null} currentScript
|
|
122
|
+
* @param {import('../lib/state-store')|null} store
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
function buildSystemPrompt(requestCtx, currentScript, store) {
|
|
126
|
+
const parts = [
|
|
127
|
+
`You are SHE Assistant, an expert AI pair programmer for she (smart-home-engine).
|
|
128
|
+
she is a Node.js daemon that runs user JavaScript scripts in a sandboxed VM for home automation.
|
|
129
|
+
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.
|
|
130
|
+
Keep any existing header comments and the 'use strict'; directive.`,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
if (requestCtx.apiref) {
|
|
134
|
+
parts.push(SHE_API_REF);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (currentScript?.path && typeof currentScript.content === 'string') {
|
|
138
|
+
parts.push(`## Current script: ${currentScript.path}\n\`\`\`javascript\n${currentScript.content}\n\`\`\``);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (requestCtx.mqtt && store) {
|
|
142
|
+
const topics = [];
|
|
143
|
+
for (const [topic, obj] of store.mqttEntries()) {
|
|
144
|
+
topics.push(`${topic}: ${JSON.stringify(obj.val)}`);
|
|
145
|
+
if (topics.length >= 100) {
|
|
146
|
+
topics.push('… (truncated)');
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (topics.length > 0) {
|
|
151
|
+
parts.push(`## Current MQTT state\n${topics.join('\n')}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (requestCtx.shedb) {
|
|
156
|
+
try {
|
|
157
|
+
const core = require('./shedb').getCore();
|
|
158
|
+
if (core) {
|
|
159
|
+
const ids = typeof core.listIds === 'function' ? core.listIds() : [];
|
|
160
|
+
if (ids.length > 0) {
|
|
161
|
+
parts.push(`## sheDB document IDs (${ids.length} total)\n${ids.slice(0, 200).join('\n')}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// shedb not initialised — skip silently
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (requestCtx.matter) {
|
|
170
|
+
try {
|
|
171
|
+
const controller = require('../matter/controller');
|
|
172
|
+
if (typeof controller.listPaired === 'function') {
|
|
173
|
+
const nodes = controller.listPaired();
|
|
174
|
+
if (nodes.length > 0) {
|
|
175
|
+
const list = nodes.map((n) => ` nodeId ${n.nodeId}: ${n.label || 'unnamed'}`).join('\n');
|
|
176
|
+
parts.push(`## Paired Matter devices\n${list}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// matter not initialised — skip silently
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return parts.join('\n\n');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Provider adapters — non-streaming
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {{ baseUrl?: string, model: string, apiKey?: string }} config
|
|
193
|
+
* @param {Array<{role:string,content:string}>} messages
|
|
194
|
+
*/
|
|
195
|
+
async function callOpenAICompat(config, messages) {
|
|
196
|
+
const base = (config.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
|
|
197
|
+
const url = `${base}/v1/chat/completions`;
|
|
198
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
199
|
+
if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
200
|
+
|
|
201
|
+
const res = await fetch(url, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers,
|
|
204
|
+
body: JSON.stringify({ model: config.model, messages, stream: false }),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
const text = await res.text().catch(() => '');
|
|
209
|
+
throw new Error(`LLM API error ${res.status}: ${text.slice(0, 300)}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const json = await res.json();
|
|
213
|
+
const choice = json.choices?.[0];
|
|
214
|
+
const message = choice?.message?.content ?? choice?.text ?? '';
|
|
215
|
+
const usage = json.usage
|
|
216
|
+
? {
|
|
217
|
+
prompt_tokens: json.usage.prompt_tokens,
|
|
218
|
+
completion_tokens: json.usage.completion_tokens,
|
|
219
|
+
}
|
|
220
|
+
: undefined;
|
|
221
|
+
return { message, usage };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {{ model: string, apiKey?: string }} config
|
|
226
|
+
* @param {Array<{role:string,content:string}>} messages — first may be role:'system'
|
|
227
|
+
*/
|
|
228
|
+
async function callAnthropic(config, messages) {
|
|
229
|
+
const systemMsg = messages.find((m) => m.role === 'system');
|
|
230
|
+
const userMessages = messages.filter((m) => m.role !== 'system');
|
|
231
|
+
|
|
232
|
+
const headers = {
|
|
233
|
+
'Content-Type': 'application/json',
|
|
234
|
+
'x-api-key': config.apiKey || '',
|
|
235
|
+
'anthropic-version': '2023-06-01',
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers,
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
model: config.model,
|
|
243
|
+
system: systemMsg?.content || '',
|
|
244
|
+
messages: userMessages,
|
|
245
|
+
max_tokens: 4096,
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (!res.ok) {
|
|
250
|
+
const text = await res.text().catch(() => '');
|
|
251
|
+
throw new Error(`Anthropic API error ${res.status}: ${text.slice(0, 300)}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const json = await res.json();
|
|
255
|
+
const message = json.content?.[0]?.text ?? '';
|
|
256
|
+
const usage = json.usage
|
|
257
|
+
? {
|
|
258
|
+
prompt_tokens: json.usage.input_tokens,
|
|
259
|
+
completion_tokens: json.usage.output_tokens,
|
|
260
|
+
}
|
|
261
|
+
: undefined;
|
|
262
|
+
return { message, usage };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Provider adapters — streaming
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Parse an SSE ReadableStream, calling onToken for each non-null extracted value.
|
|
271
|
+
* @param {ReadableStream} body
|
|
272
|
+
* @param {(json:object)=>string|null|undefined} tokenExtractor
|
|
273
|
+
* @param {(token:string)=>void} onToken
|
|
274
|
+
*/
|
|
275
|
+
async function parseSseStream(body, tokenExtractor, onToken) {
|
|
276
|
+
const reader = body.getReader();
|
|
277
|
+
const decoder = new TextDecoder();
|
|
278
|
+
let buffer = '';
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
while (true) {
|
|
282
|
+
const { done, value } = await reader.read();
|
|
283
|
+
if (done) break;
|
|
284
|
+
buffer += decoder.decode(value, { stream: true });
|
|
285
|
+
const lines = buffer.split('\n');
|
|
286
|
+
buffer = lines.pop() ?? '';
|
|
287
|
+
|
|
288
|
+
for (const line of lines) {
|
|
289
|
+
if (!line.startsWith('data: ')) continue;
|
|
290
|
+
const data = line.slice(6).trim();
|
|
291
|
+
if (data === '[DONE]') return;
|
|
292
|
+
try {
|
|
293
|
+
const json = JSON.parse(data);
|
|
294
|
+
const token = tokenExtractor(json);
|
|
295
|
+
if (token) onToken(token);
|
|
296
|
+
} catch {
|
|
297
|
+
// skip malformed JSON lines
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} finally {
|
|
302
|
+
reader.releaseLock();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Stream tokens from an OpenAI-compatible endpoint.
|
|
308
|
+
* Calls onToken(str) for each chunk, resolves when stream ends.
|
|
309
|
+
*/
|
|
310
|
+
async function streamOpenAICompat(config, messages, onToken) {
|
|
311
|
+
const base = (config.baseUrl || 'http://localhost:11434').replace(/\/$/, '');
|
|
312
|
+
const url = `${base}/v1/chat/completions`;
|
|
313
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
314
|
+
if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
|
|
315
|
+
|
|
316
|
+
const res = await fetch(url, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers,
|
|
319
|
+
body: JSON.stringify({ model: config.model, messages, stream: true }),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (!res.ok) {
|
|
323
|
+
const text = await res.text().catch(() => '');
|
|
324
|
+
throw new Error(`LLM API error ${res.status}: ${text.slice(0, 300)}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await parseSseStream(res.body, (json) => json.choices?.[0]?.delta?.content, onToken);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Stream tokens from Anthropic Messages API.
|
|
332
|
+
*/
|
|
333
|
+
async function streamAnthropic(config, messages, onToken) {
|
|
334
|
+
const systemMsg = messages.find((m) => m.role === 'system');
|
|
335
|
+
const userMessages = messages.filter((m) => m.role !== 'system');
|
|
336
|
+
|
|
337
|
+
const headers = {
|
|
338
|
+
'Content-Type': 'application/json',
|
|
339
|
+
'x-api-key': config.apiKey || '',
|
|
340
|
+
'anthropic-version': '2023-06-01',
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
headers,
|
|
346
|
+
body: JSON.stringify({
|
|
347
|
+
model: config.model,
|
|
348
|
+
system: systemMsg?.content || '',
|
|
349
|
+
messages: userMessages,
|
|
350
|
+
max_tokens: 4096,
|
|
351
|
+
stream: true,
|
|
352
|
+
}),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
if (!res.ok) {
|
|
356
|
+
const text = await res.text().catch(() => '');
|
|
357
|
+
throw new Error(`Anthropic API error ${res.status}: ${text.slice(0, 300)}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await parseSseStream(res.body, (json) => json.delta?.text, onToken);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Routes
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
// GET /she/ai/config
|
|
368
|
+
router.get('/config', (req, res) => {
|
|
369
|
+
const ai = readAiConfig(req.app.locals.configPath);
|
|
370
|
+
res.json({
|
|
371
|
+
configured: !!(ai?.provider && ai?.model),
|
|
372
|
+
provider: ai?.provider || '',
|
|
373
|
+
model: ai?.model || '',
|
|
374
|
+
baseUrl: ai?.baseUrl || '',
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// POST /she/ai/chat — non-streaming
|
|
379
|
+
router.post('/chat', async (req, res) => {
|
|
380
|
+
const ai = readAiConfig(req.app.locals.configPath);
|
|
381
|
+
if (!ai?.provider || !ai?.model) {
|
|
382
|
+
return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const { messages = [], currentScript, context = {} } = req.body || {};
|
|
386
|
+
if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
|
|
387
|
+
|
|
388
|
+
const systemPrompt = buildSystemPrompt(context, currentScript ?? null, _store);
|
|
389
|
+
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
let result;
|
|
393
|
+
if (ai.provider === 'anthropic') {
|
|
394
|
+
result = await callAnthropic(ai, fullMessages);
|
|
395
|
+
} else {
|
|
396
|
+
result = await callOpenAICompat(ai, fullMessages);
|
|
397
|
+
}
|
|
398
|
+
res.json(result);
|
|
399
|
+
} catch (e) {
|
|
400
|
+
res.status(500).json({ error: e.message });
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// POST /she/ai/chat/stream — SSE streaming
|
|
405
|
+
router.post('/chat/stream', async (req, res) => {
|
|
406
|
+
const ai = readAiConfig(req.app.locals.configPath);
|
|
407
|
+
if (!ai?.provider || !ai?.model) {
|
|
408
|
+
return res.status(400).json({ error: 'AI provider not configured. Set ai.provider and ai.model in Config.' });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const { messages = [], currentScript, context = {} } = req.body || {};
|
|
412
|
+
if (!Array.isArray(messages)) return res.status(400).json({ error: 'messages must be an array' });
|
|
413
|
+
|
|
414
|
+
res.set({
|
|
415
|
+
'Content-Type': 'text/event-stream',
|
|
416
|
+
'Cache-Control': 'no-cache',
|
|
417
|
+
'Connection': 'keep-alive',
|
|
418
|
+
});
|
|
419
|
+
res.flushHeaders();
|
|
420
|
+
|
|
421
|
+
const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
422
|
+
|
|
423
|
+
const systemPrompt = buildSystemPrompt(context, currentScript ?? null, _store);
|
|
424
|
+
const fullMessages = [{ role: 'system', content: systemPrompt }, ...messages];
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const onToken = (t) => send({ token: t });
|
|
428
|
+
|
|
429
|
+
if (ai.provider === 'anthropic') {
|
|
430
|
+
await streamAnthropic(ai, fullMessages, onToken);
|
|
431
|
+
} else {
|
|
432
|
+
await streamOpenAICompat(ai, fullMessages, onToken);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
res.write('data: [DONE]\n\n');
|
|
436
|
+
res.end();
|
|
437
|
+
} catch (e) {
|
|
438
|
+
send({ error: e.message });
|
|
439
|
+
res.end();
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
module.exports = { router, init };
|
package/src/web/auth.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Authentication module for the she web server.
|
|
5
|
+
*
|
|
6
|
+
* Supports three modes (configured via config.json `auth` field):
|
|
7
|
+
* 'none' — no auth, all /she/* routes are open (default)
|
|
8
|
+
* 'password' — single-password session auth with an HttpOnly cookie
|
|
9
|
+
* 'proxy' — trust a header set by nginx/authentik (e.g. X-Remote-User)
|
|
10
|
+
*
|
|
11
|
+
* Public endpoints (no auth required in any mode):
|
|
12
|
+
* GET /she/auth/mode — returns current mode
|
|
13
|
+
* POST /she/auth/login — password mode only; sets session cookie
|
|
14
|
+
* POST /she/auth/logout — clears session cookie
|
|
15
|
+
*
|
|
16
|
+
* Protected endpoint (auth required):
|
|
17
|
+
* POST /she/auth/setup — change auth mode / password / proxyHeader
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const bcrypt = require('bcryptjs');
|
|
22
|
+
const express = require('express');
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const BCRYPT_ROUNDS = 10;
|
|
27
|
+
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
28
|
+
|
|
29
|
+
// In-memory session store — intentionally cleared on restart
|
|
30
|
+
const _sessions = new Map(); // token (hex64) → { createdAt: number }
|
|
31
|
+
|
|
32
|
+
let _mode = 'none';
|
|
33
|
+
let _passwordHash = null; // bcrypt hash, only used in 'password' mode
|
|
34
|
+
let _proxyHeader = 'x-remote-user'; // lowercase for req.headers lookup
|
|
35
|
+
let _configPath = null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Initialise auth state. Called once from startServer().
|
|
39
|
+
*/
|
|
40
|
+
function init({ auth = 'none', password = null, proxyHeader = 'X-Remote-User', configPath = null } = {}) {
|
|
41
|
+
_mode = auth;
|
|
42
|
+
_passwordHash = password || null;
|
|
43
|
+
_proxyHeader = proxyHeader.toLowerCase();
|
|
44
|
+
_configPath = configPath;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getMode() {
|
|
48
|
+
return _mode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Session helpers ─────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function _getSessionToken(req) {
|
|
54
|
+
const cookie = req.headers.cookie || '';
|
|
55
|
+
const m = cookie.match(/(?:^|;\s*)she_session=([a-f0-9]{64})/);
|
|
56
|
+
return m ? m[1] : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _validateSession(token) {
|
|
60
|
+
if (!token) return false;
|
|
61
|
+
const s = _sessions.get(token);
|
|
62
|
+
if (!s) return false;
|
|
63
|
+
if (Date.now() - s.createdAt > SESSION_TTL_MS) {
|
|
64
|
+
_sessions.delete(token);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Auth check (used by middleware and WS gate) ─────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns true if the request is authenticated according to the current mode.
|
|
74
|
+
* @param {import('http').IncomingMessage} req
|
|
75
|
+
*/
|
|
76
|
+
function checkAuth(req) {
|
|
77
|
+
if (_mode === 'none') return true;
|
|
78
|
+
if (_mode === 'proxy') return !!req.headers[_proxyHeader];
|
|
79
|
+
if (_mode === 'password') return _validateSession(_getSessionToken(req));
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Express middleware that enforces auth on /she/* routes.
|
|
85
|
+
* Mount this AFTER the public auth router so login/mode/logout bypass it.
|
|
86
|
+
*/
|
|
87
|
+
function authMiddleware(req, res, next) {
|
|
88
|
+
if (checkAuth(req)) return next();
|
|
89
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Auth API router ─────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const router = express.Router();
|
|
95
|
+
|
|
96
|
+
/** GET /she/auth/mode — always public */
|
|
97
|
+
router.get('/mode', (req, res) => {
|
|
98
|
+
res.json({ mode: _mode });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** POST /she/auth/login — always public; only meaningful in password mode */
|
|
102
|
+
router.post('/login', async (req, res) => {
|
|
103
|
+
if (_mode !== 'password') return res.status(400).json({ error: 'Not in password mode' });
|
|
104
|
+
const { password } = req.body || {};
|
|
105
|
+
if (!password || !_passwordHash) return res.status(401).json({ error: 'Unauthorized' });
|
|
106
|
+
try {
|
|
107
|
+
const ok = await bcrypt.compare(password, _passwordHash);
|
|
108
|
+
if (!ok) return res.status(401).json({ error: 'Invalid password' });
|
|
109
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
110
|
+
_sessions.set(token, { createdAt: Date.now() });
|
|
111
|
+
res.setHeader('Set-Cookie', `she_session=${token}; HttpOnly; SameSite=Strict; Path=/`);
|
|
112
|
+
res.json({ ok: true });
|
|
113
|
+
} catch {
|
|
114
|
+
res.status(500).json({ error: 'Internal error' });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** POST /she/auth/logout — always public; clears cookie */
|
|
119
|
+
router.post('/logout', (req, res) => {
|
|
120
|
+
const token = _getSessionToken(req);
|
|
121
|
+
if (token) _sessions.delete(token);
|
|
122
|
+
res.setHeader('Set-Cookie', 'she_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0');
|
|
123
|
+
res.json({ ok: true });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* POST /she/auth/setup — protected; change auth mode / password / proxyHeader.
|
|
128
|
+
* Body: { mode: 'none'|'password'|'proxy', password?: string, proxyHeader?: string }
|
|
129
|
+
*
|
|
130
|
+
* Self-guards when in password mode (requires valid session).
|
|
131
|
+
*/
|
|
132
|
+
router.post('/setup', async (req, res) => {
|
|
133
|
+
// Self-guard: in password mode the caller must be authenticated
|
|
134
|
+
if (_mode === 'password' && !_validateSession(_getSessionToken(req))) {
|
|
135
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { mode, password, proxyHeader } = req.body || {};
|
|
139
|
+
|
|
140
|
+
if (!['none', 'password', 'proxy'].includes(mode)) {
|
|
141
|
+
return res.status(400).json({ error: 'Invalid auth mode. Must be none, password, or proxy.' });
|
|
142
|
+
}
|
|
143
|
+
if (mode === 'password' && !password) {
|
|
144
|
+
return res.status(400).json({ error: 'A non-empty password is required for password mode.' });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
// Read existing config
|
|
149
|
+
let cfg = {};
|
|
150
|
+
try {
|
|
151
|
+
cfg = JSON.parse(fs.readFileSync(_configPath, 'utf8'));
|
|
152
|
+
} catch {
|
|
153
|
+
// config file does not exist yet — start from empty
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Update auth fields, remove stale ones
|
|
157
|
+
cfg.auth = mode;
|
|
158
|
+
delete cfg.password;
|
|
159
|
+
delete cfg.proxyHeader;
|
|
160
|
+
|
|
161
|
+
if (mode === 'password') {
|
|
162
|
+
cfg.password = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
163
|
+
}
|
|
164
|
+
if (mode === 'proxy') {
|
|
165
|
+
cfg.proxyHeader = proxyHeader || 'X-Remote-User';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Write back
|
|
169
|
+
fs.mkdirSync(path.dirname(_configPath), { recursive: true });
|
|
170
|
+
fs.writeFileSync(_configPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
171
|
+
|
|
172
|
+
// Apply in-memory (no restart needed)
|
|
173
|
+
_mode = mode;
|
|
174
|
+
_passwordHash = cfg.password || null;
|
|
175
|
+
_proxyHeader = (cfg.proxyHeader || 'X-Remote-User').toLowerCase();
|
|
176
|
+
|
|
177
|
+
// Invalidate all existing sessions when switching away from password mode
|
|
178
|
+
if (mode !== 'password') _sessions.clear();
|
|
179
|
+
|
|
180
|
+
res.json({ ok: true });
|
|
181
|
+
} catch (err) {
|
|
182
|
+
res.status(500).json({ error: err.message });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
module.exports = { init, authMiddleware, checkAuth, getMode, router };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { getConfigPath } = require('../lib/storage');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG_PATH = getConfigPath();
|
|
9
|
+
|
|
10
|
+
const router = express.Router();
|
|
11
|
+
|
|
12
|
+
router.get('/', (req, res) => {
|
|
13
|
+
const configPath = req.app.locals.configPath || DEFAULT_CONFIG_PATH;
|
|
14
|
+
let fileConfig = {};
|
|
15
|
+
try {
|
|
16
|
+
fileConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
17
|
+
} catch {
|
|
18
|
+
// config file does not exist yet — return empty object
|
|
19
|
+
}
|
|
20
|
+
res.json(fileConfig);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
router.put('/', (req, res) => {
|
|
24
|
+
const configPath = req.app.locals.configPath || DEFAULT_CONFIG_PATH;
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
27
|
+
fs.writeFileSync(configPath, JSON.stringify(req.body, null, 2), 'utf8');
|
|
28
|
+
res.json({ ok: true, restartRequired: true, configPath });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
res.status(500).json({ error: err.message });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
module.exports = { router, DEFAULT_CONFIG_PATH };
|