@leeoohoo/ui-apps-devkit 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leeoohoo/ui-apps-devkit",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "ChatOS UI Apps DevKit (CLI + templates + sandbox) for building installable ChatOS UI Apps plugins.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,11 +1,29 @@
1
+ import fs from 'fs';
1
2
  import os from 'os';
2
3
  import path from 'path';
4
+ import crypto from 'crypto';
3
5
 
4
6
  import { copyDir, ensureDir, isDirectory, rmForce, sanitizeDirComponent } from '../lib/fs.js';
5
7
  import { loadDevkitConfig } from '../lib/config.js';
6
8
  import { findPluginDir, loadPluginManifest } from '../lib/plugin.js';
7
9
  import { STATE_ROOT_DIRNAME } from '../lib/state-constants.js';
8
10
 
11
+ function createActionId(prefix) {
12
+ const base = typeof prefix === 'string' && prefix.trim() ? prefix.trim() : 'action';
13
+ const short = crypto.randomUUID().split('-')[0];
14
+ return `${base}-${Date.now().toString(36)}-${short}`;
15
+ }
16
+
17
+ function logWith(filePath, entry) {
18
+ if (!filePath) return;
19
+ try {
20
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
21
+ fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`, 'utf8');
22
+ } catch {
23
+ // ignore logging failures
24
+ }
25
+ }
26
+
9
27
  function defaultStateDir(hostApp) {
10
28
  const app = typeof hostApp === 'string' && hostApp.trim() ? hostApp.trim() : 'chatos';
11
29
  return path.join(os.homedir(), STATE_ROOT_DIRNAME, app);
@@ -28,29 +46,59 @@ function copyPluginDir(srcDir, destDir) {
28
46
 
29
47
  export async function cmdInstall({ flags }) {
30
48
  const { config } = loadDevkitConfig(process.cwd());
31
- const pluginDir = findPluginDir(process.cwd(), flags['plugin-dir'] || flags.pluginDir || config?.pluginDir);
32
- const { pluginId, name, version } = loadPluginManifest(pluginDir);
33
-
49
+ const actionId = createActionId('devkit_install');
34
50
  const hostApp = String(flags['host-app'] || flags.hostApp || 'chatos').trim() || 'chatos';
35
51
  const stateDir = String(flags['state-dir'] || flags.stateDir || defaultStateDir(hostApp)).trim();
36
- if (!stateDir) throw new Error('stateDir is required');
52
+ const logFile = String(flags['log-file'] || flags.logFile || path.join(stateDir, 'devkit-install-log.jsonl')).trim();
53
+ const log = (level, message, meta, err) => {
54
+ const entry = {
55
+ ts: new Date().toISOString(),
56
+ level,
57
+ message,
58
+ actionId,
59
+ pid: process.pid,
60
+ meta: meta && typeof meta === 'object' ? meta : meta === undefined ? undefined : { value: meta },
61
+ error: err?.message || (err ? String(err) : undefined),
62
+ };
63
+ logWith(logFile, entry);
64
+ };
65
+
66
+ try {
67
+ if (!stateDir) throw new Error('stateDir is required');
68
+ log('info', 'devkit.install.start', { hostApp, stateDir, logFile });
69
+ const pluginDir = findPluginDir(process.cwd(), flags['plugin-dir'] || flags.pluginDir || config?.pluginDir);
70
+ const { pluginId, name, version } = loadPluginManifest(pluginDir);
71
+
72
+ const pluginsRoot = path.join(stateDir, 'ui_apps', 'plugins');
73
+ ensureDir(pluginsRoot);
37
74
 
38
- const pluginsRoot = path.join(stateDir, 'ui_apps', 'plugins');
39
- ensureDir(pluginsRoot);
75
+ const dirName = sanitizeDirComponent(pluginId);
76
+ if (!dirName) throw new Error(`Invalid plugin id: ${pluginId}`);
40
77
 
41
- const dirName = sanitizeDirComponent(pluginId);
42
- if (!dirName) throw new Error(`Invalid plugin id: ${pluginId}`);
78
+ const destDir = path.join(pluginsRoot, dirName);
79
+ const replaced = isDirectory(destDir);
80
+ copyPluginDir(pluginDir, destDir);
43
81
 
44
- const destDir = path.join(pluginsRoot, dirName);
45
- const replaced = isDirectory(destDir);
46
- copyPluginDir(pluginDir, destDir);
82
+ log('info', 'devkit.install.complete', {
83
+ pluginId,
84
+ name,
85
+ version,
86
+ pluginDir,
87
+ destDir,
88
+ replaced,
89
+ });
47
90
 
48
- // eslint-disable-next-line no-console
49
- console.log(
50
- `Installed: ${pluginId} (${name}@${version})\n` +
51
- ` -> ${destDir}\n` +
52
- ` replaced: ${replaced}\n\n` +
53
- `Open ChatOS -> 应用 -> 刷新(同 id 覆盖生效)。`
54
- );
91
+ // eslint-disable-next-line no-console
92
+ console.log(
93
+ `Installed: ${pluginId} (${name}@${version})\n` +
94
+ ` -> ${destDir}\n` +
95
+ ` replaced: ${replaced}\n` +
96
+ ` log: ${logFile}\n\n` +
97
+ `Open ChatOS -> 应用 -> 刷新(同 id 覆盖生效)。`
98
+ );
99
+ } catch (err) {
100
+ log('error', 'devkit.install.failed', {}, err);
101
+ throw err;
102
+ }
55
103
  }
56
104
 
@@ -18,11 +18,52 @@ function statSizeSafe(filePath) {
18
18
  }
19
19
  }
20
20
 
21
+ function collectSymlinks(rootDir) {
22
+ const root = typeof rootDir === 'string' ? rootDir.trim() : '';
23
+ if (!root) return [];
24
+ const out = [];
25
+ const walk = (dir) => {
26
+ let entries = [];
27
+ try {
28
+ entries = fs.readdirSync(dir, { withFileTypes: true });
29
+ } catch {
30
+ return;
31
+ }
32
+ entries.forEach((entry) => {
33
+ if (!entry || !entry.name) return;
34
+ const fullPath = path.join(dir, entry.name);
35
+ let stat;
36
+ try {
37
+ stat = fs.lstatSync(fullPath);
38
+ } catch {
39
+ return;
40
+ }
41
+ if (stat.isSymbolicLink()) {
42
+ out.push(fullPath);
43
+ return;
44
+ }
45
+ if (stat.isDirectory()) {
46
+ walk(fullPath);
47
+ }
48
+ });
49
+ };
50
+ walk(root);
51
+ return out;
52
+ }
53
+
21
54
  export async function cmdValidate({ flags }) {
22
55
  const { config } = loadDevkitConfig(process.cwd());
23
56
  const pluginDir = findPluginDir(process.cwd(), flags['plugin-dir'] || flags.pluginDir || config?.pluginDir);
24
57
 
25
58
  const { manifestPath, manifest } = loadPluginManifest(pluginDir);
59
+ const warnings = [];
60
+ const warn = (message) => warnings.push(message);
61
+
62
+ const symlinks = collectSymlinks(pluginDir);
63
+ if (symlinks.length > 0) {
64
+ const rel = symlinks.map((entry) => path.relative(pluginDir, entry) || entry);
65
+ warn(`Symlink detected inside plugin dir (may bypass path boundaries): ${rel.join(', ')}`);
66
+ }
26
67
 
27
68
  const manifestSize = statSizeSafe(manifestPath);
28
69
  assert(manifestSize <= 256 * 1024, `plugin.json too large (>256KiB): ${manifestSize} bytes`);
@@ -108,6 +149,13 @@ export async function cmdValidate({ flags }) {
108
149
  }
109
150
  }
110
151
 
152
+ if (warnings.length > 0) {
153
+ warnings.forEach((message) => {
154
+ // eslint-disable-next-line no-console
155
+ console.warn(`WARN: ${message}`);
156
+ });
157
+ }
158
+
111
159
  // eslint-disable-next-line no-console
112
160
  console.log(`OK: ${path.relative(process.cwd(), pluginDir)} (apps=${apps.length})`);
113
161
  }
@@ -628,6 +628,16 @@ function htmlPage() {
628
628
  }
629
629
  #container { flex: 1 1 auto; min-height:0; overflow:hidden; }
630
630
  #containerInner { height:100%; overflow:auto; }
631
+ body[data-surface='compact'] #headerSlot {
632
+ width: 50vw;
633
+ align-self: flex-end;
634
+ border-left: 1px solid var(--ds-panel-border);
635
+ }
636
+ body[data-surface='compact'] #container {
637
+ width: 50vw;
638
+ align-self: flex-end;
639
+ border-left: 1px solid var(--ds-panel-border);
640
+ }
631
641
  .muted { opacity: 0.7; font-size: 12px; }
632
642
  .bar { display:flex; gap:10px; align-items:center; justify-content:space-between; }
633
643
  .btn {
@@ -658,10 +668,12 @@ function htmlPage() {
658
668
  border-radius:14px;
659
669
  overflow:hidden;
660
670
  box-shadow: 0 18px 60px rgba(0,0,0,0.18);
671
+ z-index: 1305;
672
+ pointer-events: auto;
661
673
  }
662
674
  #promptsPanelHeader { padding: 10px 12px; display:flex; align-items:center; justify-content:space-between; border-bottom: 1px solid var(--ds-panel-border); }
663
675
  #promptsPanelBody { padding: 10px 12px; overflow:auto; display:flex; flex-direction:column; gap:10px; }
664
- #promptsFab { position: fixed; right: 16px; bottom: 16px; width: 44px; height: 44px; border-radius: 999px; display:flex; align-items:center; justify-content:center; }
676
+ #promptsFab { position: fixed; right: 16px; bottom: 16px; width: 44px; height: 44px; border-radius: 999px; display:flex; align-items:center; justify-content:center; z-index: 1305; pointer-events: auto; }
665
677
  .card { border: 1px solid var(--ds-panel-border); border-radius: 12px; padding: 10px; background: var(--ds-panel-bg); }
666
678
  .row { display:flex; gap:10px; }
667
679
  .toolbar-group { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
@@ -739,16 +751,6 @@ function htmlPage() {
739
751
  box-shadow: 0 14px 40px rgba(0,0,0,0.16);
740
752
  z-index: 12;
741
753
  }
742
- #mcpPanel[data-mode='half'] {
743
- right: 0;
744
- top: 72px;
745
- width: 50vw;
746
- height: calc(100vh - 84px);
747
- max-height: none;
748
- border-right: none;
749
- border-top-right-radius: 0;
750
- border-bottom-right-radius: 0;
751
- }
752
754
  #mcpPanelHeader {
753
755
  padding: 10px 12px;
754
756
  display:flex;
@@ -805,7 +807,7 @@ function htmlPage() {
805
807
  <div id="sandboxContext" class="muted"></div>
806
808
  <button id="btnLlmConfig" class="btn" type="button">AI Config</button>
807
809
  <button id="btnMcpTest" class="btn" type="button">MCP Test</button>
808
- <button id="btnMcpHalf" class="btn" type="button">Half Test</button>
810
+ <button id="btnHalfApp" class="btn" type="button">半屏</button>
809
811
  <button id="btnInspectorToggle" class="btn" type="button">Inspect</button>
810
812
  <button id="btnReload" class="btn" type="button">Reload</button>
811
813
  </div>
@@ -867,8 +869,8 @@ function htmlPage() {
867
869
  <div class="card">
868
870
  <div class="section-title">Paths</div>
869
871
  <pre id="mcpPaths" class="mono"></pre>
870
- <label for="mcpWorkdir">Workdir Override (optional)</label>
871
- <input id="mcpWorkdir" type="text" placeholder="留空则使用默认 workdir" />
872
+ <label for="mcpWorkdir">ProjectRoot Override (optional)</label>
873
+ <input id="mcpWorkdir" type="text" placeholder="留空则使用默认 projectRoot" />
872
874
  </div>
873
875
  <div class="card">
874
876
  <div class="section-title">Model</div>
@@ -961,7 +963,7 @@ const llmModelId = $('#llmModelId');
961
963
  const llmStatus = $('#llmStatus');
962
964
  const llmKeyStatus = $('#llmKeyStatus');
963
965
  const btnMcpTest = $('#btnMcpTest');
964
- const btnMcpHalf = $('#btnMcpHalf');
966
+ const btnHalfApp = $('#btnHalfApp');
965
967
  const mcpPanel = $('#mcpPanel');
966
968
  const btnMcpClose = $('#btnMcpClose');
967
969
  const btnMcpClear = $('#btnMcpClear');
@@ -1033,6 +1035,34 @@ const updateContextStatus = () => {
1033
1035
  sandboxContext.textContent = __SANDBOX__.pluginId + ':' + __SANDBOX__.appId;
1034
1036
  };
1035
1037
 
1038
+ const entryCompactUrl = __SANDBOX__.entryCompactUrl || '';
1039
+ const hasCompactEntry = Boolean(entryCompactUrl);
1040
+ let currentSurface = 'full';
1041
+
1042
+ const updateSurfaceState = (surface) => {
1043
+ currentSurface = surface === 'compact' ? 'compact' : 'full';
1044
+ document.body.dataset.surface = currentSurface;
1045
+ if (btnHalfApp) {
1046
+ if (!hasCompactEntry) {
1047
+ btnHalfApp.disabled = true;
1048
+ btnHalfApp.title = 'plugin.json 未配置 entry.compact.path';
1049
+ delete btnHalfApp.dataset.active;
1050
+ } else {
1051
+ btnHalfApp.disabled = false;
1052
+ btnHalfApp.title = '';
1053
+ btnHalfApp.dataset.active = currentSurface === 'compact' ? '1' : '0';
1054
+ }
1055
+ }
1056
+ if (host?.ui) host.ui.surface = currentSurface;
1057
+ };
1058
+
1059
+ const toggleCompactSurface = () => {
1060
+ if (!hasCompactEntry) return;
1061
+ updateSurfaceState(currentSurface === 'compact' ? 'full' : 'compact');
1062
+ loadAndMount().catch(renderError);
1063
+ updateInspectorIfOpen();
1064
+ };
1065
+
1036
1066
  const isInspectorOpen = () => sandboxInspector && sandboxInspector.style.display === 'flex';
1037
1067
  const isLlmPanelOpen = () => llmPanel && llmPanel.style.display === 'flex';
1038
1068
  const isMcpPanelOpen = () => mcpPanel && mcpPanel.style.display === 'flex';
@@ -1118,9 +1148,15 @@ const appendMcpOutput = (label, payload) => {
1118
1148
  mcpOutput.scrollTop = mcpOutput.scrollHeight;
1119
1149
  };
1120
1150
 
1151
+ const resolveMcpPaths = () => {
1152
+ const projectRootOverride = mcpWorkdir ? String(mcpWorkdir.value || '').trim() : '';
1153
+ if (!projectRootOverride) return sandboxPaths;
1154
+ return { ...sandboxPaths, projectRoot: projectRootOverride };
1155
+ };
1156
+
1121
1157
  const refreshMcpPaths = () => {
1122
1158
  if (!mcpPaths) return;
1123
- mcpPaths.textContent = formatJson(sandboxPaths);
1159
+ mcpPaths.textContent = formatJson(resolveMcpPaths());
1124
1160
  };
1125
1161
 
1126
1162
  const refreshMcpConfigHint = async () => {
@@ -1140,13 +1176,8 @@ const refreshMcpConfigHint = async () => {
1140
1176
  }
1141
1177
  };
1142
1178
 
1143
- const setMcpPanelOpen = (open, mode) => {
1179
+ const setMcpPanelOpen = (open) => {
1144
1180
  if (!mcpPanel) return;
1145
- if (mode) {
1146
- mcpPanel.dataset.mode = mode;
1147
- } else {
1148
- delete mcpPanel.dataset.mode;
1149
- }
1150
1181
  mcpPanel.style.display = open ? 'flex' : 'none';
1151
1182
  mcpPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
1152
1183
  if (open) {
@@ -1155,16 +1186,6 @@ const setMcpPanelOpen = (open, mode) => {
1155
1186
  }
1156
1187
  };
1157
1188
 
1158
- const toggleMcpPanel = (mode) => {
1159
- if (!mcpPanel) return;
1160
- const currentMode = mcpPanel.dataset.mode || 'panel';
1161
- if (isMcpPanelOpen() && currentMode === mode) {
1162
- setMcpPanelOpen(false);
1163
- return;
1164
- }
1165
- setMcpPanelOpen(true, mode);
1166
- };
1167
-
1168
1189
  const runMcpTest = async () => {
1169
1190
  const sendBtn = btnMcpSend;
1170
1191
  try {
@@ -1180,9 +1201,11 @@ const runMcpTest = async () => {
1180
1201
  systemPrompt: mcpSystem ? String(mcpSystem.value || '').trim() : '',
1181
1202
  disableTools: Boolean(mcpDisableTools?.checked),
1182
1203
  };
1183
- const workdirOverride = mcpWorkdir ? String(mcpWorkdir.value || '').trim() : '';
1184
- if (workdirOverride) {
1185
- payload.callMeta = { workdir: workdirOverride };
1204
+ const projectRootOverride = mcpWorkdir ? String(mcpWorkdir.value || '').trim() : '';
1205
+ if (projectRootOverride) {
1206
+ payload.callMeta = {
1207
+ chatos: { uiApp: { projectRoot: projectRootOverride } },
1208
+ };
1186
1209
  }
1187
1210
  setMcpStatus('Sending...');
1188
1211
  appendMcpOutput('request', payload);
@@ -1226,7 +1249,13 @@ const collectTokens = () => {
1226
1249
  const readHostContext = () => {
1227
1250
  if (!inspectorEnabled) return null;
1228
1251
  if (typeof host?.context?.get === 'function') return host.context.get();
1229
- return { pluginId: __SANDBOX__.pluginId, appId: __SANDBOX__.appId, theme: currentTheme, bridge: { enabled: true } };
1252
+ return {
1253
+ pluginId: __SANDBOX__.pluginId,
1254
+ appId: __SANDBOX__.appId,
1255
+ theme: currentTheme,
1256
+ surface: currentSurface,
1257
+ bridge: { enabled: true },
1258
+ };
1230
1259
  };
1231
1260
 
1232
1261
  const readThemeInfo = () => ({
@@ -1305,9 +1334,10 @@ if (btnLlmClose) btnLlmClose.addEventListener('click', () => setLlmPanelOpen(fal
1305
1334
  if (btnLlmRefresh) btnLlmRefresh.addEventListener('click', () => refreshLlmConfig());
1306
1335
  if (btnLlmSave) btnLlmSave.addEventListener('click', () => saveLlmConfig());
1307
1336
  if (btnLlmClear) btnLlmClear.addEventListener('click', () => saveLlmConfig({ clearKey: true }));
1308
- if (btnMcpTest) btnMcpTest.addEventListener('click', () => toggleMcpPanel('panel'));
1309
- if (btnMcpHalf) btnMcpHalf.addEventListener('click', () => toggleMcpPanel('half'));
1337
+ if (btnMcpTest) btnMcpTest.addEventListener('click', () => setMcpPanelOpen(!isMcpPanelOpen()));
1338
+ if (btnHalfApp) btnHalfApp.addEventListener('click', () => toggleCompactSurface());
1310
1339
  if (btnMcpClose) btnMcpClose.addEventListener('click', () => setMcpPanelOpen(false));
1340
+ if (mcpWorkdir) mcpWorkdir.addEventListener('input', () => refreshMcpPaths());
1311
1341
  if (btnMcpClear)
1312
1342
  btnMcpClear.addEventListener('click', () => {
1313
1343
  if (mcpOutput) mcpOutput.textContent = '';
@@ -1499,7 +1529,15 @@ const getTheme = () => currentTheme || resolveTheme();
1499
1529
 
1500
1530
  const host = {
1501
1531
  bridge: { enabled: true },
1502
- context: { get: () => ({ pluginId: __SANDBOX__.pluginId, appId: __SANDBOX__.appId, theme: getTheme(), bridge: { enabled: true } }) },
1532
+ context: {
1533
+ get: () => ({
1534
+ pluginId: __SANDBOX__.pluginId,
1535
+ appId: __SANDBOX__.appId,
1536
+ theme: getTheme(),
1537
+ surface: currentSurface,
1538
+ bridge: { enabled: true },
1539
+ }),
1540
+ },
1503
1541
  theme: {
1504
1542
  get: getTheme,
1505
1543
  onChange: (listener) => {
@@ -1552,7 +1590,7 @@ const host = {
1552
1590
  close: () => (setPanelOpen(false), { ok: true }),
1553
1591
  toggle: () => (setPanelOpen(panel.style.display !== 'flex'), { ok: true }),
1554
1592
  },
1555
- ui: { navigate: (menu) => ({ ok: true, menu }) },
1593
+ ui: { navigate: (menu) => ({ ok: true, menu }), surface: 'full' },
1556
1594
  chat: (() => {
1557
1595
  const clone = (v) => JSON.parse(JSON.stringify(v));
1558
1596
 
@@ -1807,6 +1845,8 @@ const host = {
1807
1845
  })(),
1808
1846
  };
1809
1847
 
1848
+ updateSurfaceState('full');
1849
+
1810
1850
  inspectorEnabled = true;
1811
1851
  updateInspector();
1812
1852
 
@@ -1816,7 +1856,7 @@ async function loadAndMount() {
1816
1856
  if (typeof dispose === 'function') { try { await dispose(); } catch {} dispose = null; }
1817
1857
  container.textContent = '';
1818
1858
 
1819
- const entryUrl = __SANDBOX__.entryUrl;
1859
+ const entryUrl = currentSurface === 'compact' && entryCompactUrl ? entryCompactUrl : __SANDBOX__.entryUrl;
1820
1860
  const mod = await import(entryUrl + (entryUrl.includes('?') ? '&' : '?') + 't=' + Date.now());
1821
1861
  const mount = mod?.mount || mod?.default?.mount || (typeof mod?.default === 'function' ? mod.default : null);
1822
1862
  if (typeof mount !== 'function') throw new Error('module entry must export mount()');
@@ -1879,7 +1919,13 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
1879
1919
  const entryAbs = resolveInsideDir(pluginDir, entryRel);
1880
1920
  if (!isFile(entryAbs)) throw new Error(`module entry not found: ${entryRel}`);
1881
1921
 
1922
+ const entryCompactRel = String(app?.entry?.compact?.path || '').trim();
1882
1923
  const entryUrl = `/plugin/${encodeURIComponent(entryRel).replaceAll('%2F', '/')}`;
1924
+ const entryCompactAbs = entryCompactRel ? resolveInsideDir(pluginDir, entryCompactRel) : '';
1925
+ const entryCompactUrl =
1926
+ entryCompactAbs && isFile(entryCompactAbs)
1927
+ ? `/plugin/${encodeURIComponent(entryCompactRel).replaceAll('%2F', '/')}`
1928
+ : '';
1883
1929
 
1884
1930
  let backendInstance = null;
1885
1931
  let backendFactory = null;
@@ -2193,6 +2239,7 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
2193
2239
  .replaceAll('__SANDBOX__.pluginId', JSON.stringify(ctxBase.pluginId))
2194
2240
  .replaceAll('__SANDBOX__.appId', JSON.stringify(effectiveAppId))
2195
2241
  .replaceAll('__SANDBOX__.entryUrl', JSON.stringify(entryUrl))
2242
+ .replaceAll('__SANDBOX__.entryCompactUrl', JSON.stringify(entryCompactUrl))
2196
2243
  .replaceAll('__SANDBOX__.registryApp', JSON.stringify({ plugin: { id: ctxBase.pluginId }, id: effectiveAppId, entry: { type: 'module', url: entryUrl } }))
2197
2244
  .replaceAll('__SANDBOX__.tokenNames', JSON.stringify(tokenNames))
2198
2245
  .replaceAll('__SANDBOX__.paths', JSON.stringify(sandboxPaths));
@@ -34,6 +34,7 @@ npm run dev
34
34
 
35
35
  - `plugin/plugin.json`:`apps[i].entry.type` 必须是 `module`,且 `path` 在插件目录内。
36
36
  - `plugin/plugin.json`:可选 `apps[i].entry.compact.path`,用于 compact UI。
37
+ - 安全:插件目录内尽量避免 symlink(`npm run validate` 会提示),以免路径边界与打包行为不一致。
37
38
  - `mount()`:返回卸载函数并清理事件/订阅;滚动放在应用内部,固定内容用 `slots.header`。
38
39
  - 主题:用 `host.theme.*` 与 `--ds-*` tokens;避免硬编码颜色。
39
40
  - 宿主能力:先判断 `host.bridge.enabled`,非宿主环境要可降级运行。
@@ -49,6 +50,7 @@ npm run dev
49
50
  2) **后端调用 LLM**:在 `plugin/backend/index.mjs` 里通过 `ctx.llm.complete()` 调用模型;前端用 `host.backend.invoke('llmComplete', { input })` 触发。
50
51
 
51
52
  说明:本地沙箱默认是 mock;可通过右上角 `AI Config` 配置 `API Key / Base URL / Model ID` 后启用真实模型调用,并用于测试应用 MCP(需配置 `ai.mcp`)。
53
+ 补充:沙箱默认把 `_meta.workdir` 设为 `dataDir`;如需模拟其它工作目录,可在 `AI Config` 里设置 `Workdir` 覆盖(支持 `$dataDir/$pluginDir/$projectRoot`)。
52
54
 
53
55
  ## 安装到本机 ChatOS
54
56
 
@@ -85,6 +85,8 @@
85
85
 
86
86
  同时,宿主默认注入 `_meta.workdir`(默认等于 `dataDir`;如需指定其它目录,可在 `ai.mcp.callMeta.workdir` 覆盖)。
87
87
 
88
+ > DevKit sandbox:右上角 `AI Config` 支持设置 `Workdir` 覆盖;留空即使用 `dataDir`(也支持 `$dataDir/$pluginDir/$projectRoot`)。
89
+
88
90
  示例(`plugin.json`):
89
91
 
90
92
  ```json
@@ -9,6 +9,11 @@ npm install
9
9
  npm run dev
10
10
  ```
11
11
 
12
+ ## 本地沙箱 AI Config(可选)
13
+
14
+ - 右上角 `AI Config` 可配置 `API Key / Base URL / Model ID`,用于测试真实模型调用(以及 MCP 调用)。
15
+ - 沙箱默认把 `_meta.workdir` 设为 `dataDir`;如需模拟其它工作目录,可在 `AI Config` 里设置 `Workdir` 覆盖(支持 `$dataDir/$pluginDir/$projectRoot`)。
16
+
12
17
  ## 目录说明
13
18
 
14
19
  - `plugin/plugin.json`:插件清单(应用列表、入口、后端、AI 贡献)
@@ -29,6 +34,7 @@ npm run dev
29
34
 
30
35
  - `plugin/plugin.json`:`apps[i].entry.type` 必须是 `module`,且 `path` 在插件目录内。
31
36
  - `plugin/plugin.json`:可选 `apps[i].entry.compact.path`,用于 compact UI。
37
+ - 安全:插件目录内尽量避免 symlink(`npm run validate` 会提示),以免路径边界与打包行为不一致。
32
38
  - `mount()`:返回卸载函数并清理事件/订阅;滚动放在应用内部,固定内容用 `slots.header`。
33
39
  - 主题:用 `host.theme.*` 与 `--ds-*` tokens;避免硬编码颜色。
34
40
  - 宿主能力:先判断 `host.bridge.enabled`,非宿主环境要可降级运行。
@@ -85,6 +85,8 @@
85
85
 
86
86
  同时,宿主默认注入 `_meta.workdir`(默认等于 `dataDir`;如需指定其它目录,可在 `ai.mcp.callMeta.workdir` 覆盖)。
87
87
 
88
+ > DevKit sandbox:右上角 `AI Config` 支持设置 `Workdir` 覆盖;留空即使用 `dataDir`(也支持 `$dataDir/$pluginDir/$projectRoot`)。
89
+
88
90
  示例(`plugin.json`):
89
91
 
90
92
  ```json