@leeoohoo/ui-apps-devkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +70 -0
  2. package/bin/chatos-uiapp.js +5 -0
  3. package/package.json +22 -0
  4. package/src/cli.js +53 -0
  5. package/src/commands/dev.js +14 -0
  6. package/src/commands/init.js +141 -0
  7. package/src/commands/install.js +55 -0
  8. package/src/commands/pack.js +72 -0
  9. package/src/commands/validate.js +103 -0
  10. package/src/lib/args.js +49 -0
  11. package/src/lib/config.js +29 -0
  12. package/src/lib/fs.js +78 -0
  13. package/src/lib/path-boundary.js +16 -0
  14. package/src/lib/plugin.js +45 -0
  15. package/src/lib/template.js +168 -0
  16. package/src/sandbox/server.js +861 -0
  17. package/templates/basic/README.md +58 -0
  18. package/templates/basic/chatos.config.json +5 -0
  19. package/templates/basic/docs/CHATOS_UI_APPS_AI_CONTRIBUTIONS.md +181 -0
  20. package/templates/basic/docs/CHATOS_UI_APPS_BACKEND_PROTOCOL.md +74 -0
  21. package/templates/basic/docs/CHATOS_UI_APPS_HOST_API.md +123 -0
  22. package/templates/basic/docs/CHATOS_UI_APPS_OVERVIEW.md +110 -0
  23. package/templates/basic/docs/CHATOS_UI_APPS_PLUGIN_MANIFEST.md +227 -0
  24. package/templates/basic/docs/CHATOS_UI_PROMPTS_PROTOCOL.md +392 -0
  25. package/templates/basic/plugin/apps/app/index.mjs +263 -0
  26. package/templates/basic/plugin/apps/app/mcp-prompt.en.md +7 -0
  27. package/templates/basic/plugin/apps/app/mcp-prompt.zh.md +7 -0
  28. package/templates/basic/plugin/apps/app/mcp-server.mjs +15 -0
  29. package/templates/basic/plugin/backend/index.mjs +37 -0
  30. package/templates/basic/template.json +7 -0
  31. package/templates/notepad/README.md +36 -0
  32. package/templates/notepad/chatos.config.json +4 -0
  33. package/templates/notepad/docs/CHATOS_UI_APPS_AI_CONTRIBUTIONS.md +181 -0
  34. package/templates/notepad/docs/CHATOS_UI_APPS_BACKEND_PROTOCOL.md +74 -0
  35. package/templates/notepad/docs/CHATOS_UI_APPS_HOST_API.md +123 -0
  36. package/templates/notepad/docs/CHATOS_UI_APPS_OVERVIEW.md +110 -0
  37. package/templates/notepad/docs/CHATOS_UI_APPS_PLUGIN_MANIFEST.md +227 -0
  38. package/templates/notepad/docs/CHATOS_UI_PROMPTS_PROTOCOL.md +392 -0
  39. package/templates/notepad/plugin/apps/app/api.mjs +30 -0
  40. package/templates/notepad/plugin/apps/app/dom.mjs +14 -0
  41. package/templates/notepad/plugin/apps/app/ds-tree.mjs +35 -0
  42. package/templates/notepad/plugin/apps/app/index.mjs +1056 -0
  43. package/templates/notepad/plugin/apps/app/layers.mjs +338 -0
  44. package/templates/notepad/plugin/apps/app/markdown.mjs +120 -0
  45. package/templates/notepad/plugin/apps/app/mcp-prompt.en.md +22 -0
  46. package/templates/notepad/plugin/apps/app/mcp-prompt.zh.md +22 -0
  47. package/templates/notepad/plugin/apps/app/mcp-server.mjs +200 -0
  48. package/templates/notepad/plugin/apps/app/styles.mjs +355 -0
  49. package/templates/notepad/plugin/apps/app/tags.mjs +21 -0
  50. package/templates/notepad/plugin/apps/app/ui.mjs +280 -0
  51. package/templates/notepad/plugin/backend/index.mjs +99 -0
  52. package/templates/notepad/plugin/plugin.json +23 -0
  53. package/templates/notepad/plugin/shared/notepad-paths.mjs +62 -0
  54. package/templates/notepad/plugin/shared/notepad-store.mjs +765 -0
  55. package/templates/notepad/template.json +8 -0
@@ -0,0 +1,861 @@
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
+ function sendJson(res, status, obj) {
11
+ const raw = JSON.stringify(obj);
12
+ res.writeHead(status, {
13
+ 'content-type': 'application/json; charset=utf-8',
14
+ 'cache-control': 'no-store',
15
+ });
16
+ res.end(raw);
17
+ }
18
+
19
+ function sendText(res, status, text, contentType) {
20
+ res.writeHead(status, {
21
+ 'content-type': contentType || 'text/plain; charset=utf-8',
22
+ 'cache-control': 'no-store',
23
+ });
24
+ res.end(text);
25
+ }
26
+
27
+ function guessContentType(filePath) {
28
+ const ext = path.extname(filePath).toLowerCase();
29
+ if (ext === '.html') return 'text/html; charset=utf-8';
30
+ if (ext === '.css') return 'text/css; charset=utf-8';
31
+ if (ext === '.mjs' || ext === '.js') return 'text/javascript; charset=utf-8';
32
+ if (ext === '.json') return 'application/json; charset=utf-8';
33
+ if (ext === '.md') return 'text/markdown; charset=utf-8';
34
+ if (ext === '.svg') return 'image/svg+xml';
35
+ if (ext === '.png') return 'image/png';
36
+ return 'application/octet-stream';
37
+ }
38
+
39
+ function serveStaticFile(res, filePath) {
40
+ if (!isFile(filePath)) return false;
41
+ const ct = guessContentType(filePath);
42
+ const buf = fs.readFileSync(filePath);
43
+ res.writeHead(200, { 'content-type': ct, 'cache-control': 'no-store' });
44
+ res.end(buf);
45
+ return true;
46
+ }
47
+
48
+ function startRecursiveWatcher(rootDir, onChange) {
49
+ const root = path.resolve(rootDir);
50
+ if (!isDirectory(root)) return () => {};
51
+
52
+ const watchers = new Map();
53
+
54
+ const shouldIgnore = (p) => {
55
+ const base = path.basename(p);
56
+ if (!base) return false;
57
+ if (base === 'node_modules') return true;
58
+ if (base === '.git') return true;
59
+ if (base === '.DS_Store') return true;
60
+ return false;
61
+ };
62
+
63
+ const scan = (dir) => {
64
+ const abs = path.resolve(dir);
65
+ if (!isDirectory(abs)) return;
66
+ if (shouldIgnore(abs)) return;
67
+ if (!watchers.has(abs)) {
68
+ try {
69
+ const w = fs.watch(abs, (eventType, filename) => {
70
+ const relName = filename ? String(filename) : '';
71
+ const filePath = relName ? path.join(abs, relName) : abs;
72
+ try {
73
+ onChange({ eventType, filePath });
74
+ } catch {
75
+ // ignore
76
+ }
77
+ scheduleRescan();
78
+ });
79
+ watchers.set(abs, w);
80
+ } catch {
81
+ // ignore
82
+ }
83
+ }
84
+
85
+ let entries = [];
86
+ try {
87
+ entries = fs.readdirSync(abs, { withFileTypes: true });
88
+ } catch {
89
+ return;
90
+ }
91
+ for (const ent of entries) {
92
+ if (!ent?.isDirectory?.()) continue;
93
+ const child = path.join(abs, ent.name);
94
+ if (shouldIgnore(child)) continue;
95
+ scan(child);
96
+ }
97
+ };
98
+
99
+ let rescanTimer = null;
100
+ const scheduleRescan = () => {
101
+ if (rescanTimer) return;
102
+ rescanTimer = setTimeout(() => {
103
+ rescanTimer = null;
104
+ scan(root);
105
+ }, 250);
106
+ };
107
+
108
+ scan(root);
109
+
110
+ return () => {
111
+ if (rescanTimer) {
112
+ try {
113
+ clearTimeout(rescanTimer);
114
+ } catch {
115
+ // ignore
116
+ }
117
+ rescanTimer = null;
118
+ }
119
+ for (const w of watchers.values()) {
120
+ try {
121
+ w.close();
122
+ } catch {
123
+ // ignore
124
+ }
125
+ }
126
+ watchers.clear();
127
+ };
128
+ }
129
+
130
+ function htmlPage() {
131
+ return `<!doctype html>
132
+ <html lang="zh-CN">
133
+ <head>
134
+ <meta charset="UTF-8" />
135
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
136
+ <title>ChatOS UI Apps Sandbox</title>
137
+ <style>
138
+ :root { color-scheme: light dark; }
139
+ body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; }
140
+ #appRoot { height: 100vh; display:flex; flex-direction:column; }
141
+ #sandboxToolbar { flex: 0 0 auto; border-bottom: 1px solid rgba(0,0,0,0.10); padding: 10px 12px; }
142
+ #headerSlot { flex: 0 0 auto; border-bottom: 1px solid rgba(0,0,0,0.08); padding: 10px 12px; }
143
+ #container { flex: 1 1 auto; min-height:0; overflow:hidden; }
144
+ #containerInner { height:100%; overflow:auto; }
145
+ .muted { opacity: 0.7; font-size: 12px; }
146
+ .bar { display:flex; gap:10px; align-items:center; justify-content:space-between; }
147
+ .btn { border:1px solid rgba(0,0,0,0.14); background:rgba(0,0,0,0.04); padding:6px 10px; border-radius:10px; cursor:pointer; font-weight:650; }
148
+ .btn:active { transform: translateY(1px); }
149
+ #promptsPanel { position: fixed; right: 12px; bottom: 12px; width: 420px; max-height: 70vh; display:none; flex-direction:column; background:rgba(255,255,255,0.96); color:#111; border:1px solid rgba(0,0,0,0.18); border-radius:14px; overflow:hidden; box-shadow: 0 18px 60px rgba(0,0,0,0.18); }
150
+ @media (prefers-color-scheme: dark) {
151
+ #promptsPanel { background: rgba(17,17,17,0.92); color: #eee; border-color: rgba(255,255,255,0.18); }
152
+ #sandboxToolbar { border-bottom-color: rgba(255,255,255,0.12); }
153
+ #headerSlot { border-bottom-color: rgba(255,255,255,0.10); }
154
+ .btn { border-color: rgba(255,255,255,0.18); background: rgba(255,255,255,0.06); color:#eee; }
155
+ }
156
+ #promptsPanelHeader { padding: 10px 12px; display:flex; align-items:center; justify-content:space-between; border-bottom: 1px solid rgba(0,0,0,0.12); }
157
+ #promptsPanelBody { padding: 10px 12px; overflow:auto; display:flex; flex-direction:column; gap:10px; }
158
+ #promptsFab { position: fixed; right: 16px; bottom: 16px; width: 44px; height: 44px; border-radius: 999px; display:flex; align-items:center; justify-content:center; }
159
+ .card { border: 1px solid rgba(0,0,0,0.12); border-radius: 12px; padding: 10px; }
160
+ .row { display:flex; gap:10px; }
161
+ input, textarea, select { width:100%; padding:8px; border-radius:10px; border:1px solid rgba(0,0,0,0.14); background:rgba(0,0,0,0.03); color: inherit; }
162
+ textarea { min-height: 70px; resize: vertical; }
163
+ label { font-size: 12px; opacity: 0.8; }
164
+ .danger { border-color: rgba(255,0,0,0.35); }
165
+ </style>
166
+ </head>
167
+ <body>
168
+ <div id="appRoot">
169
+ <div id="sandboxToolbar">
170
+ <div class="bar">
171
+ <div>
172
+ <div style="font-weight:800">ChatOS UI Apps Sandbox</div>
173
+ <div class="muted">Host API mock · 模拟 module mount({ container, host, slots })</div>
174
+ </div>
175
+ <div class="row">
176
+ <button id="btnReload" class="btn" type="button">Reload</button>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ <div id="headerSlot"></div>
181
+ <div id="container"><div id="containerInner"></div></div>
182
+ </div>
183
+
184
+ <button id="promptsFab" class="btn" type="button">:)</button>
185
+
186
+ <div id="promptsPanel">
187
+ <div id="promptsPanelHeader">
188
+ <div style="font-weight:800">UI Prompts</div>
189
+ <button id="promptsClose" class="btn" type="button">Close</button>
190
+ </div>
191
+ <div id="promptsPanelBody"></div>
192
+ </div>
193
+
194
+ <script type="module" src="/sandbox.mjs"></script>
195
+ </body>
196
+ </html>`;
197
+ }
198
+
199
+ function sandboxClientJs() {
200
+ return `const $ = (sel) => document.querySelector(sel);
201
+
202
+ const container = $('#containerInner');
203
+ const headerSlot = $('#headerSlot');
204
+ const fab = $('#promptsFab');
205
+ const panel = $('#promptsPanel');
206
+ const panelBody = $('#promptsPanelBody');
207
+ const panelClose = $('#promptsClose');
208
+
209
+ const setPanelOpen = (open) => { panel.style.display = open ? 'flex' : 'none'; };
210
+ fab.addEventListener('click', () => setPanelOpen(panel.style.display !== 'flex'));
211
+ panelClose.addEventListener('click', () => setPanelOpen(false));
212
+ window.addEventListener('chatos:uiPrompts:open', () => setPanelOpen(true));
213
+ window.addEventListener('chatos:uiPrompts:close', () => setPanelOpen(false));
214
+ window.addEventListener('chatos:uiPrompts:toggle', () => setPanelOpen(panel.style.display !== 'flex'));
215
+
216
+ const entries = [];
217
+ const listeners = new Set();
218
+ const emitUpdate = () => {
219
+ const payload = { path: '(sandbox)', entries: [...entries] };
220
+ for (const fn of listeners) { try { fn(payload); } catch {} }
221
+ renderPrompts();
222
+ };
223
+
224
+ const uuid = () => (globalThis.crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + Math.random().toString(16).slice(2));
225
+
226
+ function renderPrompts() {
227
+ panelBody.textContent = '';
228
+ const pending = new Map();
229
+ for (const e of entries) {
230
+ if (e?.type !== 'ui_prompt') continue;
231
+ const id = String(e?.requestId || '');
232
+ if (!id) continue;
233
+ if (e.action === 'request') pending.set(id, e);
234
+ if (e.action === 'response') pending.delete(id);
235
+ }
236
+
237
+ if (pending.size === 0) {
238
+ const empty = document.createElement('div');
239
+ empty.className = 'muted';
240
+ empty.textContent = '暂无待办(request 后会出现在这里)';
241
+ panelBody.appendChild(empty);
242
+ return;
243
+ }
244
+
245
+ for (const [requestId, req] of pending.entries()) {
246
+ const card = document.createElement('div');
247
+ card.className = 'card';
248
+
249
+ const title = document.createElement('div');
250
+ title.style.fontWeight = '800';
251
+ title.textContent = req?.prompt?.title || '(untitled)';
252
+
253
+ const msg = document.createElement('div');
254
+ msg.className = 'muted';
255
+ msg.style.marginTop = '6px';
256
+ msg.textContent = req?.prompt?.message || '';
257
+
258
+ const source = document.createElement('div');
259
+ source.className = 'muted';
260
+ source.style.marginTop = '6px';
261
+ source.textContent = req?.prompt?.source ? String(req.prompt.source) : '';
262
+
263
+ const form = document.createElement('div');
264
+ form.style.marginTop = '10px';
265
+ form.style.display = 'grid';
266
+ form.style.gap = '10px';
267
+
268
+ const kind = String(req?.prompt?.kind || '');
269
+
270
+ const mkBtn = (label, danger) => {
271
+ const btn = document.createElement('button');
272
+ btn.type = 'button';
273
+ btn.className = 'btn' + (danger ? ' danger' : '');
274
+ btn.textContent = label;
275
+ return btn;
276
+ };
277
+
278
+ const submit = async (response) => {
279
+ entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'response', requestId, response });
280
+ emitUpdate();
281
+ };
282
+
283
+ if (kind === 'kv') {
284
+ const fields = Array.isArray(req?.prompt?.fields) ? req.prompt.fields : [];
285
+ const values = {};
286
+ for (const f of fields) {
287
+ const key = String(f?.key || '');
288
+ if (!key) continue;
289
+ const wrap = document.createElement('div');
290
+ const lab = document.createElement('label');
291
+ lab.textContent = f?.label ? String(f.label) : key;
292
+ const input = document.createElement(f?.multiline ? 'textarea' : 'input');
293
+ input.placeholder = f?.placeholder ? String(f.placeholder) : '';
294
+ input.value = f?.default ? String(f.default) : '';
295
+ input.addEventListener('input', () => { values[key] = String(input.value || ''); });
296
+ values[key] = String(input.value || '');
297
+ wrap.appendChild(lab);
298
+ wrap.appendChild(input);
299
+ form.appendChild(wrap);
300
+ }
301
+ const row = document.createElement('div');
302
+ row.className = 'row';
303
+ const ok = mkBtn('Submit');
304
+ ok.addEventListener('click', () => submit({ status: 'ok', values }));
305
+ const cancel = mkBtn('Cancel', true);
306
+ cancel.addEventListener('click', () => submit({ status: 'cancel' }));
307
+ row.appendChild(ok);
308
+ row.appendChild(cancel);
309
+ form.appendChild(row);
310
+ } else if (kind === 'choice') {
311
+ const options = Array.isArray(req?.prompt?.options) ? req.prompt.options : [];
312
+ const multiple = Boolean(req?.prompt?.multiple);
313
+ const selected = new Set();
314
+ const wrap = document.createElement('div');
315
+ const lab = document.createElement('label');
316
+ lab.textContent = '选择';
317
+ const select = document.createElement('select');
318
+ if (multiple) select.multiple = true;
319
+ for (const opt of options) {
320
+ const v = String(opt?.value || '');
321
+ const o = document.createElement('option');
322
+ o.value = v;
323
+ o.textContent = opt?.label ? String(opt.label) : v;
324
+ select.appendChild(o);
325
+ }
326
+ select.addEventListener('change', () => {
327
+ selected.clear();
328
+ for (const o of select.selectedOptions) selected.add(String(o.value));
329
+ });
330
+ wrap.appendChild(lab);
331
+ wrap.appendChild(select);
332
+ form.appendChild(wrap);
333
+ const row = document.createElement('div');
334
+ row.className = 'row';
335
+ const ok = mkBtn('Submit');
336
+ ok.addEventListener('click', () => submit({ status: 'ok', value: multiple ? Array.from(selected) : Array.from(selected)[0] || '' }));
337
+ const cancel = mkBtn('Cancel', true);
338
+ cancel.addEventListener('click', () => submit({ status: 'cancel' }));
339
+ row.appendChild(ok);
340
+ row.appendChild(cancel);
341
+ form.appendChild(row);
342
+ } else {
343
+ const row = document.createElement('div');
344
+ row.className = 'row';
345
+ const ok = mkBtn('OK');
346
+ ok.addEventListener('click', () => submit({ status: 'ok' }));
347
+ const cancel = mkBtn('Cancel', true);
348
+ cancel.addEventListener('click', () => submit({ status: 'cancel' }));
349
+ row.appendChild(ok);
350
+ row.appendChild(cancel);
351
+ form.appendChild(row);
352
+ }
353
+
354
+ card.appendChild(title);
355
+ if (msg.textContent) card.appendChild(msg);
356
+ if (source.textContent) card.appendChild(source);
357
+ card.appendChild(form);
358
+ panelBody.appendChild(card);
359
+ }
360
+ }
361
+
362
+ const getTheme = () => (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
363
+
364
+ const host = {
365
+ bridge: { enabled: true },
366
+ context: { get: () => ({ pluginId: __SANDBOX__.pluginId, appId: __SANDBOX__.appId, theme: getTheme(), bridge: { enabled: true } }) },
367
+ theme: {
368
+ get: getTheme,
369
+ onChange: (listener) => {
370
+ if (!window.matchMedia || typeof listener !== 'function') return () => {};
371
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
372
+ const fn = () => { try { listener(getTheme()); } catch {} };
373
+ mq.addEventListener('change', fn);
374
+ return () => mq.removeEventListener('change', fn);
375
+ },
376
+ },
377
+ admin: {
378
+ state: async () => ({ ok: true, state: {} }),
379
+ onUpdate: () => () => {},
380
+ models: { list: async () => ({ ok: true, models: [] }) },
381
+ secrets: { list: async () => ({ ok: true, secrets: [] }) },
382
+ },
383
+ registry: {
384
+ list: async () => ({ ok: true, apps: [__SANDBOX__.registryApp] }),
385
+ },
386
+ backend: {
387
+ invoke: async (method, params) => {
388
+ const r = await fetch('/api/backend/invoke', {
389
+ method: 'POST',
390
+ headers: { 'content-type': 'application/json' },
391
+ body: JSON.stringify({ method, params }),
392
+ });
393
+ const j = await r.json();
394
+ if (j?.ok === false) throw new Error(j?.message || 'invoke failed');
395
+ return j?.result;
396
+ },
397
+ },
398
+ uiPrompts: {
399
+ read: async () => ({ path: '(sandbox)', entries: [...entries] }),
400
+ onUpdate: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
401
+ request: async (payload) => {
402
+ const requestId = payload?.requestId ? String(payload.requestId) : uuid();
403
+ const prompt = payload?.prompt && typeof payload.prompt === 'object' ? { ...payload.prompt } : null;
404
+ if (prompt && !prompt.source) prompt.source = __SANDBOX__.pluginId + ':' + __SANDBOX__.appId;
405
+ entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'request', requestId, runId: payload?.runId, prompt });
406
+ emitUpdate();
407
+ return { ok: true, requestId };
408
+ },
409
+ respond: async (payload) => {
410
+ const requestId = String(payload?.requestId || '');
411
+ if (!requestId) throw new Error('requestId is required');
412
+ const response = payload?.response && typeof payload.response === 'object' ? payload.response : null;
413
+ entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'response', requestId, runId: payload?.runId, response });
414
+ emitUpdate();
415
+ return { ok: true };
416
+ },
417
+ open: () => (setPanelOpen(true), { ok: true }),
418
+ close: () => (setPanelOpen(false), { ok: true }),
419
+ toggle: () => (setPanelOpen(panel.style.display !== 'flex'), { ok: true }),
420
+ },
421
+ ui: { navigate: (menu) => ({ ok: true, menu }) },
422
+ chat: (() => {
423
+ const clone = (v) => JSON.parse(JSON.stringify(v));
424
+
425
+ const agents = [
426
+ {
427
+ id: 'sandbox-agent',
428
+ name: 'Sandbox Agent',
429
+ description: 'Mock agent for ChatOS UI Apps Sandbox',
430
+ },
431
+ ];
432
+
433
+ const sessions = new Map();
434
+ const defaultSessionByAgent = new Map();
435
+ const messagesBySession = new Map();
436
+
437
+ const listeners = new Set();
438
+ const activeRuns = new Map(); // sessionId -> { aborted: boolean, timers: number[] }
439
+
440
+ const emit = (payload) => {
441
+ for (const sub of listeners) {
442
+ const filter = sub?.filter && typeof sub.filter === 'object' ? sub.filter : {};
443
+ if (filter?.sessionId && String(filter.sessionId) !== String(payload?.sessionId || '')) continue;
444
+ if (Array.isArray(filter?.types) && filter.types.length > 0) {
445
+ const t = String(payload?.type || '');
446
+ if (!filter.types.includes(t)) continue;
447
+ }
448
+ try {
449
+ sub.fn(payload);
450
+ } catch {
451
+ // ignore
452
+ }
453
+ }
454
+ };
455
+
456
+ const ensureAgent = async () => {
457
+ if (agents.length > 0) return agents[0];
458
+ const created = { id: 'sandbox-agent', name: 'Sandbox Agent', description: 'Mock agent' };
459
+ agents.push(created);
460
+ return created;
461
+ };
462
+
463
+ const ensureSession = async (agentId) => {
464
+ const aid = String(agentId || '').trim() || (await ensureAgent()).id;
465
+ const existingId = defaultSessionByAgent.get(aid);
466
+ if (existingId && sessions.has(existingId)) return sessions.get(existingId);
467
+
468
+ const id = 'sandbox-session-' + uuid();
469
+ const session = { id, agentId: aid, createdAt: new Date().toISOString() };
470
+ sessions.set(id, session);
471
+ defaultSessionByAgent.set(aid, id);
472
+ if (!messagesBySession.has(id)) messagesBySession.set(id, []);
473
+ return session;
474
+ };
475
+
476
+ const agentsApi = {
477
+ list: async () => ({ ok: true, agents: clone(agents) }),
478
+ ensureDefault: async () => ({ ok: true, agent: clone(await ensureAgent()) }),
479
+ create: async (payload) => {
480
+ const agent = {
481
+ id: 'sandbox-agent-' + uuid(),
482
+ name: payload?.name ? String(payload.name) : 'Sandbox Agent',
483
+ description: payload?.description ? String(payload.description) : '',
484
+ };
485
+ agents.unshift(agent);
486
+ return { ok: true, agent: clone(agent) };
487
+ },
488
+ update: async (id, patch) => {
489
+ const agentId = String(id || '').trim();
490
+ if (!agentId) throw new Error('id is required');
491
+ const idx = agents.findIndex((a) => a.id === agentId);
492
+ if (idx < 0) throw new Error('agent not found');
493
+ const a = agents[idx];
494
+ if (patch?.name) a.name = String(patch.name);
495
+ if (patch?.description) a.description = String(patch.description);
496
+ return { ok: true, agent: clone(a) };
497
+ },
498
+ delete: async (id) => {
499
+ const agentId = String(id || '').trim();
500
+ if (!agentId) throw new Error('id is required');
501
+ const idx = agents.findIndex((a) => a.id === agentId);
502
+ if (idx < 0) return { ok: true, deleted: false };
503
+ agents.splice(idx, 1);
504
+ return { ok: true, deleted: true };
505
+ },
506
+ createForApp: async (payload) => {
507
+ const name = payload?.name ? String(payload.name) : 'App Agent (' + __SANDBOX__.appId + ')';
508
+ return await agentsApi.create({ ...payload, name });
509
+ },
510
+ };
511
+
512
+ const sessionsApi = {
513
+ list: async () => ({ ok: true, sessions: clone(Array.from(sessions.values())) }),
514
+ ensureDefault: async (payload) => {
515
+ const session = await ensureSession(payload?.agentId);
516
+ return { ok: true, session: clone(session) };
517
+ },
518
+ create: async (payload) => {
519
+ const agentId = payload?.agentId ? String(payload.agentId) : (await ensureAgent()).id;
520
+ const id = 'sandbox-session-' + uuid();
521
+ const session = { id, agentId, createdAt: new Date().toISOString() };
522
+ sessions.set(id, session);
523
+ if (!messagesBySession.has(id)) messagesBySession.set(id, []);
524
+ return { ok: true, session: clone(session) };
525
+ },
526
+ };
527
+
528
+ const messagesApi = {
529
+ list: async (payload) => {
530
+ const sessionId = String(payload?.sessionId || '').trim();
531
+ if (!sessionId) throw new Error('sessionId is required');
532
+ const msgs = messagesBySession.get(sessionId) || [];
533
+ return { ok: true, messages: clone(msgs) };
534
+ },
535
+ };
536
+
537
+ const abort = async (payload) => {
538
+ const sessionId = String(payload?.sessionId || '').trim();
539
+ if (!sessionId) throw new Error('sessionId is required');
540
+ const run = activeRuns.get(sessionId);
541
+ if (run) {
542
+ run.aborted = true;
543
+ for (const t of run.timers) {
544
+ try {
545
+ clearTimeout(t);
546
+ } catch {
547
+ // ignore
548
+ }
549
+ }
550
+ activeRuns.delete(sessionId);
551
+ }
552
+ emit({ type: 'assistant_abort', sessionId, ts: new Date().toISOString() });
553
+ return { ok: true };
554
+ };
555
+
556
+ const send = async (payload) => {
557
+ const sessionId = String(payload?.sessionId || '').trim();
558
+ const text = String(payload?.text || '').trim();
559
+ if (!sessionId) throw new Error('sessionId is required');
560
+ if (!text) throw new Error('text is required');
561
+
562
+ if (!sessions.has(sessionId)) throw new Error('session not found');
563
+
564
+ const msgs = messagesBySession.get(sessionId) || [];
565
+ const userMsg = { id: 'msg-' + uuid(), role: 'user', text, ts: new Date().toISOString() };
566
+ msgs.push(userMsg);
567
+ messagesBySession.set(sessionId, msgs);
568
+ emit({ type: 'user_message', sessionId, message: clone(userMsg) });
569
+
570
+ const assistantMsg = { id: 'msg-' + uuid(), role: 'assistant', text: '', ts: new Date().toISOString() };
571
+ msgs.push(assistantMsg);
572
+ emit({ type: 'assistant_start', sessionId, message: clone(assistantMsg) });
573
+
574
+ const out = '[sandbox] echo: ' + text;
575
+ const chunks = [];
576
+ for (let i = 0; i < out.length; i += 8) chunks.push(out.slice(i, i + 8));
577
+
578
+ const run = { aborted: false, timers: [] };
579
+ activeRuns.set(sessionId, run);
580
+
581
+ chunks.forEach((delta, idx) => {
582
+ const t = setTimeout(() => {
583
+ if (run.aborted) return;
584
+ assistantMsg.text += delta;
585
+ emit({ type: 'assistant_delta', sessionId, delta });
586
+ if (idx === chunks.length - 1) {
587
+ activeRuns.delete(sessionId);
588
+ emit({ type: 'assistant_end', sessionId, message: clone(assistantMsg) });
589
+ }
590
+ }, 80 + idx * 60);
591
+ run.timers.push(t);
592
+ });
593
+
594
+ return { ok: true };
595
+ };
596
+
597
+ const events = {
598
+ subscribe: (filter, fn) => {
599
+ if (typeof fn !== 'function') throw new Error('listener is required');
600
+ const sub = { filter: filter && typeof filter === 'object' ? { ...filter } : {}, fn };
601
+ listeners.add(sub);
602
+ return () => listeners.delete(sub);
603
+ },
604
+ unsubscribe: () => (listeners.clear(), { ok: true }),
605
+ };
606
+
607
+ return {
608
+ agents: agentsApi,
609
+ sessions: sessionsApi,
610
+ messages: messagesApi,
611
+ send,
612
+ abort,
613
+ events,
614
+ };
615
+ })(),
616
+ };
617
+
618
+ let dispose = null;
619
+
620
+ async function loadAndMount() {
621
+ if (typeof dispose === 'function') { try { await dispose(); } catch {} dispose = null; }
622
+ container.textContent = '';
623
+
624
+ const entryUrl = __SANDBOX__.entryUrl;
625
+ const mod = await import(entryUrl + (entryUrl.includes('?') ? '&' : '?') + 't=' + Date.now());
626
+ const mount = mod?.mount || mod?.default?.mount || (typeof mod?.default === 'function' ? mod.default : null);
627
+ if (typeof mount !== 'function') throw new Error('module entry must export mount()');
628
+ const ret = await mount({ container, host, slots: { header: headerSlot } });
629
+ if (typeof ret === 'function') dispose = ret;
630
+ else if (ret && typeof ret.dispose === 'function') dispose = () => ret.dispose();
631
+ }
632
+
633
+ const renderError = (e) => {
634
+ const pre = document.createElement('pre');
635
+ pre.style.padding = '12px';
636
+ pre.style.whiteSpace = 'pre-wrap';
637
+ pre.textContent = '[sandbox] ' + (e?.stack || e?.message || String(e));
638
+ container.appendChild(pre);
639
+ };
640
+
641
+ const scheduleReload = (() => {
642
+ let t = null;
643
+ return () => {
644
+ if (t) return;
645
+ t = setTimeout(() => {
646
+ t = null;
647
+ loadAndMount().catch(renderError);
648
+ }, 80);
649
+ };
650
+ })();
651
+
652
+ try {
653
+ const es = new EventSource('/events');
654
+ es.addEventListener('reload', () => scheduleReload());
655
+ } catch {
656
+ // ignore
657
+ }
658
+
659
+ $('#btnReload').addEventListener('click', () => loadAndMount().catch(renderError));
660
+
661
+ loadAndMount().catch(renderError);
662
+ `;
663
+ }
664
+
665
+ async function loadBackendFactory({ pluginDir, manifest }) {
666
+ const entryRel = manifest?.backend?.entry ? String(manifest.backend.entry).trim() : '';
667
+ if (!entryRel) return null;
668
+ const abs = resolveInsideDir(pluginDir, entryRel);
669
+ const fileUrl = url.pathToFileURL(abs).toString();
670
+ const mod = await import(fileUrl + `?t=${Date.now()}`);
671
+ if (typeof mod?.createUiAppsBackend !== 'function') {
672
+ throw new Error('backend entry must export createUiAppsBackend(ctx)');
673
+ }
674
+ return mod.createUiAppsBackend;
675
+ }
676
+
677
+ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' }) {
678
+ const { manifest } = loadPluginManifest(pluginDir);
679
+ const app = pickAppFromManifest(manifest, appId);
680
+ const effectiveAppId = String(app?.id || '');
681
+ const entryRel = String(app?.entry?.path || '').trim();
682
+ if (!entryRel) throw new Error('apps[i].entry.path is required');
683
+
684
+ const entryAbs = resolveInsideDir(pluginDir, entryRel);
685
+ if (!isFile(entryAbs)) throw new Error(`module entry not found: ${entryRel}`);
686
+
687
+ const entryUrl = `/plugin/${encodeURIComponent(entryRel).replaceAll('%2F', '/')}`;
688
+
689
+ let backendInstance = null;
690
+ let backendFactory = null;
691
+
692
+ const ctxBase = {
693
+ pluginId: String(manifest?.id || ''),
694
+ pluginDir,
695
+ stateDir: path.join(process.cwd(), '.chatos', 'state', 'chatos'),
696
+ sessionRoot: process.cwd(),
697
+ projectRoot: process.cwd(),
698
+ dataDir: '',
699
+ llm: {
700
+ complete: async (payload) => {
701
+ const input = typeof payload?.input === 'string' ? payload.input : '';
702
+ const normalized = String(input || '').trim();
703
+ if (!normalized) throw new Error('input is required');
704
+ const modelName =
705
+ typeof payload?.modelName === 'string' && payload.modelName.trim()
706
+ ? payload.modelName.trim()
707
+ : typeof payload?.modelId === 'string' && payload.modelId.trim()
708
+ ? `model:${payload.modelId.trim()}`
709
+ : 'sandbox';
710
+ return {
711
+ ok: true,
712
+ model: modelName,
713
+ content: `[sandbox llm] ${normalized}`,
714
+ };
715
+ },
716
+ },
717
+ };
718
+ ctxBase.dataDir = path.join(process.cwd(), '.chatos', 'data', ctxBase.pluginId);
719
+ ensureDir(ctxBase.stateDir);
720
+ ensureDir(ctxBase.dataDir);
721
+
722
+ const sseClients = new Set();
723
+ const sseWrite = (res, event, data) => {
724
+ try {
725
+ res.write(`event: ${event}\n`);
726
+ res.write(`data: ${JSON.stringify(data ?? null)}\n\n`);
727
+ } catch {
728
+ // ignore
729
+ }
730
+ };
731
+ const sseBroadcast = (event, data) => {
732
+ for (const res of sseClients) {
733
+ sseWrite(res, event, data);
734
+ }
735
+ };
736
+
737
+ let changeSeq = 0;
738
+ const stopWatch = startRecursiveWatcher(pluginDir, ({ eventType, filePath }) => {
739
+ const rel = filePath ? path.relative(pluginDir, filePath).replaceAll('\\', '/') : '';
740
+ const base = rel ? path.basename(rel) : '';
741
+ if (!rel) return;
742
+ if (base === '.DS_Store') return;
743
+ if (base.endsWith('.map')) return;
744
+
745
+ changeSeq += 1;
746
+ if (rel.startsWith('backend/')) {
747
+ backendInstance = null;
748
+ backendFactory = null;
749
+ }
750
+ sseBroadcast('reload', { seq: changeSeq, eventType: eventType || '', path: rel });
751
+ });
752
+
753
+ const server = http.createServer(async (req, res) => {
754
+ try {
755
+ const parsed = url.parse(req.url || '/', true);
756
+ const pathname = parsed.pathname || '/';
757
+
758
+ if (req.method === 'GET' && pathname === '/') {
759
+ return sendText(res, 200, htmlPage(), 'text/html; charset=utf-8');
760
+ }
761
+
762
+ if (req.method === 'GET' && pathname === '/events') {
763
+ res.writeHead(200, {
764
+ 'content-type': 'text/event-stream; charset=utf-8',
765
+ 'cache-control': 'no-store',
766
+ connection: 'keep-alive',
767
+ });
768
+ res.write(': connected\n\n');
769
+ sseClients.add(res);
770
+ const ping = setInterval(() => {
771
+ try {
772
+ res.write(': ping\n\n');
773
+ } catch {
774
+ // ignore
775
+ }
776
+ }, 15000);
777
+ req.on('close', () => {
778
+ try {
779
+ clearInterval(ping);
780
+ } catch {
781
+ // ignore
782
+ }
783
+ sseClients.delete(res);
784
+ });
785
+ return;
786
+ }
787
+
788
+ if (req.method === 'GET' && pathname === '/sandbox.mjs') {
789
+ const js = sandboxClientJs()
790
+ .replaceAll('__SANDBOX__.pluginId', JSON.stringify(ctxBase.pluginId))
791
+ .replaceAll('__SANDBOX__.appId', JSON.stringify(effectiveAppId))
792
+ .replaceAll('__SANDBOX__.entryUrl', JSON.stringify(entryUrl))
793
+ .replaceAll('__SANDBOX__.registryApp', JSON.stringify({ plugin: { id: ctxBase.pluginId }, id: effectiveAppId, entry: { type: 'module', url: entryUrl } }));
794
+ return sendText(res, 200, js, 'text/javascript; charset=utf-8');
795
+ }
796
+
797
+ if (req.method === 'GET' && pathname.startsWith('/plugin/')) {
798
+ const rel = decodeURIComponent(pathname.slice('/plugin/'.length));
799
+ const abs = resolveInsideDir(pluginDir, rel);
800
+ if (!serveStaticFile(res, abs)) return sendText(res, 404, 'Not found');
801
+ return;
802
+ }
803
+
804
+ if (req.method === 'GET' && pathname === '/api/manifest') {
805
+ return sendJson(res, 200, { ok: true, manifest });
806
+ }
807
+
808
+ if (pathname === '/api/backend/invoke') {
809
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, message: 'Method not allowed' });
810
+ let body = '';
811
+ req.on('data', (chunk) => {
812
+ body += chunk;
813
+ });
814
+ req.on('end', async () => {
815
+ try {
816
+ const payload = body ? JSON.parse(body) : {};
817
+ const method = typeof payload?.method === 'string' ? payload.method.trim() : '';
818
+ if (!method) return sendJson(res, 400, { ok: false, message: 'method is required' });
819
+ const params = payload?.params;
820
+
821
+ if (!backendFactory) backendFactory = await loadBackendFactory({ pluginDir, manifest });
822
+ if (!backendFactory) return sendJson(res, 200, { ok: false, message: 'backend not configured in plugin.json' });
823
+
824
+ if (!backendInstance || typeof backendInstance !== 'object' || !backendInstance.methods) {
825
+ backendInstance = await backendFactory({ ...ctxBase });
826
+ }
827
+ const fn = backendInstance?.methods?.[method];
828
+ if (typeof fn !== 'function') return sendJson(res, 404, { ok: false, message: `method not found: ${method}` });
829
+ const result = await fn(params, { ...ctxBase });
830
+ return sendJson(res, 200, { ok: true, result });
831
+ } catch (e) {
832
+ return sendJson(res, 200, { ok: false, message: e?.message || String(e) });
833
+ }
834
+ });
835
+ return;
836
+ }
837
+
838
+ sendText(res, 404, 'Not found');
839
+ } catch (e) {
840
+ sendJson(res, 500, { ok: false, message: e?.message || String(e) });
841
+ }
842
+ });
843
+ server.once('close', () => stopWatch());
844
+
845
+ await new Promise((resolve, reject) => {
846
+ server.once('error', reject);
847
+ server.listen(port, '127.0.0.1', () => {
848
+ server.off('error', reject);
849
+ resolve();
850
+ });
851
+ });
852
+
853
+ // eslint-disable-next-line no-console
854
+ console.log(`Sandbox running:
855
+ http://localhost:${port}/
856
+ pluginDir:
857
+ ${pluginDir}
858
+ app:
859
+ ${ctxBase.pluginId}:${effectiveAppId}
860
+ `);
861
+ }