@leeoohoo/ui-apps-devkit 0.1.1 → 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.
@@ -1,37 +1,447 @@
1
1
  import fs from 'fs';
2
- import http from 'http';
3
- import path from 'path';
4
- import url from 'url';
5
-
6
- import { ensureDir, isDirectory, isFile } from '../lib/fs.js';
7
- import { loadPluginManifest, pickAppFromManifest } from '../lib/plugin.js';
8
- import { resolveInsideDir } from '../lib/path-boundary.js';
2
+ import http from 'http';
3
+ import path from 'path';
4
+ import url from 'url';
5
+
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
8
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
9
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
10
+ import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
11
+
12
+ import { copyDir, ensureDir, isDirectory, isFile } from '../lib/fs.js';
13
+ import { loadPluginManifest, pickAppFromManifest } from '../lib/plugin.js';
14
+ import { resolveInsideDir } from '../lib/path-boundary.js';
15
+ import { COMPAT_STATE_ROOT_DIRNAME, STATE_ROOT_DIRNAME } from '../lib/state-constants.js';
9
16
 
10
17
  const __filename = url.fileURLToPath(import.meta.url);
11
- const __dirname = path.dirname(__filename);
12
-
13
- const TOKEN_REGEX = /--ds-[a-z0-9-]+/gi;
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;
14
23
  const GLOBAL_STYLES_CANDIDATES = [
15
24
  path.resolve(__dirname, '..', '..', '..', 'common', 'aide-ui', 'components', 'GlobalStyles.jsx'),
16
25
  path.resolve(process.cwd(), 'common', 'aide-ui', 'components', 'GlobalStyles.jsx'),
17
26
  ];
18
27
 
19
- function loadTokenNames() {
20
- for (const candidate of GLOBAL_STYLES_CANDIDATES) {
21
- try {
22
- if (!isFile(candidate)) continue;
23
- const raw = fs.readFileSync(candidate, 'utf8');
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');
24
33
  const matches = raw.match(TOKEN_REGEX) || [];
25
34
  const names = Array.from(new Set(matches.map((v) => v.toLowerCase())));
26
35
  if (names.length > 0) return names.sort();
27
36
  } catch {
28
37
  // ignore
29
38
  }
30
- }
31
- return [];
32
- }
33
-
34
-
39
+ }
40
+ return [];
41
+ }
42
+
43
+ function resolveSandboxRoots() {
44
+ const cwd = process.cwd();
45
+ const primary = path.join(cwd, SANDBOX_STATE_DIRNAME);
46
+ const legacy = path.join(cwd, SANDBOX_COMPAT_DIRNAME);
47
+ if (!isDirectory(primary) && isDirectory(legacy)) {
48
+ try {
49
+ copyDir(legacy, primary);
50
+ } catch {
51
+ // ignore compat copy errors
52
+ }
53
+ }
54
+ return { primary, legacy };
55
+ }
56
+
57
+ function resolveSandboxConfigPath({ primaryRoot, legacyRoot }) {
58
+ const primaryPath = path.join(primaryRoot, 'sandbox', 'llm-config.json');
59
+ if (isFile(primaryPath)) return primaryPath;
60
+ const legacyPath = path.join(legacyRoot, 'sandbox', 'llm-config.json');
61
+ if (isFile(legacyPath)) return legacyPath;
62
+ return primaryPath;
63
+ }
64
+
65
+ const DEFAULT_LLM_BASE_URL = 'https://api.openai.com/v1';
66
+
67
+ function normalizeText(value) {
68
+ return typeof value === 'string' ? value.trim() : '';
69
+ }
70
+
71
+ function isPlainObject(value) {
72
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
73
+ }
74
+
75
+ function cloneValue(value) {
76
+ if (Array.isArray(value)) {
77
+ return value.map((entry) => cloneValue(entry));
78
+ }
79
+ if (isPlainObject(value)) {
80
+ const out = {};
81
+ Object.entries(value).forEach(([key, entry]) => {
82
+ out[key] = cloneValue(entry);
83
+ });
84
+ return out;
85
+ }
86
+ return value;
87
+ }
88
+
89
+ function mergeCallMeta(base, override) {
90
+ if (!base && !override) return null;
91
+ if (!base) return cloneValue(override);
92
+ if (!override) return cloneValue(base);
93
+ if (!isPlainObject(base) || !isPlainObject(override)) {
94
+ return cloneValue(override);
95
+ }
96
+ const merged = cloneValue(base);
97
+ Object.entries(override).forEach(([key, value]) => {
98
+ if (isPlainObject(value) && isPlainObject(merged[key])) {
99
+ merged[key] = mergeCallMeta(merged[key], value);
100
+ } else {
101
+ merged[key] = cloneValue(value);
102
+ }
103
+ });
104
+ return merged;
105
+ }
106
+
107
+ function expandCallMetaValue(value, vars) {
108
+ if (typeof value === 'string') {
109
+ let text = value;
110
+ Object.entries(vars).forEach(([key, replacement]) => {
111
+ const token = `$${key}`;
112
+ text = text.split(token).join(String(replacement || ''));
113
+ });
114
+ return text;
115
+ }
116
+ if (Array.isArray(value)) {
117
+ return value.map((entry) => expandCallMetaValue(entry, vars));
118
+ }
119
+ if (isPlainObject(value)) {
120
+ const out = {};
121
+ Object.entries(value).forEach(([key, entry]) => {
122
+ out[key] = expandCallMetaValue(entry, vars);
123
+ });
124
+ return out;
125
+ }
126
+ return value;
127
+ }
128
+
129
+ function buildSandboxCallMeta({ rawCallMeta, 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 || '',
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 });
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
+ }
199
+
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 = [];
337
+ try {
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}`);
345
+ }
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}`);
354
+ }
355
+ throw new Error(`Failed to connect MCP server (${serverName}): ${errors.join(' | ')}`);
356
+ }
357
+
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,
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
+ }
444
+
35
445
  function sendJson(res, status, obj) {
36
446
  const raw = JSON.stringify(obj);
37
447
  res.writeHead(status, {
@@ -41,16 +451,33 @@ function sendJson(res, status, obj) {
41
451
  res.end(raw);
42
452
  }
43
453
 
44
- function sendText(res, status, text, contentType) {
45
- res.writeHead(status, {
46
- 'content-type': contentType || 'text/plain; charset=utf-8',
47
- 'cache-control': 'no-store',
48
- });
49
- res.end(text);
50
- }
51
-
52
- function guessContentType(filePath) {
53
- const ext = path.extname(filePath).toLowerCase();
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
+ }
461
+
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({});
470
+ try {
471
+ resolve(JSON.parse(body));
472
+ } catch (err) {
473
+ reject(err);
474
+ }
475
+ });
476
+ });
477
+ }
478
+
479
+ function guessContentType(filePath) {
480
+ const ext = path.extname(filePath).toLowerCase();
54
481
  if (ext === '.html') return 'text/html; charset=utf-8';
55
482
  if (ext === '.css') return 'text/css; charset=utf-8';
56
483
  if (ext === '.mjs' || ext === '.js') return 'text/javascript; charset=utf-8';
@@ -271,17 +698,46 @@ function htmlPage() {
271
698
  justify-content: space-between;
272
699
  border-bottom: 1px solid var(--ds-panel-border);
273
700
  }
274
- #sandboxInspectorBody {
275
- padding: 10px 12px;
276
- overflow: auto;
277
- display: flex;
278
- flex-direction: column;
279
- gap: 10px;
280
- }
281
- .section-title { font-size: 12px; font-weight: 700; opacity: 0.8; }
282
- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; white-space: pre-wrap; }
283
- input, textarea, select {
284
- width:100%;
701
+ #sandboxInspectorBody {
702
+ padding: 10px 12px;
703
+ overflow: auto;
704
+ display: flex;
705
+ flex-direction: column;
706
+ gap: 10px;
707
+ }
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%;
285
741
  padding:8px;
286
742
  border-radius:10px;
287
743
  border:1px solid var(--ds-panel-border);
@@ -308,32 +764,68 @@ function htmlPage() {
308
764
  <button id="btnThemeDark" class="btn" type="button">Dark</button>
309
765
  <button id="btnThemeSystem" class="btn" type="button">System</button>
310
766
  </div>
311
- <div id="themeStatus" class="muted"></div>
312
- <div id="sandboxContext" class="muted"></div>
313
- <button id="btnInspectorToggle" class="btn" type="button">Inspect</button>
314
- <button id="btnReload" class="btn" type="button">Reload</button>
315
- </div>
316
- </div>
317
- </div>
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>
771
+ <button id="btnReload" class="btn" type="button">Reload</button>
772
+ </div>
773
+ </div>
774
+ </div>
318
775
  <div id="headerSlot"></div>
319
776
  <div id="container"><div id="containerInner"></div></div>
320
777
  </div>
321
778
 
322
779
  <button id="promptsFab" class="btn" type="button">:)</button>
323
780
 
324
- <div id="promptsPanel">
325
- <div id="promptsPanelHeader">
326
- <div style="font-weight:800">UI Prompts</div>
327
- <button id="promptsClose" class="btn" type="button">Close</button>
328
- </div>
329
- <div id="promptsPanelBody"></div>
330
- </div>
331
-
332
- <div id="sandboxInspector" aria-hidden="true">
333
- <div id="sandboxInspectorHeader">
334
- <div style="font-weight:800">Sandbox Inspector</div>
335
- <div class="row">
336
- <button id="btnInspectorRefresh" class="btn" type="button">Refresh</button>
781
+ <div id="promptsPanel">
782
+ <div id="promptsPanelHeader">
783
+ <div style="font-weight:800">UI Prompts</div>
784
+ <button id="promptsClose" class="btn" type="button">Close</button>
785
+ </div>
786
+ <div id="promptsPanelBody"></div>
787
+ </div>
788
+
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>
823
+
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>
337
829
  <button id="btnInspectorClose" class="btn" type="button">Close</button>
338
830
  </div>
339
831
  </div>
@@ -374,16 +866,28 @@ const themeStatus = $('#themeStatus');
374
866
  const sandboxContext = $('#sandboxContext');
375
867
  const btnInspectorToggle = $('#btnInspectorToggle');
376
868
  const sandboxInspector = $('#sandboxInspector');
377
- const btnInspectorClose = $('#btnInspectorClose');
378
- const btnInspectorRefresh = $('#btnInspectorRefresh');
379
- const inspectorContext = $('#inspectorContext');
380
- const inspectorTheme = $('#inspectorTheme');
381
- const inspectorTokens = $('#inspectorTokens');
382
-
383
- const setPanelOpen = (open) => { panel.style.display = open ? 'flex' : 'none'; };
384
- fab.addEventListener('click', () => setPanelOpen(panel.style.display !== 'flex'));
385
- panelClose.addEventListener('click', () => setPanelOpen(false));
386
- window.addEventListener('chatos:uiPrompts:open', () => setPanelOpen(true));
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');
886
+
887
+ const setPanelOpen = (open) => { panel.style.display = open ? 'flex' : 'none'; };
888
+ fab.addEventListener('click', () => setPanelOpen(panel.style.display !== 'flex'));
889
+ panelClose.addEventListener('click', () => setPanelOpen(false));
890
+ window.addEventListener('chatos:uiPrompts:open', () => setPanelOpen(true));
387
891
  window.addEventListener('chatos:uiPrompts:close', () => setPanelOpen(false));
388
892
  window.addEventListener('chatos:uiPrompts:toggle', () => setPanelOpen(panel.style.display !== 'flex'));
389
893
 
@@ -432,22 +936,83 @@ const updateThemeControls = () => {
432
936
  }
433
937
  };
434
938
 
435
- const updateContextStatus = () => {
436
- if (!sandboxContext) return;
437
- sandboxContext.textContent = __SANDBOX__.pluginId + ':' + __SANDBOX__.appId;
438
- };
439
-
440
- const isInspectorOpen = () => sandboxInspector && sandboxInspector.style.display === 'flex';
441
-
442
- const formatJson = (value) => {
443
- try {
444
- return JSON.stringify(value, null, 2);
445
- } catch {
939
+ const updateContextStatus = () => {
940
+ if (!sandboxContext) return;
941
+ sandboxContext.textContent = __SANDBOX__.pluginId + ':' + __SANDBOX__.appId;
942
+ };
943
+
944
+ const isInspectorOpen = () => sandboxInspector && sandboxInspector.style.display === 'flex';
945
+ const isLlmPanelOpen = () => llmPanel && llmPanel.style.display === 'flex';
946
+
947
+ const setLlmStatus = (text, isError) => {
948
+ if (!llmStatus) return;
949
+ llmStatus.textContent = text || '';
950
+ llmStatus.style.color = isError ? '#ef4444' : '';
951
+ };
952
+
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
+ };
969
+
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 : '',
977
+ };
978
+ const apiKey = llmApiKey ? llmApiKey.value : '';
979
+ if (clearKey) {
980
+ payload.apiKey = '';
981
+ } else if (apiKey && apiKey.trim()) {
982
+ payload.apiKey = apiKey.trim();
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
+ };
1005
+
1006
+ const formatJson = (value) => {
1007
+ try {
1008
+ return JSON.stringify(value, null, 2);
1009
+ } catch {
446
1010
  return String(value);
447
1011
  }
448
1012
  };
449
1013
 
450
- const tokenNameList = Array.isArray(__SANDBOX__.tokenNames) ? __SANDBOX__.tokenNames : [];
1014
+ const tokenNameList = Array.isArray(__SANDBOX__.tokenNames) ? __SANDBOX__.tokenNames : [];
1015
+ const sandboxContextBase = __SANDBOX__.context || { pluginId: __SANDBOX__.pluginId, appId: __SANDBOX__.appId };
451
1016
 
452
1017
  const collectTokens = () => {
453
1018
  const style = getComputedStyle(document.documentElement);
@@ -465,11 +1030,11 @@ const collectTokens = () => {
465
1030
  .join('\\n');
466
1031
  };
467
1032
 
468
- const readHostContext = () => {
469
- if (!inspectorEnabled) return null;
470
- if (typeof host?.context?.get === 'function') return host.context.get();
471
- return { pluginId: __SANDBOX__.pluginId, appId: __SANDBOX__.appId, theme: currentTheme, bridge: { enabled: true } };
472
- };
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
+ };
473
1038
 
474
1039
  const readThemeInfo = () => ({
475
1040
  themeMode,
@@ -539,12 +1104,17 @@ if (systemQuery && typeof systemQuery.addEventListener === 'function') {
539
1104
  });
540
1105
  }
541
1106
 
542
- if (btnThemeLight) btnThemeLight.addEventListener('click', () => applyThemeMode('light'));
543
- if (btnThemeDark) btnThemeDark.addEventListener('click', () => applyThemeMode('dark'));
544
- if (btnThemeSystem) btnThemeSystem.addEventListener('click', () => applyThemeMode('system'));
545
- if (btnInspectorToggle) btnInspectorToggle.addEventListener('click', () => setInspectorOpen(!isInspectorOpen()));
546
- if (btnInspectorClose) btnInspectorClose.addEventListener('click', () => setInspectorOpen(false));
547
- if (btnInspectorRefresh) btnInspectorRefresh.addEventListener('click', () => updateInspector());
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());
548
1118
 
549
1119
  applyThemeMode(themeMode || 'system', { persist: false });
550
1120
  updateContextStatus();
@@ -692,15 +1262,40 @@ function renderPrompts() {
692
1262
  if (source.textContent) card.appendChild(source);
693
1263
  card.appendChild(form);
694
1264
  panelBody.appendChild(card);
695
- }
696
- }
697
-
698
- const getTheme = () => currentTheme || resolveTheme();
699
-
700
- const host = {
701
- bridge: { enabled: true },
702
- context: { get: () => ({ pluginId: __SANDBOX__.pluginId, appId: __SANDBOX__.appId, theme: getTheme(), bridge: { enabled: true } }) },
703
- theme: {
1265
+ }
1266
+ }
1267
+
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
+ };
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
+
1295
+ const host = {
1296
+ bridge: { enabled: true },
1297
+ context: { get: () => ({ ...sandboxContextBase, theme: getTheme(), bridge: { enabled: true } }) },
1298
+ theme: {
704
1299
  get: getTheme,
705
1300
  onChange: (listener) => {
706
1301
  if (typeof listener !== 'function') return () => {};
@@ -810,25 +1405,27 @@ const host = {
810
1405
  const agentsApi = {
811
1406
  list: async () => ({ ok: true, agents: clone(agents) }),
812
1407
  ensureDefault: async () => ({ ok: true, agent: clone(await ensureAgent()) }),
813
- create: async (payload) => {
814
- const agent = {
815
- id: 'sandbox-agent-' + uuid(),
816
- name: payload?.name ? String(payload.name) : 'Sandbox Agent',
817
- description: payload?.description ? String(payload.description) : '',
818
- };
819
- agents.unshift(agent);
820
- return { ok: true, agent: clone(agent) };
821
- },
822
- update: async (id, patch) => {
1408
+ create: async (payload) => {
1409
+ const agent = {
1410
+ id: 'sandbox-agent-' + uuid(),
1411
+ name: payload?.name ? String(payload.name) : 'Sandbox Agent',
1412
+ description: payload?.description ? String(payload.description) : '',
1413
+ modelId: payload?.modelId ? String(payload.modelId) : '',
1414
+ };
1415
+ agents.unshift(agent);
1416
+ return { ok: true, agent: clone(agent) };
1417
+ },
1418
+ update: async (id, patch) => {
823
1419
  const agentId = String(id || '').trim();
824
1420
  if (!agentId) throw new Error('id is required');
825
- const idx = agents.findIndex((a) => a.id === agentId);
826
- if (idx < 0) throw new Error('agent not found');
827
- const a = agents[idx];
828
- if (patch?.name) a.name = String(patch.name);
829
- if (patch?.description) a.description = String(patch.description);
830
- return { ok: true, agent: clone(a) };
831
- },
1421
+ const idx = agents.findIndex((a) => a.id === agentId);
1422
+ if (idx < 0) throw new Error('agent not found');
1423
+ const a = agents[idx];
1424
+ if (patch?.name) a.name = String(patch.name);
1425
+ if (patch?.description) a.description = String(patch.description);
1426
+ if (patch?.modelId) a.modelId = String(patch.modelId);
1427
+ return { ok: true, agent: clone(a) };
1428
+ },
832
1429
  delete: async (id) => {
833
1430
  const agentId = String(id || '').trim();
834
1431
  if (!agentId) throw new Error('id is required');
@@ -871,14 +1468,21 @@ const host = {
871
1468
  const abort = async (payload) => {
872
1469
  const sessionId = String(payload?.sessionId || '').trim();
873
1470
  if (!sessionId) throw new Error('sessionId is required');
874
- const run = activeRuns.get(sessionId);
875
- if (run) {
876
- run.aborted = true;
877
- for (const t of run.timers) {
878
- try {
879
- clearTimeout(t);
880
- } catch {
881
- // ignore
1471
+ const run = activeRuns.get(sessionId);
1472
+ if (run) {
1473
+ run.aborted = true;
1474
+ if (run.controller) {
1475
+ try {
1476
+ run.controller.abort();
1477
+ } catch {
1478
+ // ignore
1479
+ }
1480
+ }
1481
+ for (const t of run.timers) {
1482
+ try {
1483
+ clearTimeout(t);
1484
+ } catch {
1485
+ // ignore
882
1486
  }
883
1487
  }
884
1488
  activeRuns.delete(sessionId);
@@ -887,11 +1491,11 @@ const host = {
887
1491
  return { ok: true };
888
1492
  };
889
1493
 
890
- const send = async (payload) => {
891
- const sessionId = String(payload?.sessionId || '').trim();
892
- const text = String(payload?.text || '').trim();
893
- if (!sessionId) throw new Error('sessionId is required');
894
- if (!text) throw new Error('text is required');
1494
+ const send = async (payload) => {
1495
+ const sessionId = String(payload?.sessionId || '').trim();
1496
+ const text = String(payload?.text || '').trim();
1497
+ if (!sessionId) throw new Error('sessionId is required');
1498
+ if (!text) throw new Error('text is required');
895
1499
 
896
1500
  if (!sessions.has(sessionId)) throw new Error('session not found');
897
1501
 
@@ -901,32 +1505,81 @@ const host = {
901
1505
  messagesBySession.set(sessionId, msgs);
902
1506
  emit({ type: 'user_message', sessionId, message: clone(userMsg) });
903
1507
 
904
- const assistantMsg = { id: 'msg-' + uuid(), role: 'assistant', text: '', ts: new Date().toISOString() };
905
- msgs.push(assistantMsg);
906
- emit({ type: 'assistant_start', sessionId, message: clone(assistantMsg) });
907
-
908
- const out = '[sandbox] echo: ' + text;
909
- const chunks = [];
910
- for (let i = 0; i < out.length; i += 8) chunks.push(out.slice(i, i + 8));
911
-
912
- const run = { aborted: false, timers: [] };
913
- activeRuns.set(sessionId, run);
914
-
915
- chunks.forEach((delta, idx) => {
916
- const t = setTimeout(() => {
917
- if (run.aborted) return;
918
- assistantMsg.text += delta;
919
- emit({ type: 'assistant_delta', sessionId, delta });
920
- if (idx === chunks.length - 1) {
921
- activeRuns.delete(sessionId);
922
- emit({ type: 'assistant_end', sessionId, message: clone(assistantMsg) });
923
- }
924
- }, 80 + idx * 60);
925
- run.timers.push(t);
926
- });
927
-
928
- return { ok: true };
929
- };
1508
+ const assistantMsg = { id: 'msg-' + uuid(), role: 'assistant', text: '', ts: new Date().toISOString() };
1509
+ msgs.push(assistantMsg);
1510
+ emit({ type: 'assistant_start', sessionId, message: clone(assistantMsg) });
1511
+
1512
+ const run = { aborted: false, timers: [], controller: new AbortController() };
1513
+ activeRuns.set(sessionId, run);
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
+
1568
+ chunks.forEach((delta, idx) => {
1569
+ const t = setTimeout(() => {
1570
+ if (run.aborted) return;
1571
+ assistantMsg.text += delta;
1572
+ emit({ type: 'assistant_delta', sessionId, delta });
1573
+ if (idx === chunks.length - 1) {
1574
+ activeRuns.delete(sessionId);
1575
+ emit({ type: 'assistant_end', sessionId, message: clone(assistantMsg) });
1576
+ }
1577
+ }, 50 + idx * 40);
1578
+ run.timers.push(t);
1579
+ });
1580
+
1581
+ return { ok: true };
1582
+ };
930
1583
 
931
1584
  const events = {
932
1585
  subscribe: (filter, fn) => {
@@ -1021,40 +1674,236 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
1021
1674
  const entryAbs = resolveInsideDir(pluginDir, entryRel);
1022
1675
  if (!isFile(entryAbs)) throw new Error(`module entry not found: ${entryRel}`);
1023
1676
 
1024
- const entryUrl = `/plugin/${encodeURIComponent(entryRel).replaceAll('%2F', '/')}`;
1025
-
1026
- let backendInstance = null;
1027
- let backendFactory = null;
1028
-
1029
- const ctxBase = {
1030
- pluginId: String(manifest?.id || ''),
1031
- pluginDir,
1032
- stateDir: path.join(process.cwd(), '.chatos', 'state', 'chatos'),
1677
+ const entryUrl = `/plugin/${encodeURIComponent(entryRel).replaceAll('%2F', '/')}`;
1678
+
1679
+ let backendInstance = null;
1680
+ let backendFactory = null;
1681
+
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
+ }
1710
+ };
1711
+
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
+ };
1748
+
1749
+ const getSandboxLlmConfig = () => ({ ...sandboxLlmConfig });
1750
+
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
+ };
1770
+
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
+ }
1782
+
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
+ }
1859
+
1860
+ throw new Error('Too many tool calls. Aborting.');
1861
+ };
1862
+
1863
+ const ctxBase = {
1864
+ pluginId: String(manifest?.id || ''),
1865
+ pluginDir,
1866
+ stateDir: path.join(sandboxRoot, 'state', 'chatos'),
1033
1867
  sessionRoot: process.cwd(),
1034
- projectRoot: process.cwd(),
1035
- dataDir: '',
1036
- llm: {
1037
- complete: async (payload) => {
1038
- const input = typeof payload?.input === 'string' ? payload.input : '';
1039
- const normalized = String(input || '').trim();
1040
- if (!normalized) throw new Error('input is required');
1041
- const modelName =
1042
- typeof payload?.modelName === 'string' && payload.modelName.trim()
1043
- ? payload.modelName.trim()
1044
- : typeof payload?.modelId === 'string' && payload.modelId.trim()
1045
- ? `model:${payload.modelId.trim()}`
1046
- : 'sandbox';
1047
- return {
1048
- ok: true,
1049
- model: modelName,
1050
- content: `[sandbox llm] ${normalized}`,
1051
- };
1052
- },
1053
- },
1054
- };
1055
- ctxBase.dataDir = path.join(process.cwd(), '.chatos', 'data', ctxBase.pluginId);
1056
- ensureDir(ctxBase.stateDir);
1057
- ensureDir(ctxBase.dataDir);
1868
+ projectRoot: process.cwd(),
1869
+ dataDir: '',
1870
+ llm: {
1871
+ complete: async (payload) => {
1872
+ const input = typeof payload?.input === 'string' ? payload.input : '';
1873
+ const normalized = String(input || '').trim();
1874
+ if (!normalized) throw new Error('input is required');
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
+ });
1882
+ return {
1883
+ ok: true,
1884
+ model: result.model,
1885
+ content: result.content,
1886
+ toolTrace: result.toolTrace,
1887
+ };
1888
+ },
1889
+ },
1890
+ };
1891
+ ctxBase.dataDir = path.join(sandboxRoot, 'data', ctxBase.pluginId);
1892
+ ensureDir(ctxBase.stateDir);
1893
+ ensureDir(ctxBase.dataDir);
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
+ });
1058
1907
 
1059
1908
  const sseClients = new Set();
1060
1909
  const sseWrite = (res, event, data) => {
@@ -1079,13 +1928,14 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
1079
1928
  if (base === '.DS_Store') return;
1080
1929
  if (base.endsWith('.map')) return;
1081
1930
 
1082
- changeSeq += 1;
1083
- if (rel.startsWith('backend/')) {
1084
- backendInstance = null;
1085
- backendFactory = null;
1086
- }
1087
- sseBroadcast('reload', { seq: changeSeq, eventType: eventType || '', path: rel });
1088
- });
1931
+ changeSeq += 1;
1932
+ if (rel.startsWith('backend/')) {
1933
+ backendInstance = null;
1934
+ backendFactory = null;
1935
+ }
1936
+ resetMcpRuntime().catch(() => {});
1937
+ sseBroadcast('reload', { seq: changeSeq, eventType: eventType || '', path: rel });
1938
+ });
1089
1939
 
1090
1940
  const server = http.createServer(async (req, res) => {
1091
1941
  try {
@@ -1122,16 +1972,27 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
1122
1972
  return;
1123
1973
  }
1124
1974
 
1125
- if (req.method === 'GET' && pathname === '/sandbox.mjs') {
1126
- const tokenNames = loadTokenNames();
1127
- const js = sandboxClientJs()
1128
- .replaceAll('__SANDBOX__.pluginId', JSON.stringify(ctxBase.pluginId))
1129
- .replaceAll('__SANDBOX__.appId', JSON.stringify(effectiveAppId))
1130
- .replaceAll('__SANDBOX__.entryUrl', JSON.stringify(entryUrl))
1131
- .replaceAll('__SANDBOX__.registryApp', JSON.stringify({ plugin: { id: ctxBase.pluginId }, id: effectiveAppId, entry: { type: 'module', url: entryUrl } }))
1132
- .replaceAll('__SANDBOX__.tokenNames', JSON.stringify(tokenNames));
1133
- return sendText(res, 200, js, 'text/javascript; charset=utf-8');
1134
- }
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
+ };
1987
+ const js = sandboxClientJs()
1988
+ .replaceAll('__SANDBOX__.context', JSON.stringify(sandboxContext))
1989
+ .replaceAll('__SANDBOX__.pluginId', JSON.stringify(ctxBase.pluginId))
1990
+ .replaceAll('__SANDBOX__.appId', JSON.stringify(effectiveAppId))
1991
+ .replaceAll('__SANDBOX__.entryUrl', JSON.stringify(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));
1994
+ return sendText(res, 200, js, 'text/javascript; charset=utf-8');
1995
+ }
1135
1996
 
1136
1997
  if (req.method === 'GET' && pathname.startsWith('/plugin/')) {
1137
1998
  const rel = decodeURIComponent(pathname.slice('/plugin/'.length));
@@ -1140,15 +2001,77 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
1140
2001
  return;
1141
2002
  }
1142
2003
 
1143
- if (req.method === 'GET' && pathname === '/api/manifest') {
1144
- return sendJson(res, 200, { ok: true, manifest });
1145
- }
1146
-
1147
- if (pathname === '/api/backend/invoke') {
1148
- if (req.method !== 'POST') return sendJson(res, 405, { ok: false, message: 'Method not allowed' });
1149
- let body = '';
1150
- req.on('data', (chunk) => {
1151
- body += chunk;
2004
+ if (req.method === 'GET' && pathname === '/api/manifest') {
2005
+ return sendJson(res, 200, { ok: true, manifest });
2006
+ }
2007
+
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' });
2045
+ }
2046
+
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
+ }
2068
+ }
2069
+
2070
+ if (pathname === '/api/backend/invoke') {
2071
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, message: 'Method not allowed' });
2072
+ let body = '';
2073
+ req.on('data', (chunk) => {
2074
+ body += chunk;
1152
2075
  });
1153
2076
  req.on('end', async () => {
1154
2077
  try {
@@ -1179,7 +2102,10 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
1179
2102
  sendJson(res, 500, { ok: false, message: e?.message || String(e) });
1180
2103
  }
1181
2104
  });
1182
- server.once('close', () => stopWatch());
2105
+ server.once('close', () => {
2106
+ stopWatch();
2107
+ resetMcpRuntime().catch(() => {});
2108
+ });
1183
2109
 
1184
2110
  await new Promise((resolve, reject) => {
1185
2111
  server.once('error', reject);