@leeoohoo/ui-apps-devkit 0.1.0 → 0.1.2

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