@leeoohoo/ui-apps-devkit 0.1.1 → 0.1.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.
Files changed (62) hide show
  1. package/README.md +84 -76
  2. package/bin/chatos-uiapp.js +3 -4
  3. package/package.json +28 -25
  4. package/src/cli.js +53 -53
  5. package/src/commands/dev.js +14 -14
  6. package/src/commands/init.js +143 -142
  7. package/src/commands/install.js +56 -55
  8. package/src/commands/pack.js +72 -72
  9. package/src/commands/validate.js +113 -113
  10. package/src/lib/args.js +49 -49
  11. package/src/lib/config.js +29 -29
  12. package/src/lib/fs.js +78 -78
  13. package/src/lib/path-boundary.js +16 -16
  14. package/src/lib/plugin.js +45 -45
  15. package/src/lib/state-constants.js +2 -0
  16. package/src/lib/template.js +172 -172
  17. package/src/sandbox/server.js +2302 -1200
  18. package/templates/basic/README.md +80 -77
  19. package/templates/basic/chatos.config.json +5 -5
  20. package/templates/basic/docs/CHATOS_UI_APPS_AI_CONTRIBUTIONS.md +178 -178
  21. package/templates/basic/docs/CHATOS_UI_APPS_BACKEND_PROTOCOL.md +75 -74
  22. package/templates/basic/docs/CHATOS_UI_APPS_HOST_API.md +136 -136
  23. package/templates/basic/docs/CHATOS_UI_APPS_OVERVIEW.md +115 -113
  24. package/templates/basic/docs/CHATOS_UI_APPS_PLUGIN_MANIFEST.md +225 -224
  25. package/templates/basic/docs/CHATOS_UI_APPS_STYLE_GUIDE.md +95 -95
  26. package/templates/basic/docs/CHATOS_UI_APPS_TROUBLESHOOTING.md +45 -45
  27. package/templates/basic/docs/CHATOS_UI_PROMPTS_PROTOCOL.md +392 -392
  28. package/templates/basic/plugin/apps/app/compact.mjs +41 -41
  29. package/templates/basic/plugin/apps/app/index.mjs +287 -287
  30. package/templates/basic/plugin/apps/app/mcp-prompt.en.md +7 -7
  31. package/templates/basic/plugin/apps/app/mcp-prompt.zh.md +7 -7
  32. package/templates/basic/plugin/apps/app/mcp-server.mjs +15 -15
  33. package/templates/basic/plugin/backend/index.mjs +37 -37
  34. package/templates/basic/template.json +7 -7
  35. package/templates/notepad/README.md +61 -58
  36. package/templates/notepad/chatos.config.json +4 -4
  37. package/templates/notepad/docs/CHATOS_UI_APPS_AI_CONTRIBUTIONS.md +178 -178
  38. package/templates/notepad/docs/CHATOS_UI_APPS_BACKEND_PROTOCOL.md +75 -74
  39. package/templates/notepad/docs/CHATOS_UI_APPS_HOST_API.md +136 -136
  40. package/templates/notepad/docs/CHATOS_UI_APPS_OVERVIEW.md +115 -113
  41. package/templates/notepad/docs/CHATOS_UI_APPS_PLUGIN_MANIFEST.md +225 -224
  42. package/templates/notepad/docs/CHATOS_UI_APPS_STYLE_GUIDE.md +95 -95
  43. package/templates/notepad/docs/CHATOS_UI_APPS_TROUBLESHOOTING.md +45 -45
  44. package/templates/notepad/docs/CHATOS_UI_PROMPTS_PROTOCOL.md +392 -392
  45. package/templates/notepad/plugin/apps/app/api.mjs +30 -30
  46. package/templates/notepad/plugin/apps/app/compact.mjs +41 -41
  47. package/templates/notepad/plugin/apps/app/dom.mjs +14 -14
  48. package/templates/notepad/plugin/apps/app/ds-tree.mjs +35 -35
  49. package/templates/notepad/plugin/apps/app/index.mjs +1056 -1056
  50. package/templates/notepad/plugin/apps/app/layers.mjs +338 -338
  51. package/templates/notepad/plugin/apps/app/markdown.mjs +120 -120
  52. package/templates/notepad/plugin/apps/app/mcp-prompt.en.md +22 -22
  53. package/templates/notepad/plugin/apps/app/mcp-prompt.zh.md +22 -22
  54. package/templates/notepad/plugin/apps/app/mcp-server.mjs +207 -200
  55. package/templates/notepad/plugin/apps/app/styles.mjs +355 -355
  56. package/templates/notepad/plugin/apps/app/tags.mjs +21 -21
  57. package/templates/notepad/plugin/apps/app/ui.mjs +280 -280
  58. package/templates/notepad/plugin/backend/index.mjs +99 -99
  59. package/templates/notepad/plugin/plugin.json +23 -23
  60. package/templates/notepad/plugin/shared/notepad-paths.mjs +80 -62
  61. package/templates/notepad/plugin/shared/notepad-store.mjs +765 -765
  62. package/templates/notepad/template.json +8 -8
@@ -1,1200 +1,2302 @@
1
- import fs from 'fs';
2
- import http from 'http';
3
- import path from 'path';
4
- import url from 'url';
5
-
6
- import { ensureDir, isDirectory, isFile } from '../lib/fs.js';
7
- import { loadPluginManifest, pickAppFromManifest } from '../lib/plugin.js';
8
- import { resolveInsideDir } from '../lib/path-boundary.js';
9
-
10
- const __filename = url.fileURLToPath(import.meta.url);
11
- const __dirname = path.dirname(__filename);
12
-
13
- const TOKEN_REGEX = /--ds-[a-z0-9-]+/gi;
14
- const GLOBAL_STYLES_CANDIDATES = [
15
- path.resolve(__dirname, '..', '..', '..', 'common', 'aide-ui', 'components', 'GlobalStyles.jsx'),
16
- path.resolve(process.cwd(), 'common', 'aide-ui', 'components', 'GlobalStyles.jsx'),
17
- ];
18
-
19
- function loadTokenNames() {
20
- for (const candidate of GLOBAL_STYLES_CANDIDATES) {
21
- try {
22
- if (!isFile(candidate)) continue;
23
- const raw = fs.readFileSync(candidate, 'utf8');
24
- const matches = raw.match(TOKEN_REGEX) || [];
25
- const names = Array.from(new Set(matches.map((v) => v.toLowerCase())));
26
- if (names.length > 0) return names.sort();
27
- } catch {
28
- // ignore
29
- }
30
- }
31
- return [];
32
- }
33
-
34
-
35
- function sendJson(res, status, obj) {
36
- const raw = JSON.stringify(obj);
37
- res.writeHead(status, {
38
- 'content-type': 'application/json; charset=utf-8',
39
- 'cache-control': 'no-store',
40
- });
41
- res.end(raw);
42
- }
43
-
44
- function sendText(res, status, text, contentType) {
45
- res.writeHead(status, {
46
- 'content-type': contentType || 'text/plain; charset=utf-8',
47
- 'cache-control': 'no-store',
48
- });
49
- res.end(text);
50
- }
51
-
52
- function guessContentType(filePath) {
53
- const ext = path.extname(filePath).toLowerCase();
54
- if (ext === '.html') return 'text/html; charset=utf-8';
55
- if (ext === '.css') return 'text/css; charset=utf-8';
56
- if (ext === '.mjs' || ext === '.js') return 'text/javascript; charset=utf-8';
57
- if (ext === '.json') return 'application/json; charset=utf-8';
58
- if (ext === '.md') return 'text/markdown; charset=utf-8';
59
- if (ext === '.svg') return 'image/svg+xml';
60
- if (ext === '.png') return 'image/png';
61
- return 'application/octet-stream';
62
- }
63
-
64
- function serveStaticFile(res, filePath) {
65
- if (!isFile(filePath)) return false;
66
- const ct = guessContentType(filePath);
67
- const buf = fs.readFileSync(filePath);
68
- res.writeHead(200, { 'content-type': ct, 'cache-control': 'no-store' });
69
- res.end(buf);
70
- return true;
71
- }
72
-
73
- function startRecursiveWatcher(rootDir, onChange) {
74
- const root = path.resolve(rootDir);
75
- if (!isDirectory(root)) return () => {};
76
-
77
- const watchers = new Map();
78
-
79
- const shouldIgnore = (p) => {
80
- const base = path.basename(p);
81
- if (!base) return false;
82
- if (base === 'node_modules') return true;
83
- if (base === '.git') return true;
84
- if (base === '.DS_Store') return true;
85
- return false;
86
- };
87
-
88
- const scan = (dir) => {
89
- const abs = path.resolve(dir);
90
- if (!isDirectory(abs)) return;
91
- if (shouldIgnore(abs)) return;
92
- if (!watchers.has(abs)) {
93
- try {
94
- const w = fs.watch(abs, (eventType, filename) => {
95
- const relName = filename ? String(filename) : '';
96
- const filePath = relName ? path.join(abs, relName) : abs;
97
- try {
98
- onChange({ eventType, filePath });
99
- } catch {
100
- // ignore
101
- }
102
- scheduleRescan();
103
- });
104
- watchers.set(abs, w);
105
- } catch {
106
- // ignore
107
- }
108
- }
109
-
110
- let entries = [];
111
- try {
112
- entries = fs.readdirSync(abs, { withFileTypes: true });
113
- } catch {
114
- return;
115
- }
116
- for (const ent of entries) {
117
- if (!ent?.isDirectory?.()) continue;
118
- const child = path.join(abs, ent.name);
119
- if (shouldIgnore(child)) continue;
120
- scan(child);
121
- }
122
- };
123
-
124
- let rescanTimer = null;
125
- const scheduleRescan = () => {
126
- if (rescanTimer) return;
127
- rescanTimer = setTimeout(() => {
128
- rescanTimer = null;
129
- scan(root);
130
- }, 250);
131
- };
132
-
133
- scan(root);
134
-
135
- return () => {
136
- if (rescanTimer) {
137
- try {
138
- clearTimeout(rescanTimer);
139
- } catch {
140
- // ignore
141
- }
142
- rescanTimer = null;
143
- }
144
- for (const w of watchers.values()) {
145
- try {
146
- w.close();
147
- } catch {
148
- // ignore
149
- }
150
- }
151
- watchers.clear();
152
- };
153
- }
154
-
155
- function htmlPage() {
156
- return `<!doctype html>
157
- <html lang="zh-CN">
158
- <head>
159
- <meta charset="UTF-8" />
160
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
161
- <title>ChatOS UI Apps Sandbox</title>
162
- <style>
163
- :root {
164
- color-scheme: light;
165
- --ds-accent: #00d4ff;
166
- --ds-accent-2: #7c3aed;
167
- --ds-panel-bg: rgba(255, 255, 255, 0.86);
168
- --ds-panel-border: rgba(15, 23, 42, 0.08);
169
- --ds-subtle-bg: rgba(255, 255, 255, 0.62);
170
- --ds-selected-bg: linear-gradient(90deg, rgba(0, 212, 255, 0.14), rgba(124, 58, 237, 0.08));
171
- --ds-focus-ring: rgba(0, 212, 255, 0.32);
172
- --ds-nav-hover-bg: rgba(15, 23, 42, 0.06);
173
- --ds-code-bg: #f7f9fb;
174
- --ds-code-border: #eef2f7;
175
- --sandbox-bg: #f5f7fb;
176
- --sandbox-text: #111;
177
- }
178
- :root[data-theme='dark'] {
179
- color-scheme: dark;
180
- --ds-accent: #00d4ff;
181
- --ds-accent-2: #a855f7;
182
- --ds-panel-bg: rgba(17, 19, 28, 0.82);
183
- --ds-panel-border: rgba(255, 255, 255, 0.14);
184
- --ds-subtle-bg: rgba(255, 255, 255, 0.04);
185
- --ds-selected-bg: linear-gradient(90deg, rgba(0, 212, 255, 0.18), rgba(168, 85, 247, 0.14));
186
- --ds-focus-ring: rgba(0, 212, 255, 0.5);
187
- --ds-nav-hover-bg: rgba(255, 255, 255, 0.08);
188
- --ds-code-bg: #0d1117;
189
- --ds-code-border: #30363d;
190
- --sandbox-bg: #0f1115;
191
- --sandbox-text: #eee;
192
- }
193
- body {
194
- margin:0;
195
- font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
196
- background: var(--sandbox-bg);
197
- color: var(--sandbox-text);
198
- }
199
- #appRoot { height: 100vh; display:flex; flex-direction:column; }
200
- #sandboxToolbar {
201
- flex: 0 0 auto;
202
- border-bottom: 1px solid var(--ds-panel-border);
203
- padding: 10px 12px;
204
- background: var(--ds-panel-bg);
205
- }
206
- #headerSlot {
207
- flex: 0 0 auto;
208
- border-bottom: 1px solid var(--ds-panel-border);
209
- padding: 10px 12px;
210
- background: var(--ds-panel-bg);
211
- }
212
- #container { flex: 1 1 auto; min-height:0; overflow:hidden; }
213
- #containerInner { height:100%; overflow:auto; }
214
- .muted { opacity: 0.7; font-size: 12px; }
215
- .bar { display:flex; gap:10px; align-items:center; justify-content:space-between; }
216
- .btn {
217
- border:1px solid var(--ds-panel-border);
218
- background: var(--ds-subtle-bg);
219
- padding:6px 10px;
220
- border-radius:10px;
221
- cursor:pointer;
222
- font-weight:650;
223
- color: inherit;
224
- }
225
- .btn[data-active='1'] {
226
- background: var(--ds-selected-bg);
227
- box-shadow: 0 0 0 2px var(--ds-focus-ring);
228
- }
229
- .btn:active { transform: translateY(1px); }
230
- #promptsPanel {
231
- position: fixed;
232
- right: 12px;
233
- bottom: 12px;
234
- width: 420px;
235
- max-height: 70vh;
236
- display:none;
237
- flex-direction:column;
238
- background: var(--ds-panel-bg);
239
- color: inherit;
240
- border:1px solid var(--ds-panel-border);
241
- border-radius:14px;
242
- overflow:hidden;
243
- box-shadow: 0 18px 60px rgba(0,0,0,0.18);
244
- }
245
- #promptsPanelHeader { padding: 10px 12px; display:flex; align-items:center; justify-content:space-between; border-bottom: 1px solid var(--ds-panel-border); }
246
- #promptsPanelBody { padding: 10px 12px; overflow:auto; display:flex; flex-direction:column; gap:10px; }
247
- #promptsFab { position: fixed; right: 16px; bottom: 16px; width: 44px; height: 44px; border-radius: 999px; display:flex; align-items:center; justify-content:center; }
248
- .card { border: 1px solid var(--ds-panel-border); border-radius: 12px; padding: 10px; background: var(--ds-panel-bg); }
249
- .row { display:flex; gap:10px; }
250
- .toolbar-group { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
251
- .segmented { display:flex; gap:6px; align-items:center; }
252
- #sandboxInspector {
253
- position: fixed;
254
- right: 12px;
255
- top: 72px;
256
- width: 360px;
257
- max-height: 70vh;
258
- display: none;
259
- flex-direction: column;
260
- background: var(--ds-panel-bg);
261
- border: 1px solid var(--ds-panel-border);
262
- border-radius: 12px;
263
- overflow: hidden;
264
- box-shadow: 0 14px 40px rgba(0,0,0,0.16);
265
- z-index: 10;
266
- }
267
- #sandboxInspectorHeader {
268
- padding: 10px 12px;
269
- display:flex;
270
- align-items:center;
271
- justify-content: space-between;
272
- border-bottom: 1px solid var(--ds-panel-border);
273
- }
274
- #sandboxInspectorBody {
275
- padding: 10px 12px;
276
- overflow: auto;
277
- display: flex;
278
- flex-direction: column;
279
- gap: 10px;
280
- }
281
- .section-title { font-size: 12px; font-weight: 700; opacity: 0.8; }
282
- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; white-space: pre-wrap; }
283
- input, textarea, select {
284
- width:100%;
285
- padding:8px;
286
- border-radius:10px;
287
- border:1px solid var(--ds-panel-border);
288
- background: var(--ds-subtle-bg);
289
- color: inherit;
290
- }
291
- textarea { min-height: 70px; resize: vertical; }
292
- label { font-size: 12px; opacity: 0.8; }
293
- .danger { border-color: rgba(255,0,0,0.35); }
294
- </style>
295
- </head>
296
- <body>
297
- <div id="appRoot">
298
- <div id="sandboxToolbar">
299
- <div class="bar">
300
- <div>
301
- <div style="font-weight:800">ChatOS UI Apps Sandbox</div>
302
- <div class="muted">Host API mock · 模拟 module mount({ container, host, slots })</div>
303
- </div>
304
- <div class="row toolbar-group">
305
- <span class="muted">Theme</span>
306
- <div class="segmented" role="group" aria-label="Theme">
307
- <button id="btnThemeLight" class="btn" type="button">Light</button>
308
- <button id="btnThemeDark" class="btn" type="button">Dark</button>
309
- <button id="btnThemeSystem" class="btn" type="button">System</button>
310
- </div>
311
- <div id="themeStatus" class="muted"></div>
312
- <div id="sandboxContext" class="muted"></div>
313
- <button id="btnInspectorToggle" class="btn" type="button">Inspect</button>
314
- <button id="btnReload" class="btn" type="button">Reload</button>
315
- </div>
316
- </div>
317
- </div>
318
- <div id="headerSlot"></div>
319
- <div id="container"><div id="containerInner"></div></div>
320
- </div>
321
-
322
- <button id="promptsFab" class="btn" type="button">:)</button>
323
-
324
- <div id="promptsPanel">
325
- <div id="promptsPanelHeader">
326
- <div style="font-weight:800">UI Prompts</div>
327
- <button id="promptsClose" class="btn" type="button">Close</button>
328
- </div>
329
- <div id="promptsPanelBody"></div>
330
- </div>
331
-
332
- <div id="sandboxInspector" aria-hidden="true">
333
- <div id="sandboxInspectorHeader">
334
- <div style="font-weight:800">Sandbox Inspector</div>
335
- <div class="row">
336
- <button id="btnInspectorRefresh" class="btn" type="button">Refresh</button>
337
- <button id="btnInspectorClose" class="btn" type="button">Close</button>
338
- </div>
339
- </div>
340
- <div id="sandboxInspectorBody">
341
- <div>
342
- <div class="section-title">Host Context</div>
343
- <pre id="inspectorContext" class="mono"></pre>
344
- </div>
345
- <div>
346
- <div class="section-title">Theme</div>
347
- <pre id="inspectorTheme" class="mono"></pre>
348
- </div>
349
- <div>
350
- <div class="section-title">Tokens</div>
351
- <pre id="inspectorTokens" class="mono"></pre>
352
- </div>
353
- </div>
354
- </div>
355
-
356
- <script type="module" src="/sandbox.mjs"></script>
357
- </body>
358
- </html>`;
359
- }
360
-
361
- function sandboxClientJs() {
362
- return `const $ = (sel) => document.querySelector(sel);
363
-
364
- const container = $('#containerInner');
365
- const headerSlot = $('#headerSlot');
366
- const fab = $('#promptsFab');
367
- const panel = $('#promptsPanel');
368
- const panelBody = $('#promptsPanelBody');
369
- const panelClose = $('#promptsClose');
370
- const btnThemeLight = $('#btnThemeLight');
371
- const btnThemeDark = $('#btnThemeDark');
372
- const btnThemeSystem = $('#btnThemeSystem');
373
- const themeStatus = $('#themeStatus');
374
- const sandboxContext = $('#sandboxContext');
375
- const btnInspectorToggle = $('#btnInspectorToggle');
376
- const sandboxInspector = $('#sandboxInspector');
377
- const btnInspectorClose = $('#btnInspectorClose');
378
- const btnInspectorRefresh = $('#btnInspectorRefresh');
379
- const inspectorContext = $('#inspectorContext');
380
- const inspectorTheme = $('#inspectorTheme');
381
- const inspectorTokens = $('#inspectorTokens');
382
-
383
- const setPanelOpen = (open) => { panel.style.display = open ? 'flex' : 'none'; };
384
- fab.addEventListener('click', () => setPanelOpen(panel.style.display !== 'flex'));
385
- panelClose.addEventListener('click', () => setPanelOpen(false));
386
- window.addEventListener('chatos:uiPrompts:open', () => setPanelOpen(true));
387
- window.addEventListener('chatos:uiPrompts:close', () => setPanelOpen(false));
388
- window.addEventListener('chatos:uiPrompts:toggle', () => setPanelOpen(panel.style.display !== 'flex'));
389
-
390
- const THEME_STORAGE_KEY = 'chatos:sandbox:theme-mode';
391
- const themeListeners = new Set();
392
- const themeButtons = [
393
- { mode: 'light', el: btnThemeLight },
394
- { mode: 'dark', el: btnThemeDark },
395
- { mode: 'system', el: btnThemeSystem },
396
- ];
397
- const systemQuery = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
398
-
399
- const normalizeThemeMode = (mode) => (mode === 'light' || mode === 'dark' || mode === 'system' ? mode : 'system');
400
-
401
- const loadThemeMode = () => {
402
- try {
403
- return normalizeThemeMode(String(localStorage.getItem(THEME_STORAGE_KEY) || ''));
404
- } catch {
405
- return 'system';
406
- }
407
- };
408
-
409
- let themeMode = loadThemeMode();
410
- let currentTheme = 'light';
411
- let inspectorEnabled = false;
412
- let inspectorTimer = null;
413
-
414
- const resolveTheme = () => {
415
- if (themeMode === 'light' || themeMode === 'dark') return themeMode;
416
- return systemQuery && systemQuery.matches ? 'dark' : 'light';
417
- };
418
-
419
- const emitThemeChange = (theme) => {
420
- for (const fn of themeListeners) { try { fn(theme); } catch {} }
421
- };
422
-
423
- const updateThemeControls = () => {
424
- for (const { mode, el } of themeButtons) {
425
- if (!el) continue;
426
- const active = mode === themeMode;
427
- el.dataset.active = active ? '1' : '0';
428
- el.setAttribute('aria-pressed', active ? 'true' : 'false');
429
- }
430
- if (themeStatus) {
431
- themeStatus.textContent = themeMode === 'system' ? 'system -> ' + currentTheme : currentTheme;
432
- }
433
- };
434
-
435
- const updateContextStatus = () => {
436
- if (!sandboxContext) return;
437
- sandboxContext.textContent = __SANDBOX__.pluginId + ':' + __SANDBOX__.appId;
438
- };
439
-
440
- const isInspectorOpen = () => sandboxInspector && sandboxInspector.style.display === 'flex';
441
-
442
- const formatJson = (value) => {
443
- try {
444
- return JSON.stringify(value, null, 2);
445
- } catch {
446
- return String(value);
447
- }
448
- };
449
-
450
- const tokenNameList = Array.isArray(__SANDBOX__.tokenNames) ? __SANDBOX__.tokenNames : [];
451
-
452
- const collectTokens = () => {
453
- const style = getComputedStyle(document.documentElement);
454
- const names = new Set(tokenNameList);
455
- for (let i = 0; i < style.length; i += 1) {
456
- const name = style[i];
457
- if (name && name.startsWith('--ds-')) names.add(name);
458
- }
459
- return [...names]
460
- .sort()
461
- .map((name) => {
462
- const value = style.getPropertyValue(name).trim();
463
- return name + ': ' + (value || '(unset)');
464
- })
465
- .join('\\n');
466
- };
467
-
468
- const readHostContext = () => {
469
- if (!inspectorEnabled) return null;
470
- if (typeof host?.context?.get === 'function') return host.context.get();
471
- return { pluginId: __SANDBOX__.pluginId, appId: __SANDBOX__.appId, theme: currentTheme, bridge: { enabled: true } };
472
- };
473
-
474
- const readThemeInfo = () => ({
475
- themeMode,
476
- currentTheme,
477
- dataTheme: document.documentElement.dataset.theme || '',
478
- dataThemeMode: document.documentElement.dataset.themeMode || '',
479
- prefersColorScheme: systemQuery ? (systemQuery.matches ? 'dark' : 'light') : 'unknown',
480
- });
481
-
482
- const updateInspector = () => {
483
- if (!inspectorEnabled) return;
484
- if (inspectorContext) inspectorContext.textContent = formatJson(readHostContext());
485
- if (inspectorTheme) inspectorTheme.textContent = formatJson(readThemeInfo());
486
- if (inspectorTokens) inspectorTokens.textContent = collectTokens();
487
- };
488
-
489
- const startInspectorTimer = () => {
490
- if (inspectorTimer) return;
491
- inspectorTimer = setInterval(updateInspector, 1000);
492
- };
493
-
494
- const stopInspectorTimer = () => {
495
- if (!inspectorTimer) return;
496
- clearInterval(inspectorTimer);
497
- inspectorTimer = null;
498
- };
499
-
500
- const setInspectorOpen = (open) => {
501
- if (!sandboxInspector) return;
502
- sandboxInspector.style.display = open ? 'flex' : 'none';
503
- sandboxInspector.setAttribute('aria-hidden', open ? 'false' : 'true');
504
- if (open) {
505
- updateInspector();
506
- startInspectorTimer();
507
- } else {
508
- stopInspectorTimer();
509
- }
510
- };
511
-
512
- const updateInspectorIfOpen = () => {
513
- if (!inspectorEnabled) return;
514
- if (isInspectorOpen()) updateInspector();
515
- };
516
-
517
- const applyThemeMode = (mode, { persist = true } = {}) => {
518
- themeMode = normalizeThemeMode(mode);
519
- if (persist) {
520
- try {
521
- localStorage.setItem(THEME_STORAGE_KEY, themeMode);
522
- } catch {
523
- // ignore
524
- }
525
- }
526
- const nextTheme = resolveTheme();
527
- const prevTheme = currentTheme;
528
- currentTheme = nextTheme;
529
- document.documentElement.dataset.theme = nextTheme;
530
- document.documentElement.dataset.themeMode = themeMode;
531
- updateThemeControls();
532
- updateInspectorIfOpen();
533
- if (nextTheme !== prevTheme) emitThemeChange(nextTheme);
534
- };
535
-
536
- if (systemQuery && typeof systemQuery.addEventListener === 'function') {
537
- systemQuery.addEventListener('change', () => {
538
- if (themeMode === 'system') applyThemeMode('system', { persist: false });
539
- });
540
- }
541
-
542
- if (btnThemeLight) btnThemeLight.addEventListener('click', () => applyThemeMode('light'));
543
- if (btnThemeDark) btnThemeDark.addEventListener('click', () => applyThemeMode('dark'));
544
- if (btnThemeSystem) btnThemeSystem.addEventListener('click', () => applyThemeMode('system'));
545
- if (btnInspectorToggle) btnInspectorToggle.addEventListener('click', () => setInspectorOpen(!isInspectorOpen()));
546
- if (btnInspectorClose) btnInspectorClose.addEventListener('click', () => setInspectorOpen(false));
547
- if (btnInspectorRefresh) btnInspectorRefresh.addEventListener('click', () => updateInspector());
548
-
549
- applyThemeMode(themeMode || 'system', { persist: false });
550
- updateContextStatus();
551
-
552
- const entries = [];
553
- const listeners = new Set();
554
- const emitUpdate = () => {
555
- const payload = { path: '(sandbox)', entries: [...entries] };
556
- for (const fn of listeners) { try { fn(payload); } catch {} }
557
- renderPrompts();
558
- };
559
-
560
- const uuid = () => (globalThis.crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + Math.random().toString(16).slice(2));
561
-
562
- function renderPrompts() {
563
- panelBody.textContent = '';
564
- const pending = new Map();
565
- for (const e of entries) {
566
- if (e?.type !== 'ui_prompt') continue;
567
- const id = String(e?.requestId || '');
568
- if (!id) continue;
569
- if (e.action === 'request') pending.set(id, e);
570
- if (e.action === 'response') pending.delete(id);
571
- }
572
-
573
- if (pending.size === 0) {
574
- const empty = document.createElement('div');
575
- empty.className = 'muted';
576
- empty.textContent = '暂无待办(request 后会出现在这里)';
577
- panelBody.appendChild(empty);
578
- return;
579
- }
580
-
581
- for (const [requestId, req] of pending.entries()) {
582
- const card = document.createElement('div');
583
- card.className = 'card';
584
-
585
- const title = document.createElement('div');
586
- title.style.fontWeight = '800';
587
- title.textContent = req?.prompt?.title || '(untitled)';
588
-
589
- const msg = document.createElement('div');
590
- msg.className = 'muted';
591
- msg.style.marginTop = '6px';
592
- msg.textContent = req?.prompt?.message || '';
593
-
594
- const source = document.createElement('div');
595
- source.className = 'muted';
596
- source.style.marginTop = '6px';
597
- source.textContent = req?.prompt?.source ? String(req.prompt.source) : '';
598
-
599
- const form = document.createElement('div');
600
- form.style.marginTop = '10px';
601
- form.style.display = 'grid';
602
- form.style.gap = '10px';
603
-
604
- const kind = String(req?.prompt?.kind || '');
605
-
606
- const mkBtn = (label, danger) => {
607
- const btn = document.createElement('button');
608
- btn.type = 'button';
609
- btn.className = 'btn' + (danger ? ' danger' : '');
610
- btn.textContent = label;
611
- return btn;
612
- };
613
-
614
- const submit = async (response) => {
615
- entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'response', requestId, response });
616
- emitUpdate();
617
- };
618
-
619
- if (kind === 'kv') {
620
- const fields = Array.isArray(req?.prompt?.fields) ? req.prompt.fields : [];
621
- const values = {};
622
- for (const f of fields) {
623
- const key = String(f?.key || '');
624
- if (!key) continue;
625
- const wrap = document.createElement('div');
626
- const lab = document.createElement('label');
627
- lab.textContent = f?.label ? String(f.label) : key;
628
- const input = document.createElement(f?.multiline ? 'textarea' : 'input');
629
- input.placeholder = f?.placeholder ? String(f.placeholder) : '';
630
- input.value = f?.default ? String(f.default) : '';
631
- input.addEventListener('input', () => { values[key] = String(input.value || ''); });
632
- values[key] = String(input.value || '');
633
- wrap.appendChild(lab);
634
- wrap.appendChild(input);
635
- form.appendChild(wrap);
636
- }
637
- const row = document.createElement('div');
638
- row.className = 'row';
639
- const ok = mkBtn('Submit');
640
- ok.addEventListener('click', () => submit({ status: 'ok', values }));
641
- const cancel = mkBtn('Cancel', true);
642
- cancel.addEventListener('click', () => submit({ status: 'cancel' }));
643
- row.appendChild(ok);
644
- row.appendChild(cancel);
645
- form.appendChild(row);
646
- } else if (kind === 'choice') {
647
- const options = Array.isArray(req?.prompt?.options) ? req.prompt.options : [];
648
- const multiple = Boolean(req?.prompt?.multiple);
649
- const selected = new Set();
650
- const wrap = document.createElement('div');
651
- const lab = document.createElement('label');
652
- lab.textContent = '选择';
653
- const select = document.createElement('select');
654
- if (multiple) select.multiple = true;
655
- for (const opt of options) {
656
- const v = String(opt?.value || '');
657
- const o = document.createElement('option');
658
- o.value = v;
659
- o.textContent = opt?.label ? String(opt.label) : v;
660
- select.appendChild(o);
661
- }
662
- select.addEventListener('change', () => {
663
- selected.clear();
664
- for (const o of select.selectedOptions) selected.add(String(o.value));
665
- });
666
- wrap.appendChild(lab);
667
- wrap.appendChild(select);
668
- form.appendChild(wrap);
669
- const row = document.createElement('div');
670
- row.className = 'row';
671
- const ok = mkBtn('Submit');
672
- ok.addEventListener('click', () => submit({ status: 'ok', value: multiple ? Array.from(selected) : Array.from(selected)[0] || '' }));
673
- const cancel = mkBtn('Cancel', true);
674
- cancel.addEventListener('click', () => submit({ status: 'cancel' }));
675
- row.appendChild(ok);
676
- row.appendChild(cancel);
677
- form.appendChild(row);
678
- } else {
679
- const row = document.createElement('div');
680
- row.className = 'row';
681
- const ok = mkBtn('OK');
682
- ok.addEventListener('click', () => submit({ status: 'ok' }));
683
- const cancel = mkBtn('Cancel', true);
684
- cancel.addEventListener('click', () => submit({ status: 'cancel' }));
685
- row.appendChild(ok);
686
- row.appendChild(cancel);
687
- form.appendChild(row);
688
- }
689
-
690
- card.appendChild(title);
691
- if (msg.textContent) card.appendChild(msg);
692
- if (source.textContent) card.appendChild(source);
693
- card.appendChild(form);
694
- panelBody.appendChild(card);
695
- }
696
- }
697
-
698
- const getTheme = () => currentTheme || resolveTheme();
699
-
700
- const host = {
701
- bridge: { enabled: true },
702
- context: { get: () => ({ pluginId: __SANDBOX__.pluginId, appId: __SANDBOX__.appId, theme: getTheme(), bridge: { enabled: true } }) },
703
- theme: {
704
- get: getTheme,
705
- onChange: (listener) => {
706
- if (typeof listener !== 'function') return () => {};
707
- themeListeners.add(listener);
708
- return () => themeListeners.delete(listener);
709
- },
710
- },
711
- admin: {
712
- state: async () => ({ ok: true, state: {} }),
713
- onUpdate: () => () => {},
714
- models: { list: async () => ({ ok: true, models: [] }) },
715
- secrets: { list: async () => ({ ok: true, secrets: [] }) },
716
- },
717
- registry: {
718
- list: async () => ({ ok: true, apps: [__SANDBOX__.registryApp] }),
719
- },
720
- backend: {
721
- invoke: async (method, params) => {
722
- const r = await fetch('/api/backend/invoke', {
723
- method: 'POST',
724
- headers: { 'content-type': 'application/json' },
725
- body: JSON.stringify({ method, params }),
726
- });
727
- const j = await r.json();
728
- if (j?.ok === false) throw new Error(j?.message || 'invoke failed');
729
- return j?.result;
730
- },
731
- },
732
- uiPrompts: {
733
- read: async () => ({ path: '(sandbox)', entries: [...entries] }),
734
- onUpdate: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
735
- request: async (payload) => {
736
- const requestId = payload?.requestId ? String(payload.requestId) : uuid();
737
- const prompt = payload?.prompt && typeof payload.prompt === 'object' ? { ...payload.prompt } : null;
738
- if (prompt && !prompt.source) prompt.source = __SANDBOX__.pluginId + ':' + __SANDBOX__.appId;
739
- entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'request', requestId, runId: payload?.runId, prompt });
740
- emitUpdate();
741
- return { ok: true, requestId };
742
- },
743
- respond: async (payload) => {
744
- const requestId = String(payload?.requestId || '');
745
- if (!requestId) throw new Error('requestId is required');
746
- const response = payload?.response && typeof payload.response === 'object' ? payload.response : null;
747
- entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'response', requestId, runId: payload?.runId, response });
748
- emitUpdate();
749
- return { ok: true };
750
- },
751
- open: () => (setPanelOpen(true), { ok: true }),
752
- close: () => (setPanelOpen(false), { ok: true }),
753
- toggle: () => (setPanelOpen(panel.style.display !== 'flex'), { ok: true }),
754
- },
755
- ui: { navigate: (menu) => ({ ok: true, menu }) },
756
- chat: (() => {
757
- const clone = (v) => JSON.parse(JSON.stringify(v));
758
-
759
- const agents = [
760
- {
761
- id: 'sandbox-agent',
762
- name: 'Sandbox Agent',
763
- description: 'Mock agent for ChatOS UI Apps Sandbox',
764
- },
765
- ];
766
-
767
- const sessions = new Map();
768
- const defaultSessionByAgent = new Map();
769
- const messagesBySession = new Map();
770
-
771
- const listeners = new Set();
772
- const activeRuns = new Map(); // sessionId -> { aborted: boolean, timers: number[] }
773
-
774
- const emit = (payload) => {
775
- for (const sub of listeners) {
776
- const filter = sub?.filter && typeof sub.filter === 'object' ? sub.filter : {};
777
- if (filter?.sessionId && String(filter.sessionId) !== String(payload?.sessionId || '')) continue;
778
- if (Array.isArray(filter?.types) && filter.types.length > 0) {
779
- const t = String(payload?.type || '');
780
- if (!filter.types.includes(t)) continue;
781
- }
782
- try {
783
- sub.fn(payload);
784
- } catch {
785
- // ignore
786
- }
787
- }
788
- };
789
-
790
- const ensureAgent = async () => {
791
- if (agents.length > 0) return agents[0];
792
- const created = { id: 'sandbox-agent', name: 'Sandbox Agent', description: 'Mock agent' };
793
- agents.push(created);
794
- return created;
795
- };
796
-
797
- const ensureSession = async (agentId) => {
798
- const aid = String(agentId || '').trim() || (await ensureAgent()).id;
799
- const existingId = defaultSessionByAgent.get(aid);
800
- if (existingId && sessions.has(existingId)) return sessions.get(existingId);
801
-
802
- const id = 'sandbox-session-' + uuid();
803
- const session = { id, agentId: aid, createdAt: new Date().toISOString() };
804
- sessions.set(id, session);
805
- defaultSessionByAgent.set(aid, id);
806
- if (!messagesBySession.has(id)) messagesBySession.set(id, []);
807
- return session;
808
- };
809
-
810
- const agentsApi = {
811
- list: async () => ({ ok: true, agents: clone(agents) }),
812
- ensureDefault: async () => ({ ok: true, agent: clone(await ensureAgent()) }),
813
- create: async (payload) => {
814
- const agent = {
815
- id: 'sandbox-agent-' + uuid(),
816
- name: payload?.name ? String(payload.name) : 'Sandbox Agent',
817
- description: payload?.description ? String(payload.description) : '',
818
- };
819
- agents.unshift(agent);
820
- return { ok: true, agent: clone(agent) };
821
- },
822
- update: async (id, patch) => {
823
- const agentId = String(id || '').trim();
824
- if (!agentId) throw new Error('id is required');
825
- const idx = agents.findIndex((a) => a.id === agentId);
826
- if (idx < 0) throw new Error('agent not found');
827
- const a = agents[idx];
828
- if (patch?.name) a.name = String(patch.name);
829
- if (patch?.description) a.description = String(patch.description);
830
- return { ok: true, agent: clone(a) };
831
- },
832
- delete: async (id) => {
833
- const agentId = String(id || '').trim();
834
- if (!agentId) throw new Error('id is required');
835
- const idx = agents.findIndex((a) => a.id === agentId);
836
- if (idx < 0) return { ok: true, deleted: false };
837
- agents.splice(idx, 1);
838
- return { ok: true, deleted: true };
839
- },
840
- createForApp: async (payload) => {
841
- const name = payload?.name ? String(payload.name) : 'App Agent (' + __SANDBOX__.appId + ')';
842
- return await agentsApi.create({ ...payload, name });
843
- },
844
- };
845
-
846
- const sessionsApi = {
847
- list: async () => ({ ok: true, sessions: clone(Array.from(sessions.values())) }),
848
- ensureDefault: async (payload) => {
849
- const session = await ensureSession(payload?.agentId);
850
- return { ok: true, session: clone(session) };
851
- },
852
- create: async (payload) => {
853
- const agentId = payload?.agentId ? String(payload.agentId) : (await ensureAgent()).id;
854
- const id = 'sandbox-session-' + uuid();
855
- const session = { id, agentId, createdAt: new Date().toISOString() };
856
- sessions.set(id, session);
857
- if (!messagesBySession.has(id)) messagesBySession.set(id, []);
858
- return { ok: true, session: clone(session) };
859
- },
860
- };
861
-
862
- const messagesApi = {
863
- list: async (payload) => {
864
- const sessionId = String(payload?.sessionId || '').trim();
865
- if (!sessionId) throw new Error('sessionId is required');
866
- const msgs = messagesBySession.get(sessionId) || [];
867
- return { ok: true, messages: clone(msgs) };
868
- },
869
- };
870
-
871
- const abort = async (payload) => {
872
- const sessionId = String(payload?.sessionId || '').trim();
873
- if (!sessionId) throw new Error('sessionId is required');
874
- const run = activeRuns.get(sessionId);
875
- if (run) {
876
- run.aborted = true;
877
- for (const t of run.timers) {
878
- try {
879
- clearTimeout(t);
880
- } catch {
881
- // ignore
882
- }
883
- }
884
- activeRuns.delete(sessionId);
885
- }
886
- emit({ type: 'assistant_abort', sessionId, ts: new Date().toISOString() });
887
- return { ok: true };
888
- };
889
-
890
- const send = async (payload) => {
891
- const sessionId = String(payload?.sessionId || '').trim();
892
- const text = String(payload?.text || '').trim();
893
- if (!sessionId) throw new Error('sessionId is required');
894
- if (!text) throw new Error('text is required');
895
-
896
- if (!sessions.has(sessionId)) throw new Error('session not found');
897
-
898
- const msgs = messagesBySession.get(sessionId) || [];
899
- const userMsg = { id: 'msg-' + uuid(), role: 'user', text, ts: new Date().toISOString() };
900
- msgs.push(userMsg);
901
- messagesBySession.set(sessionId, msgs);
902
- emit({ type: 'user_message', sessionId, message: clone(userMsg) });
903
-
904
- const assistantMsg = { id: 'msg-' + uuid(), role: 'assistant', text: '', ts: new Date().toISOString() };
905
- msgs.push(assistantMsg);
906
- emit({ type: 'assistant_start', sessionId, message: clone(assistantMsg) });
907
-
908
- const out = '[sandbox] echo: ' + text;
909
- const chunks = [];
910
- for (let i = 0; i < out.length; i += 8) chunks.push(out.slice(i, i + 8));
911
-
912
- const run = { aborted: false, timers: [] };
913
- activeRuns.set(sessionId, run);
914
-
915
- chunks.forEach((delta, idx) => {
916
- const t = setTimeout(() => {
917
- if (run.aborted) return;
918
- assistantMsg.text += delta;
919
- emit({ type: 'assistant_delta', sessionId, delta });
920
- if (idx === chunks.length - 1) {
921
- activeRuns.delete(sessionId);
922
- emit({ type: 'assistant_end', sessionId, message: clone(assistantMsg) });
923
- }
924
- }, 80 + idx * 60);
925
- run.timers.push(t);
926
- });
927
-
928
- return { ok: true };
929
- };
930
-
931
- const events = {
932
- subscribe: (filter, fn) => {
933
- if (typeof fn !== 'function') throw new Error('listener is required');
934
- const sub = { filter: filter && typeof filter === 'object' ? { ...filter } : {}, fn };
935
- listeners.add(sub);
936
- return () => listeners.delete(sub);
937
- },
938
- unsubscribe: () => (listeners.clear(), { ok: true }),
939
- };
940
-
941
- return {
942
- agents: agentsApi,
943
- sessions: sessionsApi,
944
- messages: messagesApi,
945
- send,
946
- abort,
947
- events,
948
- };
949
- })(),
950
- };
951
-
952
- inspectorEnabled = true;
953
- updateInspector();
954
-
955
- let dispose = null;
956
-
957
- async function loadAndMount() {
958
- if (typeof dispose === 'function') { try { await dispose(); } catch {} dispose = null; }
959
- container.textContent = '';
960
-
961
- const entryUrl = __SANDBOX__.entryUrl;
962
- const mod = await import(entryUrl + (entryUrl.includes('?') ? '&' : '?') + 't=' + Date.now());
963
- const mount = mod?.mount || mod?.default?.mount || (typeof mod?.default === 'function' ? mod.default : null);
964
- if (typeof mount !== 'function') throw new Error('module entry must export mount()');
965
- const ret = await mount({ container, host, slots: { header: headerSlot } });
966
- if (typeof ret === 'function') dispose = ret;
967
- else if (ret && typeof ret.dispose === 'function') dispose = () => ret.dispose();
968
- }
969
-
970
- const renderError = (e) => {
971
- const pre = document.createElement('pre');
972
- pre.style.padding = '12px';
973
- pre.style.whiteSpace = 'pre-wrap';
974
- pre.textContent = '[sandbox] ' + (e?.stack || e?.message || String(e));
975
- container.appendChild(pre);
976
- };
977
-
978
- const scheduleReload = (() => {
979
- let t = null;
980
- return () => {
981
- if (t) return;
982
- t = setTimeout(() => {
983
- t = null;
984
- loadAndMount().catch(renderError);
985
- }, 80);
986
- };
987
- })();
988
-
989
- try {
990
- const es = new EventSource('/events');
991
- es.addEventListener('reload', () => scheduleReload());
992
- } catch {
993
- // ignore
994
- }
995
-
996
- $('#btnReload').addEventListener('click', () => loadAndMount().catch(renderError));
997
-
998
- loadAndMount().catch(renderError);
999
- `;
1000
- }
1001
-
1002
- async function loadBackendFactory({ pluginDir, manifest }) {
1003
- const entryRel = manifest?.backend?.entry ? String(manifest.backend.entry).trim() : '';
1004
- if (!entryRel) return null;
1005
- const abs = resolveInsideDir(pluginDir, entryRel);
1006
- const fileUrl = url.pathToFileURL(abs).toString();
1007
- const mod = await import(fileUrl + `?t=${Date.now()}`);
1008
- if (typeof mod?.createUiAppsBackend !== 'function') {
1009
- throw new Error('backend entry must export createUiAppsBackend(ctx)');
1010
- }
1011
- return mod.createUiAppsBackend;
1012
- }
1013
-
1014
- export async function startSandboxServer({ pluginDir, port = 4399, appId = '' }) {
1015
- const { manifest } = loadPluginManifest(pluginDir);
1016
- const app = pickAppFromManifest(manifest, appId);
1017
- const effectiveAppId = String(app?.id || '');
1018
- const entryRel = String(app?.entry?.path || '').trim();
1019
- if (!entryRel) throw new Error('apps[i].entry.path is required');
1020
-
1021
- const entryAbs = resolveInsideDir(pluginDir, entryRel);
1022
- if (!isFile(entryAbs)) throw new Error(`module entry not found: ${entryRel}`);
1023
-
1024
- const entryUrl = `/plugin/${encodeURIComponent(entryRel).replaceAll('%2F', '/')}`;
1025
-
1026
- let backendInstance = null;
1027
- let backendFactory = null;
1028
-
1029
- const ctxBase = {
1030
- pluginId: String(manifest?.id || ''),
1031
- pluginDir,
1032
- stateDir: path.join(process.cwd(), '.chatos', 'state', 'chatos'),
1033
- sessionRoot: process.cwd(),
1034
- projectRoot: process.cwd(),
1035
- dataDir: '',
1036
- llm: {
1037
- complete: async (payload) => {
1038
- const input = typeof payload?.input === 'string' ? payload.input : '';
1039
- const normalized = String(input || '').trim();
1040
- if (!normalized) throw new Error('input is required');
1041
- const modelName =
1042
- typeof payload?.modelName === 'string' && payload.modelName.trim()
1043
- ? payload.modelName.trim()
1044
- : typeof payload?.modelId === 'string' && payload.modelId.trim()
1045
- ? `model:${payload.modelId.trim()}`
1046
- : 'sandbox';
1047
- return {
1048
- ok: true,
1049
- model: modelName,
1050
- content: `[sandbox llm] ${normalized}`,
1051
- };
1052
- },
1053
- },
1054
- };
1055
- ctxBase.dataDir = path.join(process.cwd(), '.chatos', 'data', ctxBase.pluginId);
1056
- ensureDir(ctxBase.stateDir);
1057
- ensureDir(ctxBase.dataDir);
1058
-
1059
- const sseClients = new Set();
1060
- const sseWrite = (res, event, data) => {
1061
- try {
1062
- res.write(`event: ${event}\n`);
1063
- res.write(`data: ${JSON.stringify(data ?? null)}\n\n`);
1064
- } catch {
1065
- // ignore
1066
- }
1067
- };
1068
- const sseBroadcast = (event, data) => {
1069
- for (const res of sseClients) {
1070
- sseWrite(res, event, data);
1071
- }
1072
- };
1073
-
1074
- let changeSeq = 0;
1075
- const stopWatch = startRecursiveWatcher(pluginDir, ({ eventType, filePath }) => {
1076
- const rel = filePath ? path.relative(pluginDir, filePath).replaceAll('\\', '/') : '';
1077
- const base = rel ? path.basename(rel) : '';
1078
- if (!rel) return;
1079
- if (base === '.DS_Store') return;
1080
- if (base.endsWith('.map')) return;
1081
-
1082
- changeSeq += 1;
1083
- if (rel.startsWith('backend/')) {
1084
- backendInstance = null;
1085
- backendFactory = null;
1086
- }
1087
- sseBroadcast('reload', { seq: changeSeq, eventType: eventType || '', path: rel });
1088
- });
1089
-
1090
- const server = http.createServer(async (req, res) => {
1091
- try {
1092
- const parsed = url.parse(req.url || '/', true);
1093
- const pathname = parsed.pathname || '/';
1094
-
1095
- if (req.method === 'GET' && pathname === '/') {
1096
- return sendText(res, 200, htmlPage(), 'text/html; charset=utf-8');
1097
- }
1098
-
1099
- if (req.method === 'GET' && pathname === '/events') {
1100
- res.writeHead(200, {
1101
- 'content-type': 'text/event-stream; charset=utf-8',
1102
- 'cache-control': 'no-store',
1103
- connection: 'keep-alive',
1104
- });
1105
- res.write(': connected\n\n');
1106
- sseClients.add(res);
1107
- const ping = setInterval(() => {
1108
- try {
1109
- res.write(': ping\n\n');
1110
- } catch {
1111
- // ignore
1112
- }
1113
- }, 15000);
1114
- req.on('close', () => {
1115
- try {
1116
- clearInterval(ping);
1117
- } catch {
1118
- // ignore
1119
- }
1120
- sseClients.delete(res);
1121
- });
1122
- return;
1123
- }
1124
-
1125
- if (req.method === 'GET' && pathname === '/sandbox.mjs') {
1126
- const tokenNames = loadTokenNames();
1127
- const js = sandboxClientJs()
1128
- .replaceAll('__SANDBOX__.pluginId', JSON.stringify(ctxBase.pluginId))
1129
- .replaceAll('__SANDBOX__.appId', JSON.stringify(effectiveAppId))
1130
- .replaceAll('__SANDBOX__.entryUrl', JSON.stringify(entryUrl))
1131
- .replaceAll('__SANDBOX__.registryApp', JSON.stringify({ plugin: { id: ctxBase.pluginId }, id: effectiveAppId, entry: { type: 'module', url: entryUrl } }))
1132
- .replaceAll('__SANDBOX__.tokenNames', JSON.stringify(tokenNames));
1133
- return sendText(res, 200, js, 'text/javascript; charset=utf-8');
1134
- }
1135
-
1136
- if (req.method === 'GET' && pathname.startsWith('/plugin/')) {
1137
- const rel = decodeURIComponent(pathname.slice('/plugin/'.length));
1138
- const abs = resolveInsideDir(pluginDir, rel);
1139
- if (!serveStaticFile(res, abs)) return sendText(res, 404, 'Not found');
1140
- return;
1141
- }
1142
-
1143
- if (req.method === 'GET' && pathname === '/api/manifest') {
1144
- return sendJson(res, 200, { ok: true, manifest });
1145
- }
1146
-
1147
- if (pathname === '/api/backend/invoke') {
1148
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, message: 'Method not allowed' });
1149
- let body = '';
1150
- req.on('data', (chunk) => {
1151
- body += chunk;
1152
- });
1153
- req.on('end', async () => {
1154
- try {
1155
- const payload = body ? JSON.parse(body) : {};
1156
- const method = typeof payload?.method === 'string' ? payload.method.trim() : '';
1157
- if (!method) return sendJson(res, 400, { ok: false, message: 'method is required' });
1158
- const params = payload?.params;
1159
-
1160
- if (!backendFactory) backendFactory = await loadBackendFactory({ pluginDir, manifest });
1161
- if (!backendFactory) return sendJson(res, 200, { ok: false, message: 'backend not configured in plugin.json' });
1162
-
1163
- if (!backendInstance || typeof backendInstance !== 'object' || !backendInstance.methods) {
1164
- backendInstance = await backendFactory({ ...ctxBase });
1165
- }
1166
- const fn = backendInstance?.methods?.[method];
1167
- if (typeof fn !== 'function') return sendJson(res, 404, { ok: false, message: `method not found: ${method}` });
1168
- const result = await fn(params, { ...ctxBase });
1169
- return sendJson(res, 200, { ok: true, result });
1170
- } catch (e) {
1171
- return sendJson(res, 200, { ok: false, message: e?.message || String(e) });
1172
- }
1173
- });
1174
- return;
1175
- }
1176
-
1177
- sendText(res, 404, 'Not found');
1178
- } catch (e) {
1179
- sendJson(res, 500, { ok: false, message: e?.message || String(e) });
1180
- }
1181
- });
1182
- server.once('close', () => stopWatch());
1183
-
1184
- await new Promise((resolve, reject) => {
1185
- server.once('error', reject);
1186
- server.listen(port, '127.0.0.1', () => {
1187
- server.off('error', reject);
1188
- resolve();
1189
- });
1190
- });
1191
-
1192
- // eslint-disable-next-line no-console
1193
- console.log(`Sandbox running:
1194
- http://localhost:${port}/
1195
- pluginDir:
1196
- ${pluginDir}
1197
- app:
1198
- ${ctxBase.pluginId}:${effectiveAppId}
1199
- `);
1200
- }
1
+ import fs from 'fs';
2
+ import http from 'http';
3
+ import path from 'path';
4
+ import url from 'url';
5
+
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
8
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
9
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
10
+ import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
11
+
12
+ import { copyDir, ensureDir, isDirectory, isFile } from '../lib/fs.js';
13
+ import { loadPluginManifest, pickAppFromManifest } from '../lib/plugin.js';
14
+ import { resolveInsideDir } from '../lib/path-boundary.js';
15
+ import { COMPAT_STATE_ROOT_DIRNAME, STATE_ROOT_DIRNAME } from '../lib/state-constants.js';
16
+
17
+ const __filename = url.fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+
20
+ const TOKEN_REGEX = /--ds-[a-z0-9-]+/gi;
21
+ const SANDBOX_STATE_DIRNAME = STATE_ROOT_DIRNAME;
22
+ const SANDBOX_COMPAT_DIRNAME = COMPAT_STATE_ROOT_DIRNAME;
23
+ const GLOBAL_STYLES_CANDIDATES = [
24
+ path.resolve(__dirname, '..', '..', '..', 'common', 'aide-ui', 'components', 'GlobalStyles.jsx'),
25
+ path.resolve(process.cwd(), 'common', 'aide-ui', 'components', 'GlobalStyles.jsx'),
26
+ ];
27
+
28
+ function loadTokenNames() {
29
+ for (const candidate of GLOBAL_STYLES_CANDIDATES) {
30
+ try {
31
+ if (!isFile(candidate)) continue;
32
+ const raw = fs.readFileSync(candidate, 'utf8');
33
+ const matches = raw.match(TOKEN_REGEX) || [];
34
+ const names = Array.from(new Set(matches.map((v) => v.toLowerCase())));
35
+ if (names.length > 0) return names.sort();
36
+ } catch {
37
+ // ignore
38
+ }
39
+ }
40
+ return [];
41
+ }
42
+
43
+ function resolveSandboxRoots() {
44
+ const cwd = process.cwd();
45
+ const primary = path.join(cwd, SANDBOX_STATE_DIRNAME);
46
+ const legacy = path.join(cwd, SANDBOX_COMPAT_DIRNAME);
47
+ if (!isDirectory(primary) && isDirectory(legacy)) {
48
+ try {
49
+ copyDir(legacy, primary);
50
+ } catch {
51
+ // ignore compat copy errors
52
+ }
53
+ }
54
+ return { primary, legacy };
55
+ }
56
+
57
+ function resolveSandboxConfigPath({ primaryRoot, legacyRoot }) {
58
+ const primaryPath = path.join(primaryRoot, 'sandbox', 'llm-config.json');
59
+ if (isFile(primaryPath)) return primaryPath;
60
+ const legacyPath = path.join(legacyRoot, 'sandbox', 'llm-config.json');
61
+ if (isFile(legacyPath)) return legacyPath;
62
+ return primaryPath;
63
+ }
64
+
65
+ const DEFAULT_LLM_BASE_URL = 'https://api.openai.com/v1';
66
+
67
+ function normalizeText(value) {
68
+ return typeof value === 'string' ? value.trim() : '';
69
+ }
70
+
71
+ function isPlainObject(value) {
72
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
73
+ }
74
+
75
+ function cloneValue(value) {
76
+ if (Array.isArray(value)) {
77
+ return value.map((entry) => cloneValue(entry));
78
+ }
79
+ if (isPlainObject(value)) {
80
+ const out = {};
81
+ Object.entries(value).forEach(([key, entry]) => {
82
+ out[key] = cloneValue(entry);
83
+ });
84
+ return out;
85
+ }
86
+ return value;
87
+ }
88
+
89
+ function mergeCallMeta(base, override) {
90
+ if (!base && !override) return null;
91
+ if (!base) return cloneValue(override);
92
+ if (!override) return cloneValue(base);
93
+ if (!isPlainObject(base) || !isPlainObject(override)) {
94
+ return cloneValue(override);
95
+ }
96
+ const merged = cloneValue(base);
97
+ Object.entries(override).forEach(([key, value]) => {
98
+ if (isPlainObject(value) && isPlainObject(merged[key])) {
99
+ merged[key] = mergeCallMeta(merged[key], value);
100
+ } else {
101
+ merged[key] = cloneValue(value);
102
+ }
103
+ });
104
+ return merged;
105
+ }
106
+
107
+ function expandCallMetaValue(value, vars) {
108
+ if (typeof value === 'string') {
109
+ let text = value;
110
+ Object.entries(vars).forEach(([key, replacement]) => {
111
+ const token = `$${key}`;
112
+ text = text.split(token).join(String(replacement || ''));
113
+ });
114
+ return text;
115
+ }
116
+ if (Array.isArray(value)) {
117
+ return value.map((entry) => expandCallMetaValue(entry, vars));
118
+ }
119
+ if (isPlainObject(value)) {
120
+ const out = {};
121
+ Object.entries(value).forEach(([key, entry]) => {
122
+ out[key] = expandCallMetaValue(entry, vars);
123
+ });
124
+ return out;
125
+ }
126
+ return value;
127
+ }
128
+
129
+ function buildSandboxCallMeta({ rawCallMeta, context } = {}) {
130
+ const ctx = context && typeof context === 'object' ? context : null;
131
+ const defaults = ctx
132
+ ? {
133
+ chatos: {
134
+ uiApp: {
135
+ ...(ctx.pluginId ? { pluginId: ctx.pluginId } : null),
136
+ ...(ctx.appId ? { appId: ctx.appId } : null),
137
+ ...(ctx.pluginDir ? { pluginDir: ctx.pluginDir } : null),
138
+ ...(ctx.dataDir ? { dataDir: ctx.dataDir } : null),
139
+ ...(ctx.stateDir ? { stateDir: ctx.stateDir } : null),
140
+ ...(ctx.sessionRoot ? { sessionRoot: ctx.sessionRoot } : null),
141
+ ...(ctx.projectRoot ? { projectRoot: ctx.projectRoot } : null),
142
+ },
143
+ },
144
+ workdir: ctx.dataDir || ctx.pluginDir || ctx.projectRoot || ctx.sessionRoot || '',
145
+ }
146
+ : null;
147
+ const raw = rawCallMeta && typeof rawCallMeta === 'object' ? rawCallMeta : null;
148
+ if (!defaults && !raw) return null;
149
+ const vars = ctx
150
+ ? {
151
+ pluginId: ctx.pluginId || '',
152
+ appId: ctx.appId || '',
153
+ pluginDir: ctx.pluginDir || '',
154
+ dataDir: ctx.dataDir || '',
155
+ stateDir: ctx.stateDir || '',
156
+ sessionRoot: ctx.sessionRoot || '',
157
+ projectRoot: ctx.projectRoot || '',
158
+ }
159
+ : {};
160
+ const expanded = raw ? expandCallMetaValue(raw, vars) : null;
161
+ return mergeCallMeta(defaults, expanded);
162
+ }
163
+
164
+ function loadSandboxLlmConfig(filePath) {
165
+ if (!filePath) return { apiKey: '', baseUrl: '', modelId: '' };
166
+ try {
167
+ if (!fs.existsSync(filePath)) return { apiKey: '', baseUrl: '', modelId: '' };
168
+ const raw = fs.readFileSync(filePath, 'utf8');
169
+ const parsed = raw ? JSON.parse(raw) : {};
170
+ return {
171
+ apiKey: normalizeText(parsed?.apiKey),
172
+ baseUrl: normalizeText(parsed?.baseUrl),
173
+ modelId: normalizeText(parsed?.modelId),
174
+ };
175
+ } catch {
176
+ return { apiKey: '', baseUrl: '', modelId: '' };
177
+ }
178
+ }
179
+
180
+ function saveSandboxLlmConfig(filePath, config) {
181
+ if (!filePath) return;
182
+ try {
183
+ ensureDir(path.dirname(filePath));
184
+ fs.writeFileSync(filePath, JSON.stringify(config || {}, null, 2), 'utf8');
185
+ } catch {
186
+ // ignore
187
+ }
188
+ }
189
+
190
+ function resolveChatCompletionsUrl(baseUrl) {
191
+ const raw = normalizeText(baseUrl);
192
+ if (!raw) return `${DEFAULT_LLM_BASE_URL}/chat/completions`;
193
+ const normalized = raw.replace(/\/+$/g, '');
194
+ if (normalized.endsWith('/chat/completions')) return normalized;
195
+ if (normalized.includes('/v1')) return `${normalized}/chat/completions`;
196
+ return `${normalized}/v1/chat/completions`;
197
+ }
198
+
199
+ function normalizeMcpName(value) {
200
+ return String(value || '')
201
+ .trim()
202
+ .toLowerCase()
203
+ .replace(/[^a-z0-9_-]+/g, '_')
204
+ .replace(/^_+|_+$/g, '');
205
+ }
206
+
207
+ function buildMcpToolIdentifier(serverName, toolName) {
208
+ const server = normalizeMcpName(serverName) || 'mcp_server';
209
+ const tool = normalizeMcpName(toolName) || 'tool';
210
+ return `mcp_${server}_${tool}`;
211
+ }
212
+
213
+ function buildMcpToolDescription(serverName, tool) {
214
+ const parts = [];
215
+ if (serverName) parts.push(`[${serverName}]`);
216
+ if (tool?.annotations?.title) parts.push(tool.annotations.title);
217
+ else if (tool?.description) parts.push(tool.description);
218
+ else parts.push('MCP tool');
219
+ return parts.join(' ');
220
+ }
221
+
222
+ function extractContentText(blocks) {
223
+ if (!Array.isArray(blocks) || blocks.length === 0) return '';
224
+ const lines = [];
225
+ blocks.forEach((block) => {
226
+ if (!block || typeof block !== 'object') return;
227
+ switch (block.type) {
228
+ case 'text':
229
+ if (block.text) lines.push(block.text);
230
+ break;
231
+ case 'resource_link':
232
+ lines.push(`resource: ${block.uri || block.resourceId || '(unknown)'}`);
233
+ break;
234
+ case 'image':
235
+ lines.push(`image (${block.mimeType || 'image'}, ${approxSize(block.data)})`);
236
+ break;
237
+ case 'audio':
238
+ lines.push(`audio (${block.mimeType || 'audio'}, ${approxSize(block.data)})`);
239
+ break;
240
+ case 'resource':
241
+ lines.push('resource payload returned (use /mcp to inspect).');
242
+ break;
243
+ default:
244
+ lines.push(`[${block.type}]`);
245
+ break;
246
+ }
247
+ });
248
+ return lines.join('\n');
249
+ }
250
+
251
+ function approxSize(base64Text) {
252
+ if (!base64Text) return 'unknown size';
253
+ const bytes = Math.round((base64Text.length * 3) / 4);
254
+ if (bytes < 1024) return `${bytes}B`;
255
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
256
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
257
+ }
258
+
259
+ function formatMcpToolResult(serverName, toolName, result) {
260
+ const header = `[${serverName}/${toolName}]`;
261
+ if (!result) return `${header} tool returned no result.`;
262
+ if (result.isError) {
263
+ const errorText = extractContentText(result.content) || 'MCP tool failed.';
264
+ return `${header} ${errorText}`;
265
+ }
266
+ const segments = [];
267
+ const textBlock = extractContentText(result.content);
268
+ if (textBlock) segments.push(textBlock);
269
+ if (result.structuredContent && Object.keys(result.structuredContent).length > 0) {
270
+ segments.push(JSON.stringify(result.structuredContent, null, 2));
271
+ }
272
+ if (segments.length === 0) segments.push('Tool completed with no text output.');
273
+ return `${header}\n${segments.join('\n\n')}`;
274
+ }
275
+
276
+ async function listAllMcpTools(client) {
277
+ const collected = [];
278
+ let cursor = null;
279
+ do {
280
+ // eslint-disable-next-line no-await-in-loop
281
+ const result = await client.listTools(cursor ? { cursor } : undefined);
282
+ if (Array.isArray(result?.tools)) {
283
+ collected.push(...result.tools);
284
+ }
285
+ cursor = result?.nextCursor || null;
286
+ } while (cursor);
287
+ if (typeof client.cacheToolMetadata === 'function') {
288
+ client.cacheToolMetadata(collected);
289
+ }
290
+ return collected;
291
+ }
292
+
293
+ async function connectMcpServer(entry) {
294
+ if (!entry || typeof entry !== 'object') return null;
295
+ const serverName = normalizeText(entry.name) || 'mcp_server';
296
+ const env = { ...process.env };
297
+ if (!env.MODEL_CLI_SESSION_ROOT) env.MODEL_CLI_SESSION_ROOT = process.cwd();
298
+ if (!env.MODEL_CLI_WORKSPACE_ROOT) env.MODEL_CLI_WORKSPACE_ROOT = process.cwd();
299
+
300
+ if (entry.command) {
301
+ const client = new Client({ name: 'sandbox', version: '0.1.0' });
302
+ const transport = new StdioClientTransport({
303
+ command: entry.command,
304
+ args: Array.isArray(entry.args) ? entry.args : [],
305
+ cwd: entry.cwd || process.cwd(),
306
+ env,
307
+ stderr: 'pipe',
308
+ });
309
+ await client.connect(transport);
310
+ const tools = await listAllMcpTools(client);
311
+ return { serverName, client, transport, tools };
312
+ }
313
+
314
+ if (entry.url) {
315
+ const urlText = normalizeText(entry.url);
316
+ if (!urlText) return null;
317
+ const parsed = new URL(urlText);
318
+ if (parsed.protocol === 'ws:' || parsed.protocol === 'wss:') {
319
+ const client = new Client({ name: 'sandbox', version: '0.1.0' });
320
+ const transport = new WebSocketClientTransport(parsed);
321
+ await client.connect(transport);
322
+ const tools = await listAllMcpTools(client);
323
+ return { serverName, client, transport, tools };
324
+ }
325
+
326
+ const errors = [];
327
+ try {
328
+ const client = new Client({ name: 'sandbox', version: '0.1.0' });
329
+ const transport = new StreamableHTTPClientTransport(parsed);
330
+ await client.connect(transport);
331
+ const tools = await listAllMcpTools(client);
332
+ return { serverName, client, transport, tools };
333
+ } catch (err) {
334
+ errors.push(`streamable_http: ${err?.message || err}`);
335
+ }
336
+ try {
337
+ const client = new Client({ name: 'sandbox', version: '0.1.0' });
338
+ const transport = new SSEClientTransport(parsed);
339
+ await client.connect(transport);
340
+ const tools = await listAllMcpTools(client);
341
+ return { serverName, client, transport, tools };
342
+ } catch (err) {
343
+ errors.push(`sse: ${err?.message || err}`);
344
+ }
345
+ throw new Error(`Failed to connect MCP server (${serverName}): ${errors.join(' | ')}`);
346
+ }
347
+
348
+ return null;
349
+ }
350
+
351
+ function buildAppMcpEntry({ pluginDir, pluginId, app }) {
352
+ const mcp = app?.ai?.mcp && typeof app.ai.mcp === 'object' ? app.ai.mcp : null;
353
+ if (!mcp) return null;
354
+ if (mcp.enabled === false) return null;
355
+ const serverName = mcp?.name ? String(mcp.name).trim() : `${pluginId}.${app.id}`;
356
+ const command = normalizeText(mcp.command) || 'node';
357
+ const args = Array.isArray(mcp.args) ? mcp.args : [];
358
+ const entryRel = normalizeText(mcp.entry);
359
+ if (entryRel) {
360
+ const entryAbs = resolveInsideDir(pluginDir, entryRel);
361
+ return { name: serverName, command, args: [entryAbs, ...args], cwd: pluginDir };
362
+ }
363
+ const urlText = normalizeText(mcp.url);
364
+ if (urlText) {
365
+ return { name: serverName, url: urlText };
366
+ }
367
+ if (normalizeText(mcp.command)) {
368
+ return { name: serverName, command, args, cwd: pluginDir };
369
+ }
370
+ return null;
371
+ }
372
+
373
+ function readPromptSource(source, pluginDir) {
374
+ if (!source) return '';
375
+ if (typeof source === 'string') {
376
+ const rel = source.trim();
377
+ if (!rel) return '';
378
+ const abs = resolveInsideDir(pluginDir, rel);
379
+ if (!isFile(abs)) return '';
380
+ return fs.readFileSync(abs, 'utf8');
381
+ }
382
+ if (typeof source === 'object') {
383
+ const content = normalizeText(source?.content);
384
+ if (content) return content;
385
+ const rel = normalizeText(source?.path);
386
+ if (!rel) return '';
387
+ const abs = resolveInsideDir(pluginDir, rel);
388
+ if (!isFile(abs)) return '';
389
+ return fs.readFileSync(abs, 'utf8');
390
+ }
391
+ return '';
392
+ }
393
+
394
+ function resolveAppMcpPrompt(app, pluginDir) {
395
+ const prompt = app?.ai?.mcpPrompt;
396
+ if (!prompt) return '';
397
+ if (typeof prompt === 'string') {
398
+ return readPromptSource(prompt, pluginDir);
399
+ }
400
+ if (typeof prompt === 'object') {
401
+ const zh = readPromptSource(prompt.zh, pluginDir);
402
+ const en = readPromptSource(prompt.en, pluginDir);
403
+ return zh || en || '';
404
+ }
405
+ return '';
406
+ }
407
+
408
+ async function callOpenAiChat({ apiKey, baseUrl, model, messages, tools, signal }) {
409
+ const endpoint = resolveChatCompletionsUrl(baseUrl);
410
+ const payload = {
411
+ model,
412
+ messages,
413
+ stream: false,
414
+ };
415
+ if (Array.isArray(tools) && tools.length > 0) {
416
+ payload.tools = tools;
417
+ payload.tool_choice = 'auto';
418
+ }
419
+ const res = await fetch(endpoint, {
420
+ method: 'POST',
421
+ headers: {
422
+ 'content-type': 'application/json',
423
+ authorization: `Bearer ${apiKey}`,
424
+ },
425
+ body: JSON.stringify(payload),
426
+ signal,
427
+ });
428
+ if (!res.ok) {
429
+ const text = await res.text();
430
+ throw new Error(`LLM request failed (${res.status}): ${text || res.statusText}`);
431
+ }
432
+ return await res.json();
433
+ }
434
+
435
+ function sendJson(res, status, obj) {
436
+ const raw = JSON.stringify(obj);
437
+ res.writeHead(status, {
438
+ 'content-type': 'application/json; charset=utf-8',
439
+ 'cache-control': 'no-store',
440
+ });
441
+ res.end(raw);
442
+ }
443
+
444
+ function sendText(res, status, text, contentType) {
445
+ res.writeHead(status, {
446
+ 'content-type': contentType || 'text/plain; charset=utf-8',
447
+ 'cache-control': 'no-store',
448
+ });
449
+ res.end(text);
450
+ }
451
+
452
+ function readJsonBody(req) {
453
+ return new Promise((resolve, reject) => {
454
+ let body = '';
455
+ req.on('data', (chunk) => {
456
+ body += chunk;
457
+ });
458
+ req.on('end', () => {
459
+ if (!body) return resolve({});
460
+ try {
461
+ resolve(JSON.parse(body));
462
+ } catch (err) {
463
+ reject(err);
464
+ }
465
+ });
466
+ });
467
+ }
468
+
469
+ function guessContentType(filePath) {
470
+ const ext = path.extname(filePath).toLowerCase();
471
+ if (ext === '.html') return 'text/html; charset=utf-8';
472
+ if (ext === '.css') return 'text/css; charset=utf-8';
473
+ if (ext === '.mjs' || ext === '.js') return 'text/javascript; charset=utf-8';
474
+ if (ext === '.json') return 'application/json; charset=utf-8';
475
+ if (ext === '.md') return 'text/markdown; charset=utf-8';
476
+ if (ext === '.svg') return 'image/svg+xml';
477
+ if (ext === '.png') return 'image/png';
478
+ return 'application/octet-stream';
479
+ }
480
+
481
+ function serveStaticFile(res, filePath) {
482
+ if (!isFile(filePath)) return false;
483
+ const ct = guessContentType(filePath);
484
+ const buf = fs.readFileSync(filePath);
485
+ res.writeHead(200, { 'content-type': ct, 'cache-control': 'no-store' });
486
+ res.end(buf);
487
+ return true;
488
+ }
489
+
490
+ function startRecursiveWatcher(rootDir, onChange) {
491
+ const root = path.resolve(rootDir);
492
+ if (!isDirectory(root)) return () => {};
493
+
494
+ const watchers = new Map();
495
+
496
+ const shouldIgnore = (p) => {
497
+ const base = path.basename(p);
498
+ if (!base) return false;
499
+ if (base === 'node_modules') return true;
500
+ if (base === '.git') return true;
501
+ if (base === '.DS_Store') return true;
502
+ return false;
503
+ };
504
+
505
+ const scan = (dir) => {
506
+ const abs = path.resolve(dir);
507
+ if (!isDirectory(abs)) return;
508
+ if (shouldIgnore(abs)) return;
509
+ if (!watchers.has(abs)) {
510
+ try {
511
+ const w = fs.watch(abs, (eventType, filename) => {
512
+ const relName = filename ? String(filename) : '';
513
+ const filePath = relName ? path.join(abs, relName) : abs;
514
+ try {
515
+ onChange({ eventType, filePath });
516
+ } catch {
517
+ // ignore
518
+ }
519
+ scheduleRescan();
520
+ });
521
+ watchers.set(abs, w);
522
+ } catch {
523
+ // ignore
524
+ }
525
+ }
526
+
527
+ let entries = [];
528
+ try {
529
+ entries = fs.readdirSync(abs, { withFileTypes: true });
530
+ } catch {
531
+ return;
532
+ }
533
+ for (const ent of entries) {
534
+ if (!ent?.isDirectory?.()) continue;
535
+ const child = path.join(abs, ent.name);
536
+ if (shouldIgnore(child)) continue;
537
+ scan(child);
538
+ }
539
+ };
540
+
541
+ let rescanTimer = null;
542
+ const scheduleRescan = () => {
543
+ if (rescanTimer) return;
544
+ rescanTimer = setTimeout(() => {
545
+ rescanTimer = null;
546
+ scan(root);
547
+ }, 250);
548
+ };
549
+
550
+ scan(root);
551
+
552
+ return () => {
553
+ if (rescanTimer) {
554
+ try {
555
+ clearTimeout(rescanTimer);
556
+ } catch {
557
+ // ignore
558
+ }
559
+ rescanTimer = null;
560
+ }
561
+ for (const w of watchers.values()) {
562
+ try {
563
+ w.close();
564
+ } catch {
565
+ // ignore
566
+ }
567
+ }
568
+ watchers.clear();
569
+ };
570
+ }
571
+
572
+ function htmlPage() {
573
+ return `<!doctype html>
574
+ <html lang="zh-CN">
575
+ <head>
576
+ <meta charset="UTF-8" />
577
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
578
+ <title>ChatOS UI Apps Sandbox</title>
579
+ <style>
580
+ :root {
581
+ color-scheme: light;
582
+ --ds-accent: #00d4ff;
583
+ --ds-accent-2: #7c3aed;
584
+ --ds-panel-bg: rgba(255, 255, 255, 0.86);
585
+ --ds-panel-border: rgba(15, 23, 42, 0.08);
586
+ --ds-subtle-bg: rgba(255, 255, 255, 0.62);
587
+ --ds-selected-bg: linear-gradient(90deg, rgba(0, 212, 255, 0.14), rgba(124, 58, 237, 0.08));
588
+ --ds-focus-ring: rgba(0, 212, 255, 0.32);
589
+ --ds-nav-hover-bg: rgba(15, 23, 42, 0.06);
590
+ --ds-code-bg: #f7f9fb;
591
+ --ds-code-border: #eef2f7;
592
+ --sandbox-bg: #f5f7fb;
593
+ --sandbox-text: #111;
594
+ }
595
+ :root[data-theme='dark'] {
596
+ color-scheme: dark;
597
+ --ds-accent: #00d4ff;
598
+ --ds-accent-2: #a855f7;
599
+ --ds-panel-bg: rgba(17, 19, 28, 0.82);
600
+ --ds-panel-border: rgba(255, 255, 255, 0.14);
601
+ --ds-subtle-bg: rgba(255, 255, 255, 0.04);
602
+ --ds-selected-bg: linear-gradient(90deg, rgba(0, 212, 255, 0.18), rgba(168, 85, 247, 0.14));
603
+ --ds-focus-ring: rgba(0, 212, 255, 0.5);
604
+ --ds-nav-hover-bg: rgba(255, 255, 255, 0.08);
605
+ --ds-code-bg: #0d1117;
606
+ --ds-code-border: #30363d;
607
+ --sandbox-bg: #0f1115;
608
+ --sandbox-text: #eee;
609
+ }
610
+ body {
611
+ margin:0;
612
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
613
+ background: var(--sandbox-bg);
614
+ color: var(--sandbox-text);
615
+ }
616
+ #appRoot { height: 100vh; display:flex; flex-direction:column; }
617
+ #sandboxToolbar {
618
+ flex: 0 0 auto;
619
+ border-bottom: 1px solid var(--ds-panel-border);
620
+ padding: 10px 12px;
621
+ background: var(--ds-panel-bg);
622
+ }
623
+ #headerSlot {
624
+ flex: 0 0 auto;
625
+ border-bottom: 1px solid var(--ds-panel-border);
626
+ padding: 10px 12px;
627
+ background: var(--ds-panel-bg);
628
+ }
629
+ #container { flex: 1 1 auto; min-height:0; overflow:hidden; }
630
+ #containerInner { height:100%; overflow:auto; }
631
+ .muted { opacity: 0.7; font-size: 12px; }
632
+ .bar { display:flex; gap:10px; align-items:center; justify-content:space-between; }
633
+ .btn {
634
+ border:1px solid var(--ds-panel-border);
635
+ background: var(--ds-subtle-bg);
636
+ padding:6px 10px;
637
+ border-radius:10px;
638
+ cursor:pointer;
639
+ font-weight:650;
640
+ color: inherit;
641
+ }
642
+ .btn[data-active='1'] {
643
+ background: var(--ds-selected-bg);
644
+ box-shadow: 0 0 0 2px var(--ds-focus-ring);
645
+ }
646
+ .btn:active { transform: translateY(1px); }
647
+ #promptsPanel {
648
+ position: fixed;
649
+ right: 12px;
650
+ bottom: 12px;
651
+ width: 420px;
652
+ max-height: 70vh;
653
+ display:none;
654
+ flex-direction:column;
655
+ background: var(--ds-panel-bg);
656
+ color: inherit;
657
+ border:1px solid var(--ds-panel-border);
658
+ border-radius:14px;
659
+ overflow:hidden;
660
+ box-shadow: 0 18px 60px rgba(0,0,0,0.18);
661
+ }
662
+ #promptsPanelHeader { padding: 10px 12px; display:flex; align-items:center; justify-content:space-between; border-bottom: 1px solid var(--ds-panel-border); }
663
+ #promptsPanelBody { padding: 10px 12px; overflow:auto; display:flex; flex-direction:column; gap:10px; }
664
+ #promptsFab { position: fixed; right: 16px; bottom: 16px; width: 44px; height: 44px; border-radius: 999px; display:flex; align-items:center; justify-content:center; }
665
+ .card { border: 1px solid var(--ds-panel-border); border-radius: 12px; padding: 10px; background: var(--ds-panel-bg); }
666
+ .row { display:flex; gap:10px; }
667
+ .toolbar-group { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
668
+ .segmented { display:flex; gap:6px; align-items:center; }
669
+ #sandboxInspector {
670
+ position: fixed;
671
+ right: 12px;
672
+ top: 72px;
673
+ width: 360px;
674
+ max-height: 70vh;
675
+ display: none;
676
+ flex-direction: column;
677
+ background: var(--ds-panel-bg);
678
+ border: 1px solid var(--ds-panel-border);
679
+ border-radius: 12px;
680
+ overflow: hidden;
681
+ box-shadow: 0 14px 40px rgba(0,0,0,0.16);
682
+ z-index: 10;
683
+ }
684
+ #sandboxInspectorHeader {
685
+ padding: 10px 12px;
686
+ display:flex;
687
+ align-items:center;
688
+ justify-content: space-between;
689
+ border-bottom: 1px solid var(--ds-panel-border);
690
+ }
691
+ #sandboxInspectorBody {
692
+ padding: 10px 12px;
693
+ overflow: auto;
694
+ display: flex;
695
+ flex-direction: column;
696
+ gap: 10px;
697
+ }
698
+ #llmPanel {
699
+ position: fixed;
700
+ right: 12px;
701
+ top: 72px;
702
+ width: 420px;
703
+ max-height: 70vh;
704
+ display: none;
705
+ flex-direction: column;
706
+ background: var(--ds-panel-bg);
707
+ border: 1px solid var(--ds-panel-border);
708
+ border-radius: 12px;
709
+ overflow: hidden;
710
+ box-shadow: 0 14px 40px rgba(0,0,0,0.16);
711
+ z-index: 11;
712
+ }
713
+ #llmPanelHeader {
714
+ padding: 10px 12px;
715
+ display:flex;
716
+ align-items:center;
717
+ justify-content: space-between;
718
+ border-bottom: 1px solid var(--ds-panel-border);
719
+ }
720
+ #llmPanelBody {
721
+ padding: 10px 12px;
722
+ overflow: auto;
723
+ display: flex;
724
+ flex-direction: column;
725
+ gap: 10px;
726
+ }
727
+ #mcpPanel {
728
+ position: fixed;
729
+ right: 12px;
730
+ top: 72px;
731
+ width: 520px;
732
+ max-height: 70vh;
733
+ display: none;
734
+ flex-direction: column;
735
+ background: var(--ds-panel-bg);
736
+ border: 1px solid var(--ds-panel-border);
737
+ border-radius: 12px;
738
+ overflow: hidden;
739
+ box-shadow: 0 14px 40px rgba(0,0,0,0.16);
740
+ z-index: 12;
741
+ }
742
+ #mcpPanelHeader {
743
+ padding: 10px 12px;
744
+ display:flex;
745
+ align-items:center;
746
+ justify-content: space-between;
747
+ border-bottom: 1px solid var(--ds-panel-border);
748
+ }
749
+ #mcpPanelBody {
750
+ padding: 10px 12px;
751
+ overflow: auto;
752
+ display: flex;
753
+ flex-direction: column;
754
+ gap: 10px;
755
+ }
756
+ #mcpPaths {
757
+ max-height: 140px;
758
+ overflow: auto;
759
+ }
760
+ #mcpOutput {
761
+ max-height: 240px;
762
+ overflow: auto;
763
+ }
764
+ .section-title { font-size: 12px; font-weight: 700; opacity: 0.8; }
765
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; white-space: pre-wrap; }
766
+ input, textarea, select {
767
+ width:100%;
768
+ padding:8px;
769
+ border-radius:10px;
770
+ border:1px solid var(--ds-panel-border);
771
+ background: var(--ds-subtle-bg);
772
+ color: inherit;
773
+ }
774
+ textarea { min-height: 70px; resize: vertical; }
775
+ label { font-size: 12px; opacity: 0.8; }
776
+ .danger { border-color: rgba(255,0,0,0.35); }
777
+ </style>
778
+ </head>
779
+ <body>
780
+ <div id="appRoot">
781
+ <div id="sandboxToolbar">
782
+ <div class="bar">
783
+ <div>
784
+ <div style="font-weight:800">ChatOS UI Apps Sandbox</div>
785
+ <div class="muted">Host API mock · 模拟 module mount({ container, host, slots })</div>
786
+ </div>
787
+ <div class="row toolbar-group">
788
+ <span class="muted">Theme</span>
789
+ <div class="segmented" role="group" aria-label="Theme">
790
+ <button id="btnThemeLight" class="btn" type="button">Light</button>
791
+ <button id="btnThemeDark" class="btn" type="button">Dark</button>
792
+ <button id="btnThemeSystem" class="btn" type="button">System</button>
793
+ </div>
794
+ <div id="themeStatus" class="muted"></div>
795
+ <div id="sandboxContext" class="muted"></div>
796
+ <button id="btnLlmConfig" class="btn" type="button">AI Config</button>
797
+ <button id="btnMcpTest" class="btn" type="button">MCP Test</button>
798
+ <button id="btnInspectorToggle" class="btn" type="button">Inspect</button>
799
+ <button id="btnReload" class="btn" type="button">Reload</button>
800
+ </div>
801
+ </div>
802
+ </div>
803
+ <div id="headerSlot"></div>
804
+ <div id="container"><div id="containerInner"></div></div>
805
+ </div>
806
+
807
+ <button id="promptsFab" class="btn" type="button">:)</button>
808
+
809
+ <div id="promptsPanel">
810
+ <div id="promptsPanelHeader">
811
+ <div style="font-weight:800">UI Prompts</div>
812
+ <button id="promptsClose" class="btn" type="button">Close</button>
813
+ </div>
814
+ <div id="promptsPanelBody"></div>
815
+ </div>
816
+
817
+ <div id="llmPanel" aria-hidden="true">
818
+ <div id="llmPanelHeader">
819
+ <div style="font-weight:800">Sandbox LLM</div>
820
+ <div class="row">
821
+ <button id="btnLlmRefresh" class="btn" type="button">Refresh</button>
822
+ <button id="btnLlmClose" class="btn" type="button">Close</button>
823
+ </div>
824
+ </div>
825
+ <div id="llmPanelBody">
826
+ <div class="card">
827
+ <label for="llmApiKey">API Key</label>
828
+ <input id="llmApiKey" type="password" placeholder="sk-..." autocomplete="off" />
829
+ <div id="llmKeyStatus" class="muted"></div>
830
+ </div>
831
+ <div class="card">
832
+ <label for="llmBaseUrl">Base URL</label>
833
+ <input id="llmBaseUrl" type="text" placeholder="https://api.openai.com/v1" />
834
+ </div>
835
+ <div class="card">
836
+ <label for="llmModelId">Model ID</label>
837
+ <input id="llmModelId" type="text" placeholder="gpt-4o-mini" />
838
+ </div>
839
+ <div class="row">
840
+ <button id="btnLlmSave" class="btn" type="button">Save</button>
841
+ <button id="btnLlmClear" class="btn" type="button">Clear Key</button>
842
+ </div>
843
+ <div id="llmStatus" class="muted"></div>
844
+ </div>
845
+ </div>
846
+
847
+ <div id="mcpPanel" aria-hidden="true">
848
+ <div id="mcpPanelHeader">
849
+ <div style="font-weight:800">MCP Test</div>
850
+ <div class="row">
851
+ <button id="btnMcpClear" class="btn" type="button">Clear</button>
852
+ <button id="btnMcpClose" class="btn" type="button">Close</button>
853
+ </div>
854
+ </div>
855
+ <div id="mcpPanelBody">
856
+ <div class="card">
857
+ <div class="section-title">Paths</div>
858
+ <pre id="mcpPaths" class="mono"></pre>
859
+ <label for="mcpWorkdir">Workdir Override (optional)</label>
860
+ <input id="mcpWorkdir" type="text" placeholder="留空则使用默认 workdir" />
861
+ </div>
862
+ <div class="card">
863
+ <div class="section-title">Model</div>
864
+ <label for="mcpModelId">Model ID (optional)</label>
865
+ <input id="mcpModelId" type="text" placeholder="留空则使用 AI Config 中的 Model ID" />
866
+ <div id="mcpConfigHint" class="muted"></div>
867
+ </div>
868
+ <div class="card">
869
+ <label for="mcpSystem">System Prompt (optional)</label>
870
+ <textarea id="mcpSystem" placeholder="留空则使用 MCP Prompt / 空"></textarea>
871
+ </div>
872
+ <div class="card">
873
+ <label for="mcpMessage">User Message</label>
874
+ <textarea id="mcpMessage" placeholder="输入测试消息"></textarea>
875
+ <div class="row" style="margin-top:8px; align-items:center; justify-content:space-between;">
876
+ <label style="display:flex; gap:6px; align-items:center;">
877
+ <input id="mcpDisableTools" type="checkbox" style="width:auto;" />
878
+ Disable tools
879
+ </label>
880
+ <button id="btnMcpSend" class="btn" type="button">Send</button>
881
+ </div>
882
+ <div id="mcpStatus" class="muted"></div>
883
+ </div>
884
+ <div class="card">
885
+ <div class="section-title">Output</div>
886
+ <pre id="mcpOutput" class="mono"></pre>
887
+ </div>
888
+ </div>
889
+ </div>
890
+
891
+ <div id="sandboxInspector" aria-hidden="true">
892
+ <div id="sandboxInspectorHeader">
893
+ <div style="font-weight:800">Sandbox Inspector</div>
894
+ <div class="row">
895
+ <button id="btnInspectorRefresh" class="btn" type="button">Refresh</button>
896
+ <button id="btnInspectorClose" class="btn" type="button">Close</button>
897
+ </div>
898
+ </div>
899
+ <div id="sandboxInspectorBody">
900
+ <div>
901
+ <div class="section-title">Host Context</div>
902
+ <pre id="inspectorContext" class="mono"></pre>
903
+ </div>
904
+ <div>
905
+ <div class="section-title">Theme</div>
906
+ <pre id="inspectorTheme" class="mono"></pre>
907
+ </div>
908
+ <div>
909
+ <div class="section-title">Tokens</div>
910
+ <pre id="inspectorTokens" class="mono"></pre>
911
+ </div>
912
+ </div>
913
+ </div>
914
+
915
+ <script type="module" src="/sandbox.mjs"></script>
916
+ </body>
917
+ </html>`;
918
+ }
919
+
920
+ function sandboxClientJs() {
921
+ return `const $ = (sel) => document.querySelector(sel);
922
+
923
+ const container = $('#containerInner');
924
+ const headerSlot = $('#headerSlot');
925
+ const fab = $('#promptsFab');
926
+ const panel = $('#promptsPanel');
927
+ const panelBody = $('#promptsPanelBody');
928
+ const panelClose = $('#promptsClose');
929
+ const btnThemeLight = $('#btnThemeLight');
930
+ const btnThemeDark = $('#btnThemeDark');
931
+ const btnThemeSystem = $('#btnThemeSystem');
932
+ const themeStatus = $('#themeStatus');
933
+ const sandboxContext = $('#sandboxContext');
934
+ const btnInspectorToggle = $('#btnInspectorToggle');
935
+ const sandboxInspector = $('#sandboxInspector');
936
+ const btnInspectorClose = $('#btnInspectorClose');
937
+ const btnInspectorRefresh = $('#btnInspectorRefresh');
938
+ const inspectorContext = $('#inspectorContext');
939
+ const inspectorTheme = $('#inspectorTheme');
940
+ const inspectorTokens = $('#inspectorTokens');
941
+ const btnLlmConfig = $('#btnLlmConfig');
942
+ const llmPanel = $('#llmPanel');
943
+ const btnLlmClose = $('#btnLlmClose');
944
+ const btnLlmRefresh = $('#btnLlmRefresh');
945
+ const btnLlmSave = $('#btnLlmSave');
946
+ const btnLlmClear = $('#btnLlmClear');
947
+ const llmApiKey = $('#llmApiKey');
948
+ const llmBaseUrl = $('#llmBaseUrl');
949
+ const llmModelId = $('#llmModelId');
950
+ const llmStatus = $('#llmStatus');
951
+ const llmKeyStatus = $('#llmKeyStatus');
952
+ const btnMcpTest = $('#btnMcpTest');
953
+ const mcpPanel = $('#mcpPanel');
954
+ const btnMcpClose = $('#btnMcpClose');
955
+ const btnMcpClear = $('#btnMcpClear');
956
+ const btnMcpSend = $('#btnMcpSend');
957
+ const mcpPaths = $('#mcpPaths');
958
+ const mcpWorkdir = $('#mcpWorkdir');
959
+ const mcpModelId = $('#mcpModelId');
960
+ const mcpSystem = $('#mcpSystem');
961
+ const mcpMessage = $('#mcpMessage');
962
+ const mcpDisableTools = $('#mcpDisableTools');
963
+ const mcpStatus = $('#mcpStatus');
964
+ const mcpOutput = $('#mcpOutput');
965
+ const mcpConfigHint = $('#mcpConfigHint');
966
+
967
+ const setPanelOpen = (open) => { panel.style.display = open ? 'flex' : 'none'; };
968
+ fab.addEventListener('click', () => setPanelOpen(panel.style.display !== 'flex'));
969
+ panelClose.addEventListener('click', () => setPanelOpen(false));
970
+ window.addEventListener('chatos:uiPrompts:open', () => setPanelOpen(true));
971
+ window.addEventListener('chatos:uiPrompts:close', () => setPanelOpen(false));
972
+ window.addEventListener('chatos:uiPrompts:toggle', () => setPanelOpen(panel.style.display !== 'flex'));
973
+
974
+ const THEME_STORAGE_KEY = 'chatos:sandbox:theme-mode';
975
+ const themeListeners = new Set();
976
+ const themeButtons = [
977
+ { mode: 'light', el: btnThemeLight },
978
+ { mode: 'dark', el: btnThemeDark },
979
+ { mode: 'system', el: btnThemeSystem },
980
+ ];
981
+ const systemQuery = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
982
+
983
+ const normalizeThemeMode = (mode) => (mode === 'light' || mode === 'dark' || mode === 'system' ? mode : 'system');
984
+
985
+ const loadThemeMode = () => {
986
+ try {
987
+ return normalizeThemeMode(String(localStorage.getItem(THEME_STORAGE_KEY) || ''));
988
+ } catch {
989
+ return 'system';
990
+ }
991
+ };
992
+
993
+ let themeMode = loadThemeMode();
994
+ let currentTheme = 'light';
995
+ let inspectorEnabled = false;
996
+ let inspectorTimer = null;
997
+
998
+ const resolveTheme = () => {
999
+ if (themeMode === 'light' || themeMode === 'dark') return themeMode;
1000
+ return systemQuery && systemQuery.matches ? 'dark' : 'light';
1001
+ };
1002
+
1003
+ const emitThemeChange = (theme) => {
1004
+ for (const fn of themeListeners) { try { fn(theme); } catch {} }
1005
+ };
1006
+
1007
+ const updateThemeControls = () => {
1008
+ for (const { mode, el } of themeButtons) {
1009
+ if (!el) continue;
1010
+ const active = mode === themeMode;
1011
+ el.dataset.active = active ? '1' : '0';
1012
+ el.setAttribute('aria-pressed', active ? 'true' : 'false');
1013
+ }
1014
+ if (themeStatus) {
1015
+ themeStatus.textContent = themeMode === 'system' ? 'system -> ' + currentTheme : currentTheme;
1016
+ }
1017
+ };
1018
+
1019
+ const updateContextStatus = () => {
1020
+ if (!sandboxContext) return;
1021
+ sandboxContext.textContent = __SANDBOX__.pluginId + ':' + __SANDBOX__.appId;
1022
+ };
1023
+
1024
+ const isInspectorOpen = () => sandboxInspector && sandboxInspector.style.display === 'flex';
1025
+ const isLlmPanelOpen = () => llmPanel && llmPanel.style.display === 'flex';
1026
+ const isMcpPanelOpen = () => mcpPanel && mcpPanel.style.display === 'flex';
1027
+
1028
+ const setLlmStatus = (text, isError) => {
1029
+ if (!llmStatus) return;
1030
+ llmStatus.textContent = text || '';
1031
+ llmStatus.style.color = isError ? '#ef4444' : '';
1032
+ };
1033
+
1034
+ const refreshLlmConfig = async () => {
1035
+ try {
1036
+ setLlmStatus('Loading...');
1037
+ const r = await fetch('/api/sandbox/llm-config');
1038
+ const j = await r.json();
1039
+ if (!j?.ok) throw new Error(j?.message || 'Failed to load config');
1040
+ const cfg = j?.config || {};
1041
+ if (llmBaseUrl) llmBaseUrl.value = cfg.baseUrl || '';
1042
+ if (llmModelId) llmModelId.value = cfg.modelId || '';
1043
+ if (llmKeyStatus) llmKeyStatus.textContent = cfg.hasApiKey ? 'API key set' : 'API key missing';
1044
+ setLlmStatus('');
1045
+ } catch (err) {
1046
+ setLlmStatus(err?.message || String(err), true);
1047
+ }
1048
+ };
1049
+
1050
+ const saveLlmConfig = async ({ clearKey } = {}) => {
1051
+ try {
1052
+ setLlmStatus('Saving...');
1053
+ const payload = {
1054
+ baseUrl: llmBaseUrl ? llmBaseUrl.value : '',
1055
+ modelId: llmModelId ? llmModelId.value : '',
1056
+ };
1057
+ const apiKey = llmApiKey ? llmApiKey.value : '';
1058
+ if (clearKey) {
1059
+ payload.apiKey = '';
1060
+ } else if (apiKey && apiKey.trim()) {
1061
+ payload.apiKey = apiKey.trim();
1062
+ }
1063
+ const r = await fetch('/api/sandbox/llm-config', {
1064
+ method: 'POST',
1065
+ headers: { 'content-type': 'application/json' },
1066
+ body: JSON.stringify(payload),
1067
+ });
1068
+ const j = await r.json();
1069
+ if (!j?.ok) throw new Error(j?.message || 'Failed to save config');
1070
+ if (llmApiKey) llmApiKey.value = '';
1071
+ await refreshLlmConfig();
1072
+ setLlmStatus('Saved');
1073
+ } catch (err) {
1074
+ setLlmStatus(err?.message || String(err), true);
1075
+ }
1076
+ };
1077
+
1078
+ const setLlmPanelOpen = (open) => {
1079
+ if (!llmPanel) return;
1080
+ llmPanel.style.display = open ? 'flex' : 'none';
1081
+ llmPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
1082
+ if (open) refreshLlmConfig();
1083
+ };
1084
+
1085
+ const formatJson = (value) => {
1086
+ try {
1087
+ return JSON.stringify(value, null, 2);
1088
+ } catch {
1089
+ return String(value);
1090
+ }
1091
+ };
1092
+
1093
+ const sandboxPaths = __SANDBOX__.paths || {};
1094
+
1095
+ const setMcpStatus = (text, isError) => {
1096
+ if (!mcpStatus) return;
1097
+ mcpStatus.textContent = text || '';
1098
+ mcpStatus.style.color = isError ? '#ef4444' : '';
1099
+ };
1100
+
1101
+ const appendMcpOutput = (label, payload) => {
1102
+ if (!mcpOutput) return;
1103
+ const ts = new Date().toISOString();
1104
+ const content = typeof payload === 'string' ? payload : formatJson(payload);
1105
+ mcpOutput.textContent += '[' + ts + '] ' + label + '\\n' + content + '\\n\\n';
1106
+ mcpOutput.scrollTop = mcpOutput.scrollHeight;
1107
+ };
1108
+
1109
+ const refreshMcpPaths = () => {
1110
+ if (!mcpPaths) return;
1111
+ mcpPaths.textContent = formatJson(sandboxPaths);
1112
+ };
1113
+
1114
+ const refreshMcpConfigHint = async () => {
1115
+ if (!mcpConfigHint) return;
1116
+ try {
1117
+ mcpConfigHint.textContent = 'Loading...';
1118
+ const r = await fetch('/api/sandbox/llm-config');
1119
+ const j = await r.json();
1120
+ if (!j?.ok) throw new Error(j?.message || 'Failed to load config');
1121
+ const cfg = j?.config || {};
1122
+ const modelText = cfg.modelId ? 'model=' + cfg.modelId : 'model=unset';
1123
+ const baseText = cfg.baseUrl ? 'base=' + cfg.baseUrl : 'base=default';
1124
+ const keyText = cfg.hasApiKey ? 'key=ok' : 'key=missing';
1125
+ mcpConfigHint.textContent = modelText + ' · ' + baseText + ' · ' + keyText;
1126
+ } catch (err) {
1127
+ mcpConfigHint.textContent = err?.message || String(err);
1128
+ }
1129
+ };
1130
+
1131
+ const setMcpPanelOpen = (open) => {
1132
+ if (!mcpPanel) return;
1133
+ mcpPanel.style.display = open ? 'flex' : 'none';
1134
+ mcpPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
1135
+ if (open) {
1136
+ refreshMcpConfigHint();
1137
+ refreshMcpPaths();
1138
+ }
1139
+ };
1140
+
1141
+ const runMcpTest = async () => {
1142
+ const sendBtn = btnMcpSend;
1143
+ try {
1144
+ const message = mcpMessage ? String(mcpMessage.value || '').trim() : '';
1145
+ if (!message) {
1146
+ setMcpStatus('Message is required.', true);
1147
+ return;
1148
+ }
1149
+ if (sendBtn) sendBtn.disabled = true;
1150
+ const payload = {
1151
+ messages: [{ role: 'user', text: message }],
1152
+ modelId: mcpModelId ? String(mcpModelId.value || '').trim() : '',
1153
+ systemPrompt: mcpSystem ? String(mcpSystem.value || '').trim() : '',
1154
+ disableTools: Boolean(mcpDisableTools?.checked),
1155
+ };
1156
+ const workdirOverride = mcpWorkdir ? String(mcpWorkdir.value || '').trim() : '';
1157
+ if (workdirOverride) {
1158
+ payload.callMeta = { workdir: workdirOverride };
1159
+ }
1160
+ setMcpStatus('Sending...');
1161
+ appendMcpOutput('request', payload);
1162
+ const r = await fetch('/api/llm/chat', {
1163
+ method: 'POST',
1164
+ headers: { 'content-type': 'application/json' },
1165
+ body: JSON.stringify(payload),
1166
+ });
1167
+ const j = await r.json();
1168
+ if (!j?.ok) throw new Error(j?.message || 'MCP test failed');
1169
+ appendMcpOutput('assistant', j?.content || '');
1170
+ if (Array.isArray(j?.toolTrace) && j.toolTrace.length > 0) {
1171
+ appendMcpOutput('toolTrace', j.toolTrace);
1172
+ }
1173
+ setMcpStatus('Done');
1174
+ } catch (err) {
1175
+ setMcpStatus(err?.message || String(err), true);
1176
+ } finally {
1177
+ if (sendBtn) sendBtn.disabled = false;
1178
+ }
1179
+ };
1180
+
1181
+ const tokenNameList = Array.isArray(__SANDBOX__.tokenNames) ? __SANDBOX__.tokenNames : [];
1182
+
1183
+ const collectTokens = () => {
1184
+ const style = getComputedStyle(document.documentElement);
1185
+ const names = new Set(tokenNameList);
1186
+ for (let i = 0; i < style.length; i += 1) {
1187
+ const name = style[i];
1188
+ if (name && name.startsWith('--ds-')) names.add(name);
1189
+ }
1190
+ return [...names]
1191
+ .sort()
1192
+ .map((name) => {
1193
+ const value = style.getPropertyValue(name).trim();
1194
+ return name + ': ' + (value || '(unset)');
1195
+ })
1196
+ .join('\\n');
1197
+ };
1198
+
1199
+ const readHostContext = () => {
1200
+ if (!inspectorEnabled) return null;
1201
+ if (typeof host?.context?.get === 'function') return host.context.get();
1202
+ return { pluginId: __SANDBOX__.pluginId, appId: __SANDBOX__.appId, theme: currentTheme, bridge: { enabled: true } };
1203
+ };
1204
+
1205
+ const readThemeInfo = () => ({
1206
+ themeMode,
1207
+ currentTheme,
1208
+ dataTheme: document.documentElement.dataset.theme || '',
1209
+ dataThemeMode: document.documentElement.dataset.themeMode || '',
1210
+ prefersColorScheme: systemQuery ? (systemQuery.matches ? 'dark' : 'light') : 'unknown',
1211
+ });
1212
+
1213
+ const updateInspector = () => {
1214
+ if (!inspectorEnabled) return;
1215
+ if (inspectorContext) inspectorContext.textContent = formatJson(readHostContext());
1216
+ if (inspectorTheme) inspectorTheme.textContent = formatJson(readThemeInfo());
1217
+ if (inspectorTokens) inspectorTokens.textContent = collectTokens();
1218
+ };
1219
+
1220
+ const startInspectorTimer = () => {
1221
+ if (inspectorTimer) return;
1222
+ inspectorTimer = setInterval(updateInspector, 1000);
1223
+ };
1224
+
1225
+ const stopInspectorTimer = () => {
1226
+ if (!inspectorTimer) return;
1227
+ clearInterval(inspectorTimer);
1228
+ inspectorTimer = null;
1229
+ };
1230
+
1231
+ const setInspectorOpen = (open) => {
1232
+ if (!sandboxInspector) return;
1233
+ sandboxInspector.style.display = open ? 'flex' : 'none';
1234
+ sandboxInspector.setAttribute('aria-hidden', open ? 'false' : 'true');
1235
+ if (open) {
1236
+ updateInspector();
1237
+ startInspectorTimer();
1238
+ } else {
1239
+ stopInspectorTimer();
1240
+ }
1241
+ };
1242
+
1243
+ const updateInspectorIfOpen = () => {
1244
+ if (!inspectorEnabled) return;
1245
+ if (isInspectorOpen()) updateInspector();
1246
+ };
1247
+
1248
+ const applyThemeMode = (mode, { persist = true } = {}) => {
1249
+ themeMode = normalizeThemeMode(mode);
1250
+ if (persist) {
1251
+ try {
1252
+ localStorage.setItem(THEME_STORAGE_KEY, themeMode);
1253
+ } catch {
1254
+ // ignore
1255
+ }
1256
+ }
1257
+ const nextTheme = resolveTheme();
1258
+ const prevTheme = currentTheme;
1259
+ currentTheme = nextTheme;
1260
+ document.documentElement.dataset.theme = nextTheme;
1261
+ document.documentElement.dataset.themeMode = themeMode;
1262
+ updateThemeControls();
1263
+ updateInspectorIfOpen();
1264
+ if (nextTheme !== prevTheme) emitThemeChange(nextTheme);
1265
+ };
1266
+
1267
+ if (systemQuery && typeof systemQuery.addEventListener === 'function') {
1268
+ systemQuery.addEventListener('change', () => {
1269
+ if (themeMode === 'system') applyThemeMode('system', { persist: false });
1270
+ });
1271
+ }
1272
+
1273
+ if (btnThemeLight) btnThemeLight.addEventListener('click', () => applyThemeMode('light'));
1274
+ if (btnThemeDark) btnThemeDark.addEventListener('click', () => applyThemeMode('dark'));
1275
+ if (btnThemeSystem) btnThemeSystem.addEventListener('click', () => applyThemeMode('system'));
1276
+ if (btnLlmConfig) btnLlmConfig.addEventListener('click', () => setLlmPanelOpen(!isLlmPanelOpen()));
1277
+ if (btnLlmClose) btnLlmClose.addEventListener('click', () => setLlmPanelOpen(false));
1278
+ if (btnLlmRefresh) btnLlmRefresh.addEventListener('click', () => refreshLlmConfig());
1279
+ if (btnLlmSave) btnLlmSave.addEventListener('click', () => saveLlmConfig());
1280
+ if (btnLlmClear) btnLlmClear.addEventListener('click', () => saveLlmConfig({ clearKey: true }));
1281
+ if (btnMcpTest) btnMcpTest.addEventListener('click', () => setMcpPanelOpen(!isMcpPanelOpen()));
1282
+ if (btnMcpClose) btnMcpClose.addEventListener('click', () => setMcpPanelOpen(false));
1283
+ if (btnMcpClear)
1284
+ btnMcpClear.addEventListener('click', () => {
1285
+ if (mcpOutput) mcpOutput.textContent = '';
1286
+ setMcpStatus('');
1287
+ });
1288
+ if (btnMcpSend)
1289
+ btnMcpSend.addEventListener('click', () => {
1290
+ runMcpTest();
1291
+ });
1292
+ if (btnInspectorToggle) btnInspectorToggle.addEventListener('click', () => setInspectorOpen(!isInspectorOpen()));
1293
+ if (btnInspectorClose) btnInspectorClose.addEventListener('click', () => setInspectorOpen(false));
1294
+ if (btnInspectorRefresh) btnInspectorRefresh.addEventListener('click', () => updateInspector());
1295
+
1296
+ applyThemeMode(themeMode || 'system', { persist: false });
1297
+ updateContextStatus();
1298
+
1299
+ const entries = [];
1300
+ const listeners = new Set();
1301
+ const emitUpdate = () => {
1302
+ const payload = { path: '(sandbox)', entries: [...entries] };
1303
+ for (const fn of listeners) { try { fn(payload); } catch {} }
1304
+ renderPrompts();
1305
+ };
1306
+
1307
+ const uuid = () => (globalThis.crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + Math.random().toString(16).slice(2));
1308
+
1309
+ function renderPrompts() {
1310
+ panelBody.textContent = '';
1311
+ const pending = new Map();
1312
+ for (const e of entries) {
1313
+ if (e?.type !== 'ui_prompt') continue;
1314
+ const id = String(e?.requestId || '');
1315
+ if (!id) continue;
1316
+ if (e.action === 'request') pending.set(id, e);
1317
+ if (e.action === 'response') pending.delete(id);
1318
+ }
1319
+
1320
+ if (pending.size === 0) {
1321
+ const empty = document.createElement('div');
1322
+ empty.className = 'muted';
1323
+ empty.textContent = '暂无待办(request 后会出现在这里)';
1324
+ panelBody.appendChild(empty);
1325
+ return;
1326
+ }
1327
+
1328
+ for (const [requestId, req] of pending.entries()) {
1329
+ const card = document.createElement('div');
1330
+ card.className = 'card';
1331
+
1332
+ const title = document.createElement('div');
1333
+ title.style.fontWeight = '800';
1334
+ title.textContent = req?.prompt?.title || '(untitled)';
1335
+
1336
+ const msg = document.createElement('div');
1337
+ msg.className = 'muted';
1338
+ msg.style.marginTop = '6px';
1339
+ msg.textContent = req?.prompt?.message || '';
1340
+
1341
+ const source = document.createElement('div');
1342
+ source.className = 'muted';
1343
+ source.style.marginTop = '6px';
1344
+ source.textContent = req?.prompt?.source ? String(req.prompt.source) : '';
1345
+
1346
+ const form = document.createElement('div');
1347
+ form.style.marginTop = '10px';
1348
+ form.style.display = 'grid';
1349
+ form.style.gap = '10px';
1350
+
1351
+ const kind = String(req?.prompt?.kind || '');
1352
+
1353
+ const mkBtn = (label, danger) => {
1354
+ const btn = document.createElement('button');
1355
+ btn.type = 'button';
1356
+ btn.className = 'btn' + (danger ? ' danger' : '');
1357
+ btn.textContent = label;
1358
+ return btn;
1359
+ };
1360
+
1361
+ const submit = async (response) => {
1362
+ entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'response', requestId, response });
1363
+ emitUpdate();
1364
+ };
1365
+
1366
+ if (kind === 'kv') {
1367
+ const fields = Array.isArray(req?.prompt?.fields) ? req.prompt.fields : [];
1368
+ const values = {};
1369
+ for (const f of fields) {
1370
+ const key = String(f?.key || '');
1371
+ if (!key) continue;
1372
+ const wrap = document.createElement('div');
1373
+ const lab = document.createElement('label');
1374
+ lab.textContent = f?.label ? String(f.label) : key;
1375
+ const input = document.createElement(f?.multiline ? 'textarea' : 'input');
1376
+ input.placeholder = f?.placeholder ? String(f.placeholder) : '';
1377
+ input.value = f?.default ? String(f.default) : '';
1378
+ input.addEventListener('input', () => { values[key] = String(input.value || ''); });
1379
+ values[key] = String(input.value || '');
1380
+ wrap.appendChild(lab);
1381
+ wrap.appendChild(input);
1382
+ form.appendChild(wrap);
1383
+ }
1384
+ const row = document.createElement('div');
1385
+ row.className = 'row';
1386
+ const ok = mkBtn('Submit');
1387
+ ok.addEventListener('click', () => submit({ status: 'ok', values }));
1388
+ const cancel = mkBtn('Cancel', true);
1389
+ cancel.addEventListener('click', () => submit({ status: 'cancel' }));
1390
+ row.appendChild(ok);
1391
+ row.appendChild(cancel);
1392
+ form.appendChild(row);
1393
+ } else if (kind === 'choice') {
1394
+ const options = Array.isArray(req?.prompt?.options) ? req.prompt.options : [];
1395
+ const multiple = Boolean(req?.prompt?.multiple);
1396
+ const selected = new Set();
1397
+ const wrap = document.createElement('div');
1398
+ const lab = document.createElement('label');
1399
+ lab.textContent = '选择';
1400
+ const select = document.createElement('select');
1401
+ if (multiple) select.multiple = true;
1402
+ for (const opt of options) {
1403
+ const v = String(opt?.value || '');
1404
+ const o = document.createElement('option');
1405
+ o.value = v;
1406
+ o.textContent = opt?.label ? String(opt.label) : v;
1407
+ select.appendChild(o);
1408
+ }
1409
+ select.addEventListener('change', () => {
1410
+ selected.clear();
1411
+ for (const o of select.selectedOptions) selected.add(String(o.value));
1412
+ });
1413
+ wrap.appendChild(lab);
1414
+ wrap.appendChild(select);
1415
+ form.appendChild(wrap);
1416
+ const row = document.createElement('div');
1417
+ row.className = 'row';
1418
+ const ok = mkBtn('Submit');
1419
+ ok.addEventListener('click', () => submit({ status: 'ok', value: multiple ? Array.from(selected) : Array.from(selected)[0] || '' }));
1420
+ const cancel = mkBtn('Cancel', true);
1421
+ cancel.addEventListener('click', () => submit({ status: 'cancel' }));
1422
+ row.appendChild(ok);
1423
+ row.appendChild(cancel);
1424
+ form.appendChild(row);
1425
+ } else {
1426
+ const row = document.createElement('div');
1427
+ row.className = 'row';
1428
+ const ok = mkBtn('OK');
1429
+ ok.addEventListener('click', () => submit({ status: 'ok' }));
1430
+ const cancel = mkBtn('Cancel', true);
1431
+ cancel.addEventListener('click', () => submit({ status: 'cancel' }));
1432
+ row.appendChild(ok);
1433
+ row.appendChild(cancel);
1434
+ form.appendChild(row);
1435
+ }
1436
+
1437
+ card.appendChild(title);
1438
+ if (msg.textContent) card.appendChild(msg);
1439
+ if (source.textContent) card.appendChild(source);
1440
+ card.appendChild(form);
1441
+ panelBody.appendChild(card);
1442
+ }
1443
+ }
1444
+
1445
+ const buildChatMessages = (list) => {
1446
+ const out = [];
1447
+ if (!Array.isArray(list)) return out;
1448
+ for (const msg of list) {
1449
+ const role = String(msg?.role || '').trim();
1450
+ const text = typeof msg?.text === 'string' ? msg.text : '';
1451
+ if (!text || !text.trim()) continue;
1452
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
1453
+ out.push({ role, text });
1454
+ }
1455
+ return out;
1456
+ };
1457
+
1458
+ const callSandboxChat = async (payload, signal) => {
1459
+ const r = await fetch('/api/llm/chat', {
1460
+ method: 'POST',
1461
+ headers: { 'content-type': 'application/json' },
1462
+ body: JSON.stringify(payload || {}),
1463
+ signal,
1464
+ });
1465
+ const j = await r.json();
1466
+ if (!j?.ok) throw new Error(j?.message || 'Sandbox LLM call failed');
1467
+ return j;
1468
+ };
1469
+
1470
+ const getTheme = () => currentTheme || resolveTheme();
1471
+
1472
+ const host = {
1473
+ bridge: { enabled: true },
1474
+ context: { get: () => ({ pluginId: __SANDBOX__.pluginId, appId: __SANDBOX__.appId, theme: getTheme(), bridge: { enabled: true } }) },
1475
+ theme: {
1476
+ get: getTheme,
1477
+ onChange: (listener) => {
1478
+ if (typeof listener !== 'function') return () => {};
1479
+ themeListeners.add(listener);
1480
+ return () => themeListeners.delete(listener);
1481
+ },
1482
+ },
1483
+ admin: {
1484
+ state: async () => ({ ok: true, state: {} }),
1485
+ onUpdate: () => () => {},
1486
+ models: { list: async () => ({ ok: true, models: [] }) },
1487
+ secrets: { list: async () => ({ ok: true, secrets: [] }) },
1488
+ },
1489
+ registry: {
1490
+ list: async () => ({ ok: true, apps: [__SANDBOX__.registryApp] }),
1491
+ },
1492
+ backend: {
1493
+ invoke: async (method, params) => {
1494
+ const r = await fetch('/api/backend/invoke', {
1495
+ method: 'POST',
1496
+ headers: { 'content-type': 'application/json' },
1497
+ body: JSON.stringify({ method, params }),
1498
+ });
1499
+ const j = await r.json();
1500
+ if (j?.ok === false) throw new Error(j?.message || 'invoke failed');
1501
+ return j?.result;
1502
+ },
1503
+ },
1504
+ uiPrompts: {
1505
+ read: async () => ({ path: '(sandbox)', entries: [...entries] }),
1506
+ onUpdate: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
1507
+ request: async (payload) => {
1508
+ const requestId = payload?.requestId ? String(payload.requestId) : uuid();
1509
+ const prompt = payload?.prompt && typeof payload.prompt === 'object' ? { ...payload.prompt } : null;
1510
+ if (prompt && !prompt.source) prompt.source = __SANDBOX__.pluginId + ':' + __SANDBOX__.appId;
1511
+ entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'request', requestId, runId: payload?.runId, prompt });
1512
+ emitUpdate();
1513
+ return { ok: true, requestId };
1514
+ },
1515
+ respond: async (payload) => {
1516
+ const requestId = String(payload?.requestId || '');
1517
+ if (!requestId) throw new Error('requestId is required');
1518
+ const response = payload?.response && typeof payload.response === 'object' ? payload.response : null;
1519
+ entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'response', requestId, runId: payload?.runId, response });
1520
+ emitUpdate();
1521
+ return { ok: true };
1522
+ },
1523
+ open: () => (setPanelOpen(true), { ok: true }),
1524
+ close: () => (setPanelOpen(false), { ok: true }),
1525
+ toggle: () => (setPanelOpen(panel.style.display !== 'flex'), { ok: true }),
1526
+ },
1527
+ ui: { navigate: (menu) => ({ ok: true, menu }) },
1528
+ chat: (() => {
1529
+ const clone = (v) => JSON.parse(JSON.stringify(v));
1530
+
1531
+ const agents = [
1532
+ {
1533
+ id: 'sandbox-agent',
1534
+ name: 'Sandbox Agent',
1535
+ description: 'Mock agent for ChatOS UI Apps Sandbox',
1536
+ },
1537
+ ];
1538
+
1539
+ const sessions = new Map();
1540
+ const defaultSessionByAgent = new Map();
1541
+ const messagesBySession = new Map();
1542
+
1543
+ const listeners = new Set();
1544
+ const activeRuns = new Map(); // sessionId -> { aborted: boolean, timers: number[] }
1545
+
1546
+ const emit = (payload) => {
1547
+ for (const sub of listeners) {
1548
+ const filter = sub?.filter && typeof sub.filter === 'object' ? sub.filter : {};
1549
+ if (filter?.sessionId && String(filter.sessionId) !== String(payload?.sessionId || '')) continue;
1550
+ if (Array.isArray(filter?.types) && filter.types.length > 0) {
1551
+ const t = String(payload?.type || '');
1552
+ if (!filter.types.includes(t)) continue;
1553
+ }
1554
+ try {
1555
+ sub.fn(payload);
1556
+ } catch {
1557
+ // ignore
1558
+ }
1559
+ }
1560
+ };
1561
+
1562
+ const ensureAgent = async () => {
1563
+ if (agents.length > 0) return agents[0];
1564
+ const created = { id: 'sandbox-agent', name: 'Sandbox Agent', description: 'Mock agent' };
1565
+ agents.push(created);
1566
+ return created;
1567
+ };
1568
+
1569
+ const ensureSession = async (agentId) => {
1570
+ const aid = String(agentId || '').trim() || (await ensureAgent()).id;
1571
+ const existingId = defaultSessionByAgent.get(aid);
1572
+ if (existingId && sessions.has(existingId)) return sessions.get(existingId);
1573
+
1574
+ const id = 'sandbox-session-' + uuid();
1575
+ const session = { id, agentId: aid, createdAt: new Date().toISOString() };
1576
+ sessions.set(id, session);
1577
+ defaultSessionByAgent.set(aid, id);
1578
+ if (!messagesBySession.has(id)) messagesBySession.set(id, []);
1579
+ return session;
1580
+ };
1581
+
1582
+ const agentsApi = {
1583
+ list: async () => ({ ok: true, agents: clone(agents) }),
1584
+ ensureDefault: async () => ({ ok: true, agent: clone(await ensureAgent()) }),
1585
+ create: async (payload) => {
1586
+ const agent = {
1587
+ id: 'sandbox-agent-' + uuid(),
1588
+ name: payload?.name ? String(payload.name) : 'Sandbox Agent',
1589
+ description: payload?.description ? String(payload.description) : '',
1590
+ modelId: payload?.modelId ? String(payload.modelId) : '',
1591
+ };
1592
+ agents.unshift(agent);
1593
+ return { ok: true, agent: clone(agent) };
1594
+ },
1595
+ update: async (id, patch) => {
1596
+ const agentId = String(id || '').trim();
1597
+ if (!agentId) throw new Error('id is required');
1598
+ const idx = agents.findIndex((a) => a.id === agentId);
1599
+ if (idx < 0) throw new Error('agent not found');
1600
+ const a = agents[idx];
1601
+ if (patch?.name) a.name = String(patch.name);
1602
+ if (patch?.description) a.description = String(patch.description);
1603
+ if (patch?.modelId) a.modelId = String(patch.modelId);
1604
+ return { ok: true, agent: clone(a) };
1605
+ },
1606
+ delete: async (id) => {
1607
+ const agentId = String(id || '').trim();
1608
+ if (!agentId) throw new Error('id is required');
1609
+ const idx = agents.findIndex((a) => a.id === agentId);
1610
+ if (idx < 0) return { ok: true, deleted: false };
1611
+ agents.splice(idx, 1);
1612
+ return { ok: true, deleted: true };
1613
+ },
1614
+ createForApp: async (payload) => {
1615
+ const name = payload?.name ? String(payload.name) : 'App Agent (' + __SANDBOX__.appId + ')';
1616
+ return await agentsApi.create({ ...payload, name });
1617
+ },
1618
+ };
1619
+
1620
+ const sessionsApi = {
1621
+ list: async () => ({ ok: true, sessions: clone(Array.from(sessions.values())) }),
1622
+ ensureDefault: async (payload) => {
1623
+ const session = await ensureSession(payload?.agentId);
1624
+ return { ok: true, session: clone(session) };
1625
+ },
1626
+ create: async (payload) => {
1627
+ const agentId = payload?.agentId ? String(payload.agentId) : (await ensureAgent()).id;
1628
+ const id = 'sandbox-session-' + uuid();
1629
+ const session = { id, agentId, createdAt: new Date().toISOString() };
1630
+ sessions.set(id, session);
1631
+ if (!messagesBySession.has(id)) messagesBySession.set(id, []);
1632
+ return { ok: true, session: clone(session) };
1633
+ },
1634
+ };
1635
+
1636
+ const messagesApi = {
1637
+ list: async (payload) => {
1638
+ const sessionId = String(payload?.sessionId || '').trim();
1639
+ if (!sessionId) throw new Error('sessionId is required');
1640
+ const msgs = messagesBySession.get(sessionId) || [];
1641
+ return { ok: true, messages: clone(msgs) };
1642
+ },
1643
+ };
1644
+
1645
+ const abort = async (payload) => {
1646
+ const sessionId = String(payload?.sessionId || '').trim();
1647
+ if (!sessionId) throw new Error('sessionId is required');
1648
+ const run = activeRuns.get(sessionId);
1649
+ if (run) {
1650
+ run.aborted = true;
1651
+ if (run.controller) {
1652
+ try {
1653
+ run.controller.abort();
1654
+ } catch {
1655
+ // ignore
1656
+ }
1657
+ }
1658
+ for (const t of run.timers) {
1659
+ try {
1660
+ clearTimeout(t);
1661
+ } catch {
1662
+ // ignore
1663
+ }
1664
+ }
1665
+ activeRuns.delete(sessionId);
1666
+ }
1667
+ emit({ type: 'assistant_abort', sessionId, ts: new Date().toISOString() });
1668
+ return { ok: true };
1669
+ };
1670
+
1671
+ const send = async (payload) => {
1672
+ const sessionId = String(payload?.sessionId || '').trim();
1673
+ const text = String(payload?.text || '').trim();
1674
+ if (!sessionId) throw new Error('sessionId is required');
1675
+ if (!text) throw new Error('text is required');
1676
+
1677
+ if (!sessions.has(sessionId)) throw new Error('session not found');
1678
+
1679
+ const msgs = messagesBySession.get(sessionId) || [];
1680
+ const userMsg = { id: 'msg-' + uuid(), role: 'user', text, ts: new Date().toISOString() };
1681
+ msgs.push(userMsg);
1682
+ messagesBySession.set(sessionId, msgs);
1683
+ emit({ type: 'user_message', sessionId, message: clone(userMsg) });
1684
+
1685
+ const assistantMsg = { id: 'msg-' + uuid(), role: 'assistant', text: '', ts: new Date().toISOString() };
1686
+ msgs.push(assistantMsg);
1687
+ emit({ type: 'assistant_start', sessionId, message: clone(assistantMsg) });
1688
+
1689
+ const run = { aborted: false, timers: [], controller: new AbortController() };
1690
+ activeRuns.set(sessionId, run);
1691
+
1692
+ let result = null;
1693
+ try {
1694
+ const session = sessions.get(sessionId);
1695
+ const agent = session ? agents.find((a) => a.id === session.agentId) : null;
1696
+ const agentModelId = agent?.modelId ? String(agent.modelId) : '';
1697
+ const chatPayload = {
1698
+ messages: buildChatMessages(msgs),
1699
+ modelId: typeof payload?.modelId === 'string' && payload.modelId.trim() ? payload.modelId : agentModelId,
1700
+ modelName: typeof payload?.modelName === 'string' ? payload.modelName : '',
1701
+ systemPrompt: typeof payload?.systemPrompt === 'string' ? payload.systemPrompt : '',
1702
+ disableTools: payload?.disableTools === true,
1703
+ };
1704
+ result = await callSandboxChat(chatPayload, run.controller.signal);
1705
+ } catch (err) {
1706
+ activeRuns.delete(sessionId);
1707
+ if (run.aborted) {
1708
+ emit({ type: 'assistant_abort', sessionId, ts: new Date().toISOString() });
1709
+ return { ok: true, aborted: true };
1710
+ }
1711
+ const errText = '[sandbox error] ' + (err?.message || String(err));
1712
+ assistantMsg.text = errText;
1713
+ emit({ type: 'assistant_delta', sessionId, delta: errText });
1714
+ emit({ type: 'assistant_end', sessionId, message: clone(assistantMsg), error: errText });
1715
+ return { ok: false, error: errText };
1716
+ }
1717
+
1718
+ if (run.aborted) {
1719
+ activeRuns.delete(sessionId);
1720
+ emit({ type: 'assistant_abort', sessionId, ts: new Date().toISOString() });
1721
+ return { ok: true, aborted: true };
1722
+ }
1723
+
1724
+ const toolTrace = Array.isArray(result?.toolTrace) ? result.toolTrace : [];
1725
+ for (const trace of toolTrace) {
1726
+ if (!trace) continue;
1727
+ if (trace.tool) {
1728
+ emit({ type: 'tool_call', sessionId, tool: trace.tool, args: trace.args || null });
1729
+ }
1730
+ if (trace.result !== undefined) {
1731
+ emit({ type: 'tool_result', sessionId, tool: trace.tool, result: trace.result });
1732
+ }
1733
+ }
1734
+
1735
+ const out = typeof result?.content === 'string' ? result.content : '';
1736
+ if (!out) {
1737
+ activeRuns.delete(sessionId);
1738
+ emit({ type: 'assistant_end', sessionId, message: clone(assistantMsg) });
1739
+ return { ok: true };
1740
+ }
1741
+
1742
+ const chunks = [];
1743
+ for (let i = 0; i < out.length; i += 16) chunks.push(out.slice(i, i + 16));
1744
+
1745
+ chunks.forEach((delta, idx) => {
1746
+ const t = setTimeout(() => {
1747
+ if (run.aborted) return;
1748
+ assistantMsg.text += delta;
1749
+ emit({ type: 'assistant_delta', sessionId, delta });
1750
+ if (idx === chunks.length - 1) {
1751
+ activeRuns.delete(sessionId);
1752
+ emit({ type: 'assistant_end', sessionId, message: clone(assistantMsg) });
1753
+ }
1754
+ }, 50 + idx * 40);
1755
+ run.timers.push(t);
1756
+ });
1757
+
1758
+ return { ok: true };
1759
+ };
1760
+
1761
+ const events = {
1762
+ subscribe: (filter, fn) => {
1763
+ if (typeof fn !== 'function') throw new Error('listener is required');
1764
+ const sub = { filter: filter && typeof filter === 'object' ? { ...filter } : {}, fn };
1765
+ listeners.add(sub);
1766
+ return () => listeners.delete(sub);
1767
+ },
1768
+ unsubscribe: () => (listeners.clear(), { ok: true }),
1769
+ };
1770
+
1771
+ return {
1772
+ agents: agentsApi,
1773
+ sessions: sessionsApi,
1774
+ messages: messagesApi,
1775
+ send,
1776
+ abort,
1777
+ events,
1778
+ };
1779
+ })(),
1780
+ };
1781
+
1782
+ inspectorEnabled = true;
1783
+ updateInspector();
1784
+
1785
+ let dispose = null;
1786
+
1787
+ async function loadAndMount() {
1788
+ if (typeof dispose === 'function') { try { await dispose(); } catch {} dispose = null; }
1789
+ container.textContent = '';
1790
+
1791
+ const entryUrl = __SANDBOX__.entryUrl;
1792
+ const mod = await import(entryUrl + (entryUrl.includes('?') ? '&' : '?') + 't=' + Date.now());
1793
+ const mount = mod?.mount || mod?.default?.mount || (typeof mod?.default === 'function' ? mod.default : null);
1794
+ if (typeof mount !== 'function') throw new Error('module entry must export mount()');
1795
+ const ret = await mount({ container, host, slots: { header: headerSlot } });
1796
+ if (typeof ret === 'function') dispose = ret;
1797
+ else if (ret && typeof ret.dispose === 'function') dispose = () => ret.dispose();
1798
+ }
1799
+
1800
+ const renderError = (e) => {
1801
+ const pre = document.createElement('pre');
1802
+ pre.style.padding = '12px';
1803
+ pre.style.whiteSpace = 'pre-wrap';
1804
+ pre.textContent = '[sandbox] ' + (e?.stack || e?.message || String(e));
1805
+ container.appendChild(pre);
1806
+ };
1807
+
1808
+ const scheduleReload = (() => {
1809
+ let t = null;
1810
+ return () => {
1811
+ if (t) return;
1812
+ t = setTimeout(() => {
1813
+ t = null;
1814
+ loadAndMount().catch(renderError);
1815
+ }, 80);
1816
+ };
1817
+ })();
1818
+
1819
+ try {
1820
+ const es = new EventSource('/events');
1821
+ es.addEventListener('reload', () => scheduleReload());
1822
+ } catch {
1823
+ // ignore
1824
+ }
1825
+
1826
+ $('#btnReload').addEventListener('click', () => loadAndMount().catch(renderError));
1827
+
1828
+ loadAndMount().catch(renderError);
1829
+ `;
1830
+ }
1831
+
1832
+ async function loadBackendFactory({ pluginDir, manifest }) {
1833
+ const entryRel = manifest?.backend?.entry ? String(manifest.backend.entry).trim() : '';
1834
+ if (!entryRel) return null;
1835
+ const abs = resolveInsideDir(pluginDir, entryRel);
1836
+ const fileUrl = url.pathToFileURL(abs).toString();
1837
+ const mod = await import(fileUrl + `?t=${Date.now()}`);
1838
+ if (typeof mod?.createUiAppsBackend !== 'function') {
1839
+ throw new Error('backend entry must export createUiAppsBackend(ctx)');
1840
+ }
1841
+ return mod.createUiAppsBackend;
1842
+ }
1843
+
1844
+ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' }) {
1845
+ const { manifest } = loadPluginManifest(pluginDir);
1846
+ const app = pickAppFromManifest(manifest, appId);
1847
+ const effectiveAppId = String(app?.id || '');
1848
+ const entryRel = String(app?.entry?.path || '').trim();
1849
+ if (!entryRel) throw new Error('apps[i].entry.path is required');
1850
+
1851
+ const entryAbs = resolveInsideDir(pluginDir, entryRel);
1852
+ if (!isFile(entryAbs)) throw new Error(`module entry not found: ${entryRel}`);
1853
+
1854
+ const entryUrl = `/plugin/${encodeURIComponent(entryRel).replaceAll('%2F', '/')}`;
1855
+
1856
+ let backendInstance = null;
1857
+ let backendFactory = null;
1858
+
1859
+ const { primary: sandboxRoot, legacy: legacySandboxRoot } = resolveSandboxRoots();
1860
+ const sandboxConfigPath = resolveSandboxConfigPath({ primaryRoot: sandboxRoot, legacyRoot: legacySandboxRoot });
1861
+ let sandboxLlmConfig = loadSandboxLlmConfig(sandboxConfigPath);
1862
+ const getAppMcpPrompt = () => resolveAppMcpPrompt(app, pluginDir);
1863
+ const appMcpEntry = buildAppMcpEntry({ pluginDir, pluginId: String(manifest?.id || ''), app });
1864
+
1865
+ let mcpRuntime = null;
1866
+ let mcpRuntimePromise = null;
1867
+ let sandboxCallMeta = null;
1868
+
1869
+ const resetMcpRuntime = async () => {
1870
+ const runtime = mcpRuntime;
1871
+ mcpRuntime = null;
1872
+ mcpRuntimePromise = null;
1873
+ if (runtime?.transport && typeof runtime.transport.close === 'function') {
1874
+ try {
1875
+ await runtime.transport.close();
1876
+ } catch {
1877
+ // ignore
1878
+ }
1879
+ }
1880
+ if (runtime?.client && typeof runtime.client.close === 'function') {
1881
+ try {
1882
+ await runtime.client.close();
1883
+ } catch {
1884
+ // ignore
1885
+ }
1886
+ }
1887
+ };
1888
+
1889
+ const ensureMcpRuntime = async () => {
1890
+ if (!appMcpEntry) return null;
1891
+ if (mcpRuntime) return mcpRuntime;
1892
+ if (!mcpRuntimePromise) {
1893
+ mcpRuntimePromise = (async () => {
1894
+ const handle = await connectMcpServer(appMcpEntry);
1895
+ if (!handle) return null;
1896
+ const toolEntries = Array.isArray(handle.tools)
1897
+ ? handle.tools.map((tool) => {
1898
+ const identifier = buildMcpToolIdentifier(handle.serverName, tool?.name);
1899
+ return {
1900
+ identifier,
1901
+ serverName: handle.serverName,
1902
+ toolName: tool?.name,
1903
+ client: handle.client,
1904
+ definition: {
1905
+ type: 'function',
1906
+ function: {
1907
+ name: identifier,
1908
+ description: buildMcpToolDescription(handle.serverName, tool),
1909
+ parameters:
1910
+ tool?.inputSchema && typeof tool.inputSchema === 'object'
1911
+ ? tool.inputSchema
1912
+ : { type: 'object', properties: {} },
1913
+ },
1914
+ },
1915
+ };
1916
+ })
1917
+ : [];
1918
+ const toolMap = new Map(toolEntries.map((entry) => [entry.identifier, entry]));
1919
+ return { ...handle, toolEntries, toolMap };
1920
+ })();
1921
+ }
1922
+ mcpRuntime = await mcpRuntimePromise;
1923
+ return mcpRuntime;
1924
+ };
1925
+
1926
+ const getSandboxLlmConfig = () => ({ ...sandboxLlmConfig });
1927
+
1928
+ const updateSandboxLlmConfig = (patch) => {
1929
+ if (!patch || typeof patch !== 'object') return getSandboxLlmConfig();
1930
+ const next = { ...sandboxLlmConfig };
1931
+ if (Object.prototype.hasOwnProperty.call(patch, 'apiKey')) {
1932
+ next.apiKey = normalizeText(patch.apiKey);
1933
+ }
1934
+ if (Object.prototype.hasOwnProperty.call(patch, 'baseUrl')) {
1935
+ next.baseUrl = normalizeText(patch.baseUrl);
1936
+ }
1937
+ if (Object.prototype.hasOwnProperty.call(patch, 'modelId')) {
1938
+ next.modelId = normalizeText(patch.modelId);
1939
+ }
1940
+ sandboxLlmConfig = next;
1941
+ saveSandboxLlmConfig(sandboxConfigPath, next);
1942
+ return { ...next };
1943
+ };
1944
+
1945
+ const runSandboxChat = async ({ messages, modelId, modelName, systemPrompt, disableTools, signal, callMeta } = {}) => {
1946
+ const cfg = getSandboxLlmConfig();
1947
+ const apiKey = normalizeText(cfg.apiKey || process.env.SANDBOX_LLM_API_KEY);
1948
+ const baseUrl = normalizeText(cfg.baseUrl) || DEFAULT_LLM_BASE_URL;
1949
+ const effectiveModel = normalizeText(modelId) || normalizeText(modelName) || normalizeText(cfg.modelId);
1950
+ if (!apiKey) {
1951
+ throw new Error('Sandbox API key not configured. Use "AI Config" in the sandbox toolbar.');
1952
+ }
1953
+ if (!effectiveModel) {
1954
+ throw new Error('Sandbox modelId not configured. Use "AI Config" in the sandbox toolbar.');
1955
+ }
1956
+
1957
+ const prompt = normalizeText(systemPrompt) || (!disableTools ? normalizeText(getAppMcpPrompt()) : '');
1958
+ const openAiMessages = [];
1959
+ if (prompt) openAiMessages.push({ role: 'system', content: prompt });
1960
+ const inputMessages = Array.isArray(messages) ? messages : [];
1961
+ for (const msg of inputMessages) {
1962
+ const role = normalizeText(msg?.role);
1963
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
1964
+ const text = typeof msg?.text === 'string' ? msg.text : typeof msg?.content === 'string' ? msg.content : '';
1965
+ if (!text || !text.trim()) continue;
1966
+ openAiMessages.push({ role, content: String(text) });
1967
+ }
1968
+ if (openAiMessages.length === 0) throw new Error('No input messages provided.');
1969
+
1970
+ let toolEntries = [];
1971
+ let toolMap = new Map();
1972
+ let effectiveCallMeta = sandboxCallMeta;
1973
+ if (!disableTools) {
1974
+ const runtime = await ensureMcpRuntime();
1975
+ if (runtime?.toolEntries?.length) {
1976
+ toolEntries = runtime.toolEntries;
1977
+ toolMap = runtime.toolMap || new Map();
1978
+ }
1979
+ if (callMeta && typeof callMeta === 'object') {
1980
+ effectiveCallMeta = mergeCallMeta(sandboxCallMeta, callMeta);
1981
+ }
1982
+ }
1983
+ const toolDefs = toolEntries.map((entry) => entry.definition);
1984
+
1985
+ const toolTrace = [];
1986
+ let iteration = 0;
1987
+ const maxToolPasses = 8;
1988
+ let workingMessages = openAiMessages.slice();
1989
+
1990
+ while (iteration < maxToolPasses) {
1991
+ const response = await callOpenAiChat({
1992
+ apiKey,
1993
+ baseUrl,
1994
+ model: effectiveModel,
1995
+ messages: workingMessages,
1996
+ tools: toolDefs,
1997
+ signal,
1998
+ });
1999
+ const message = response?.choices?.[0]?.message;
2000
+ if (!message) throw new Error('Empty model response.');
2001
+ const content = typeof message.content === 'string' ? message.content : '';
2002
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
2003
+ if (toolCalls.length > 0 && toolMap.size > 0 && !disableTools) {
2004
+ workingMessages.push({ role: 'assistant', content, tool_calls: toolCalls });
2005
+ for (const call of toolCalls) {
2006
+ const toolName = typeof call?.function?.name === 'string' ? call.function.name : '';
2007
+ const toolEntry = toolName ? toolMap.get(toolName) : null;
2008
+ let args = {};
2009
+ let resultText = '';
2010
+ if (!toolEntry) {
2011
+ resultText = `[error] Tool not registered: ${toolName || 'unknown'}`;
2012
+ } else {
2013
+ const rawArgs = typeof call?.function?.arguments === 'string' ? call.function.arguments : '{}';
2014
+ try {
2015
+ args = JSON.parse(rawArgs || '{}');
2016
+ } catch (err) {
2017
+ resultText = '[error] Failed to parse tool arguments: ' + (err?.message || String(err));
2018
+ args = {};
2019
+ }
2020
+ if (!resultText) {
2021
+ const toolResult = await toolEntry.client.callTool({
2022
+ name: toolEntry.toolName,
2023
+ arguments: args,
2024
+ ...(effectiveCallMeta ? { _meta: effectiveCallMeta } : {}),
2025
+ });
2026
+ resultText = formatMcpToolResult(toolEntry.serverName, toolEntry.toolName, toolResult);
2027
+ }
2028
+ }
2029
+ toolTrace.push({ tool: toolName || 'unknown', args, result: resultText });
2030
+ workingMessages.push({ role: 'tool', tool_call_id: String(call?.id || ''), content: resultText });
2031
+ }
2032
+ iteration += 1;
2033
+ continue;
2034
+ }
2035
+ return { content, model: effectiveModel, toolTrace };
2036
+ }
2037
+
2038
+ throw new Error('Too many tool calls. Aborting.');
2039
+ };
2040
+
2041
+ const ctxBase = {
2042
+ pluginId: String(manifest?.id || ''),
2043
+ pluginDir,
2044
+ stateDir: path.join(sandboxRoot, 'state', 'chatos'),
2045
+ sessionRoot: process.cwd(),
2046
+ projectRoot: process.cwd(),
2047
+ dataDir: '',
2048
+ llm: {
2049
+ complete: async (payload) => {
2050
+ const input = typeof payload?.input === 'string' ? payload.input : '';
2051
+ const normalized = String(input || '').trim();
2052
+ if (!normalized) throw new Error('input is required');
2053
+ const callMeta = payload?.callMeta && typeof payload.callMeta === 'object' ? payload.callMeta : null;
2054
+ const result = await runSandboxChat({
2055
+ messages: [{ role: 'user', text: normalized }],
2056
+ modelId: typeof payload?.modelId === 'string' ? payload.modelId : '',
2057
+ modelName: typeof payload?.modelName === 'string' ? payload.modelName : '',
2058
+ systemPrompt: typeof payload?.systemPrompt === 'string' ? payload.systemPrompt : '',
2059
+ disableTools: payload?.disableTools === true,
2060
+ callMeta,
2061
+ });
2062
+ return {
2063
+ ok: true,
2064
+ model: result.model,
2065
+ content: result.content,
2066
+ toolTrace: result.toolTrace,
2067
+ };
2068
+ },
2069
+ },
2070
+ };
2071
+ ctxBase.dataDir = path.join(sandboxRoot, 'data', ctxBase.pluginId);
2072
+ ensureDir(ctxBase.stateDir);
2073
+ ensureDir(ctxBase.dataDir);
2074
+ sandboxCallMeta = buildSandboxCallMeta({
2075
+ rawCallMeta: app?.ai?.mcp?.callMeta,
2076
+ context: {
2077
+ pluginId: ctxBase.pluginId,
2078
+ appId: effectiveAppId,
2079
+ pluginDir: ctxBase.pluginDir,
2080
+ dataDir: ctxBase.dataDir,
2081
+ stateDir: ctxBase.stateDir,
2082
+ sessionRoot: ctxBase.sessionRoot,
2083
+ projectRoot: ctxBase.projectRoot,
2084
+ },
2085
+ });
2086
+
2087
+ const sseClients = new Set();
2088
+ const sseWrite = (res, event, data) => {
2089
+ try {
2090
+ res.write(`event: ${event}\n`);
2091
+ res.write(`data: ${JSON.stringify(data ?? null)}\n\n`);
2092
+ } catch {
2093
+ // ignore
2094
+ }
2095
+ };
2096
+ const sseBroadcast = (event, data) => {
2097
+ for (const res of sseClients) {
2098
+ sseWrite(res, event, data);
2099
+ }
2100
+ };
2101
+
2102
+ let changeSeq = 0;
2103
+ const stopWatch = startRecursiveWatcher(pluginDir, ({ eventType, filePath }) => {
2104
+ const rel = filePath ? path.relative(pluginDir, filePath).replaceAll('\\', '/') : '';
2105
+ const base = rel ? path.basename(rel) : '';
2106
+ if (!rel) return;
2107
+ if (base === '.DS_Store') return;
2108
+ if (base.endsWith('.map')) return;
2109
+
2110
+ changeSeq += 1;
2111
+ if (rel.startsWith('backend/')) {
2112
+ backendInstance = null;
2113
+ backendFactory = null;
2114
+ }
2115
+ resetMcpRuntime().catch(() => {});
2116
+ sseBroadcast('reload', { seq: changeSeq, eventType: eventType || '', path: rel });
2117
+ });
2118
+
2119
+ const server = http.createServer(async (req, res) => {
2120
+ try {
2121
+ const parsed = url.parse(req.url || '/', true);
2122
+ const pathname = parsed.pathname || '/';
2123
+
2124
+ if (req.method === 'GET' && pathname === '/') {
2125
+ return sendText(res, 200, htmlPage(), 'text/html; charset=utf-8');
2126
+ }
2127
+
2128
+ if (req.method === 'GET' && pathname === '/events') {
2129
+ res.writeHead(200, {
2130
+ 'content-type': 'text/event-stream; charset=utf-8',
2131
+ 'cache-control': 'no-store',
2132
+ connection: 'keep-alive',
2133
+ });
2134
+ res.write(': connected\n\n');
2135
+ sseClients.add(res);
2136
+ const ping = setInterval(() => {
2137
+ try {
2138
+ res.write(': ping\n\n');
2139
+ } catch {
2140
+ // ignore
2141
+ }
2142
+ }, 15000);
2143
+ req.on('close', () => {
2144
+ try {
2145
+ clearInterval(ping);
2146
+ } catch {
2147
+ // ignore
2148
+ }
2149
+ sseClients.delete(res);
2150
+ });
2151
+ return;
2152
+ }
2153
+
2154
+ if (req.method === 'GET' && pathname === '/sandbox.mjs') {
2155
+ const tokenNames = loadTokenNames();
2156
+ const sandboxPaths = {
2157
+ workdir: sandboxCallMeta?.workdir || '',
2158
+ dataDir: ctxBase.dataDir || '',
2159
+ pluginDir: ctxBase.pluginDir || '',
2160
+ stateDir: ctxBase.stateDir || '',
2161
+ sessionRoot: ctxBase.sessionRoot || '',
2162
+ projectRoot: ctxBase.projectRoot || '',
2163
+ };
2164
+ const js = sandboxClientJs()
2165
+ .replaceAll('__SANDBOX__.pluginId', JSON.stringify(ctxBase.pluginId))
2166
+ .replaceAll('__SANDBOX__.appId', JSON.stringify(effectiveAppId))
2167
+ .replaceAll('__SANDBOX__.entryUrl', JSON.stringify(entryUrl))
2168
+ .replaceAll('__SANDBOX__.registryApp', JSON.stringify({ plugin: { id: ctxBase.pluginId }, id: effectiveAppId, entry: { type: 'module', url: entryUrl } }))
2169
+ .replaceAll('__SANDBOX__.tokenNames', JSON.stringify(tokenNames))
2170
+ .replaceAll('__SANDBOX__.paths', JSON.stringify(sandboxPaths));
2171
+ return sendText(res, 200, js, 'text/javascript; charset=utf-8');
2172
+ }
2173
+
2174
+ if (req.method === 'GET' && pathname.startsWith('/plugin/')) {
2175
+ const rel = decodeURIComponent(pathname.slice('/plugin/'.length));
2176
+ const abs = resolveInsideDir(pluginDir, rel);
2177
+ if (!serveStaticFile(res, abs)) return sendText(res, 404, 'Not found');
2178
+ return;
2179
+ }
2180
+
2181
+ if (req.method === 'GET' && pathname === '/api/manifest') {
2182
+ return sendJson(res, 200, { ok: true, manifest });
2183
+ }
2184
+
2185
+ if (pathname === '/api/sandbox/llm-config') {
2186
+ if (req.method === 'GET') {
2187
+ const cfg = getSandboxLlmConfig();
2188
+ return sendJson(res, 200, {
2189
+ ok: true,
2190
+ config: {
2191
+ baseUrl: cfg.baseUrl || '',
2192
+ modelId: cfg.modelId || '',
2193
+ hasApiKey: Boolean(cfg.apiKey),
2194
+ },
2195
+ });
2196
+ }
2197
+ if (req.method === 'POST') {
2198
+ try {
2199
+ const payload = await readJsonBody(req);
2200
+ const patch = payload?.config && typeof payload.config === 'object' ? payload.config : payload;
2201
+ const next = updateSandboxLlmConfig({
2202
+ ...(Object.prototype.hasOwnProperty.call(patch || {}, 'apiKey') ? { apiKey: patch.apiKey } : {}),
2203
+ ...(Object.prototype.hasOwnProperty.call(patch || {}, 'baseUrl') ? { baseUrl: patch.baseUrl } : {}),
2204
+ ...(Object.prototype.hasOwnProperty.call(patch || {}, 'modelId') ? { modelId: patch.modelId } : {}),
2205
+ });
2206
+ return sendJson(res, 200, {
2207
+ ok: true,
2208
+ config: {
2209
+ baseUrl: next.baseUrl || '',
2210
+ modelId: next.modelId || '',
2211
+ hasApiKey: Boolean(next.apiKey),
2212
+ },
2213
+ });
2214
+ } catch (err) {
2215
+ return sendJson(res, 200, { ok: false, message: err?.message || String(err) });
2216
+ }
2217
+ }
2218
+ return sendJson(res, 405, { ok: false, message: 'Method not allowed' });
2219
+ }
2220
+
2221
+ if (pathname === '/api/llm/chat') {
2222
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, message: 'Method not allowed' });
2223
+ try {
2224
+ const payload = await readJsonBody(req);
2225
+ const messages = Array.isArray(payload?.messages) ? payload.messages : [];
2226
+ const callMeta = payload?.callMeta && typeof payload.callMeta === 'object' ? payload.callMeta : null;
2227
+ const result = await runSandboxChat({
2228
+ messages,
2229
+ modelId: typeof payload?.modelId === 'string' ? payload.modelId : '',
2230
+ modelName: typeof payload?.modelName === 'string' ? payload.modelName : '',
2231
+ systemPrompt: typeof payload?.systemPrompt === 'string' ? payload.systemPrompt : '',
2232
+ disableTools: payload?.disableTools === true,
2233
+ callMeta,
2234
+ });
2235
+ return sendJson(res, 200, {
2236
+ ok: true,
2237
+ model: result.model,
2238
+ content: result.content,
2239
+ toolTrace: result.toolTrace || [],
2240
+ });
2241
+ } catch (err) {
2242
+ return sendJson(res, 200, { ok: false, message: err?.message || String(err) });
2243
+ }
2244
+ }
2245
+
2246
+ if (pathname === '/api/backend/invoke') {
2247
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, message: 'Method not allowed' });
2248
+ let body = '';
2249
+ req.on('data', (chunk) => {
2250
+ body += chunk;
2251
+ });
2252
+ req.on('end', async () => {
2253
+ try {
2254
+ const payload = body ? JSON.parse(body) : {};
2255
+ const method = typeof payload?.method === 'string' ? payload.method.trim() : '';
2256
+ if (!method) return sendJson(res, 400, { ok: false, message: 'method is required' });
2257
+ const params = payload?.params;
2258
+
2259
+ if (!backendFactory) backendFactory = await loadBackendFactory({ pluginDir, manifest });
2260
+ if (!backendFactory) return sendJson(res, 200, { ok: false, message: 'backend not configured in plugin.json' });
2261
+
2262
+ if (!backendInstance || typeof backendInstance !== 'object' || !backendInstance.methods) {
2263
+ backendInstance = await backendFactory({ ...ctxBase });
2264
+ }
2265
+ const fn = backendInstance?.methods?.[method];
2266
+ if (typeof fn !== 'function') return sendJson(res, 404, { ok: false, message: `method not found: ${method}` });
2267
+ const result = await fn(params, { ...ctxBase });
2268
+ return sendJson(res, 200, { ok: true, result });
2269
+ } catch (e) {
2270
+ return sendJson(res, 200, { ok: false, message: e?.message || String(e) });
2271
+ }
2272
+ });
2273
+ return;
2274
+ }
2275
+
2276
+ sendText(res, 404, 'Not found');
2277
+ } catch (e) {
2278
+ sendJson(res, 500, { ok: false, message: e?.message || String(e) });
2279
+ }
2280
+ });
2281
+ server.once('close', () => {
2282
+ stopWatch();
2283
+ resetMcpRuntime().catch(() => {});
2284
+ });
2285
+
2286
+ await new Promise((resolve, reject) => {
2287
+ server.once('error', reject);
2288
+ server.listen(port, '127.0.0.1', () => {
2289
+ server.off('error', reject);
2290
+ resolve();
2291
+ });
2292
+ });
2293
+
2294
+ // eslint-disable-next-line no-console
2295
+ console.log(`Sandbox running:
2296
+ http://localhost:${port}/
2297
+ pluginDir:
2298
+ ${pluginDir}
2299
+ app:
2300
+ ${ctxBase.pluginId}:${effectiveAppId}
2301
+ `);
2302
+ }