dsh-contextos-devops 0.0.0-stage → 1.48.31

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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/assets/avatar.jpg +0 -0
  4. package/assets/client.css +3316 -0
  5. package/assets/icon-180.png +0 -0
  6. package/build/client.mjs +64 -0
  7. package/client.js +7622 -0
  8. package/cordis.patch.yml +3 -0
  9. package/icon.svg +15 -0
  10. package/index.js +345 -0
  11. package/lib/base/env.js +22 -0
  12. package/lib/base/field.js +25 -0
  13. package/lib/base/http.js +140 -0
  14. package/lib/base/platform.js +33 -0
  15. package/lib/base/registry.js +186 -0
  16. package/lib/base/state.js +66 -0
  17. package/lib/base/table.js +47 -0
  18. package/lib/base/texttable.js +26 -0
  19. package/lib/base/url.js +178 -0
  20. package/lib/core/README.md +17 -0
  21. package/lib/core/apps.js +675 -0
  22. package/lib/core/auth.js +110 -0
  23. package/lib/core/avatar.js +289 -0
  24. package/lib/core/backup.js +105 -0
  25. package/lib/core/codegen.js +401 -0
  26. package/lib/core/copilot.js +419 -0
  27. package/lib/core/data.js +157 -0
  28. package/lib/core/device.js +148 -0
  29. package/lib/core/files.js +152 -0
  30. package/lib/core/gobuild.js +63 -0
  31. package/lib/core/iam.js +122 -0
  32. package/lib/core/identity.js +22 -0
  33. package/lib/core/nodes.js +627 -0
  34. package/lib/core/ops.js +306 -0
  35. package/lib/core/project.js +393 -0
  36. package/lib/core/spaces.js +406 -0
  37. package/lib/core/terminal.js +137 -0
  38. package/lib/core/terminal2.js +141 -0
  39. package/lib/core/users.js +51 -0
  40. package/lib/core/vocab.js +158 -0
  41. package/lib/misc/async.js +15 -0
  42. package/lib/misc/shell.js +27 -0
  43. package/lib/misc/static.js +33 -0
  44. package/locale/en.json +10 -0
  45. package/locale/zh.json +10 -0
  46. package/package.json +91 -4
  47. package/src/client/README.md +37 -0
  48. package/src/client/parts/00-prelude.js +315 -0
  49. package/src/client/parts/05-bootstrap.js +61 -0
  50. package/src/client/parts/08-store.js +85 -0
  51. package/src/client/parts/10-ui.js +188 -0
  52. package/src/client/parts/12-debug.js +361 -0
  53. package/src/client/parts/15-layout.js +86 -0
  54. package/src/client/parts/20-devworkspace.js +789 -0
  55. package/src/client/parts/25-editor-ui.js +28 -0
  56. package/src/client/parts/30-modals.js +85 -0
  57. package/src/client/parts/32-cards.js +176 -0
  58. package/src/client/parts/34-forms.js +176 -0
  59. package/src/client/parts/36-data.js +267 -0
  60. package/src/client/parts/40-project.js +430 -0
  61. package/src/client/parts/45-map.js +109 -0
  62. package/src/client/parts/46-pm.js +158 -0
  63. package/src/client/parts/47-three.js +324 -0
  64. package/src/client/parts/49-msg.js +167 -0
  65. package/src/client/parts/50-chat.js +1422 -0
  66. package/src/client/parts/51-pmselection.js +31 -0
  67. package/src/client/parts/52-pmdata.js +29 -0
  68. package/src/client/parts/55-pm.js +146 -0
  69. package/src/client/parts/58-config.js +381 -0
  70. package/src/client/parts/59-monitor.js +262 -0
  71. package/src/client/parts/60-page.js +1518 -0
  72. package/src/client/styles/kit.css +20 -0
  73. package/src/client/styles/main.css +3294 -0
  74. package/src/client/styles/tokens.css +2 -0
  75. package/vendor/leaflet/leaflet.css +661 -0
  76. package/vendor/leaflet/leaflet.js +6 -0
  77. package/vendor/three/three.module.js +53044 -0
  78. package/vendor/xterm/addon-fit.js +2 -0
  79. package/vendor/xterm/xterm.css +218 -0
  80. package/vendor/xterm/xterm.js +2 -0
@@ -0,0 +1,1518 @@
1
+ function PipeSelect({ icon, zh, value, options, onChange, title }) {
2
+ const [open, setOpen] = useState(false);
3
+ useEffect(() => {
4
+ if (!open) return;
5
+ const close = () => setOpen(false);
6
+ const t = setTimeout(() => window.addEventListener('click', close, { once: true }), 0);
7
+ return () => { clearTimeout(t); window.removeEventListener('click', close); };
8
+ }, [open]);
9
+ const opts = options || [];
10
+ return h('div', { className: 'cx-pipe-sel' },
11
+ h('button', {
12
+ type: 'button', className: 'cx-stage cx-stage-title cx-stage-btn', title: title || zh,
13
+ onClick: (e) => { e.stopPropagation(); setOpen((v) => !v); },
14
+ },
15
+ h(Ico, { name: icon, size: 18 }),
16
+ h('span', { className: 'cx-aff-zh' }, zh),
17
+ h('span', { className: 'cx-aff-value' }, value),
18
+ h('span', { className: 'cx-pipe-caret' }, '⌄')),
19
+ open ? h('div', { className: 'cx-pipe-menu' }, opts.length ? opts.map((o) => h('button', {
20
+ key: String(o.value), type: 'button', className: `cx-menu-item${o.on ? ' on' : ''}`,
21
+ onClick: (e) => { e.stopPropagation(); onChange(o.value); setOpen(false); },
22
+ }, o.label)) : h('div', { className: 'cx-menu-item empty' }, '—')) : null);
23
+ }
24
+
25
+ function Page() {
26
+ const [toast, setToast] = useState(null);
27
+ const [dataPulse, setDataPulse] = useState(0);
28
+ const bootSelRef = useRef(null);
29
+ if (bootSelRef.current === null) bootSelRef.current = cxRead('sel', {});
30
+ // 上次退出时的整套状态:节点/空间/实例/工作模式/视图/网页标签
31
+ const bootRef = useRef(null);
32
+ if (bootRef.current === null) bootRef.current = cxSessionGet() || {};
33
+ const boot = bootRef.current;
34
+ const restored = !!boot.view;
35
+ const nodeFresh = (x) => !!x && !!(x.builtAt || x.sizeText || (x.runtime && x.runtime.builtAt));
36
+ const [nodes, setNodes] = useState(() => cxRead('nodes', []).filter(nodeFresh));
37
+ const nodesRef = useRef(nodes); nodesRef.current = nodes;
38
+ const bootedRef = useRef(false);
39
+ const [selMap, setSelMap] = useState(() => cxRead('sel-map', {}));
40
+ const selMapRef = useRef(selMap); selMapRef.current = selMap;
41
+ useEffect(() => cxWrite('sel-map', selMap), [selMap]);
42
+ const rememberSel = (port, patch) => { if (!port) return; setSelMap((m) => ({ ...m, [port]: { ...(m[port] || {}), ...patch } })); };
43
+ const appKeyOf = (a) => (a ? `${a.space || ''}:${a.command || ''}:${a.place_uid || a.name || ''}` : '');
44
+ const [node, setNode] = useState(() => boot.node ?? bootSelRef.current?.node ?? null);
45
+ const [spaces, setSpaces] = useState([]);
46
+ const [space, setSpace] = useState(() => boot.space ?? bootSelRef.current?.space ?? null);
47
+ const [apps, setApps] = useState([]);
48
+ const [app, setApp] = useState(() => boot.app ?? bootSelRef.current?.app ?? null);
49
+ const [modal, setModal] = useState(null);
50
+ // 消息列表里的数据表单请求(替代 DATA 弹窗)
51
+ const [formReq, setFormReq] = useState(null);
52
+ const [appMode, setAppMode] = useState('recent');
53
+ const [store, setStore] = useState([]);
54
+ const [storeService, setStoreService] = useState(null);
55
+ const [webTabs, setWebTabs] = useState(() => {
56
+ try {
57
+ const saved = Array.isArray(boot.tabs) && boot.tabs.length ? { tabs: boot.tabs, active: boot.active } : cxRead('webtabs', null);
58
+ if (!saved || !Array.isArray(saved.tabs)) return [];
59
+ return saved.tabs.filter((t) => t && (t.base || t.url)).slice(0, 8).map((t, i) => ({
60
+ id: t.id || `w${Date.now().toString(36)}${i}`,
61
+ base: t.base || t.url, url: t.base || t.url,
62
+ title: t.title || t.base || t.url,
63
+ reload: 0, app: t.app || null,
64
+ }));
65
+ } catch { return []; }
66
+ });
67
+ const [webLoaded, setWebLoaded] = useState({});
68
+ const [webMenu, setWebMenu] = useState(null);
69
+ useEffect(() => { if (!webMenu) return; const close = () => setWebMenu(null); window.addEventListener('mousedown', close); return () => window.removeEventListener('mousedown', close); }, [webMenu]);
70
+ const [activeWeb, setActiveWeb] = useState(() => {
71
+ if (boot.active) return boot.active;
72
+ return cxRead('webtabs', null)?.active || null;
73
+ });
74
+ const [viewMode, setViewMode] = useState(() => boot.view || 'chat');
75
+ const [intro, setIntro] = useState(null);
76
+ const [devMounted, setDevMounted] = useState(viewMode === 'dev');
77
+ useEffect(() => {
78
+ if (viewMode !== 'dev' || devMounted) return;
79
+ const t = setTimeout(() => setDevMounted(true), 160); /* 先出过场,再挂载重型编辑器 */
80
+ return () => clearTimeout(t);
81
+ }, [viewMode, devMounted]);
82
+ const [workMode, setWorkMode] = useState(() => boot.mode || cxReadText('work-mode', 'avatar'));
83
+ useEffect(() => cxWriteText('work-mode', workMode), [workMode]);
84
+ const [chatFull, setChatFull] = useState(() => cxReadText('chatfull', '1') === '1');
85
+ const [themeMode, setThemeMode] = useState(() => cxReadText('theme', 'system'));
86
+
87
+ const [opFx, setOpFx] = useState(null);
88
+ const [pipeMenu, setPipeMenu] = useState(null);
89
+ const [sidebarHidden, setSidebarHidden] = useState(false);
90
+ useEffect(() => {
91
+ const el = shellRef.current;
92
+ if (!el) return;
93
+ const update = () => setSidebarHidden(el.getBoundingClientRect().left < 120);
94
+ update();
95
+ const ro = new ResizeObserver(update); ro.observe(el);
96
+ const iv = setInterval(update, 2000);
97
+ window.addEventListener('resize', update);
98
+ return () => { ro.disconnect(); clearInterval(iv); window.removeEventListener('resize', update); };
99
+ }, []);
100
+ const pipeModeLabel = (id) => {
101
+ const m = CHAT_MODES.find((x) => x.id === id);
102
+ if (!m) return id;
103
+ return locale === 'zh' ? m.name : ({ avatar: 'Digital Avatar', factory: 'Software Factory', iot: 'Cyber World' }[m.id] || m.id);
104
+ };
105
+ const pipeViewLabel = (id) => (locale === 'zh' ? ({ chat: '对话', manage: '管理', dev: '开发' }[id] || id) : String(id || '').toUpperCase());
106
+ const openPipe = (e, title, items, icon) => {
107
+ try { e.stopPropagation(); e.preventDefault(); } catch {}
108
+ const r = e.currentTarget.getBoundingClientRect();
109
+ const top = Math.min(r.bottom + 4, Math.max(80, (window.innerHeight || 800) - 340));
110
+ setPipeMenu({ x: Math.round(r.left), y: Math.round(top), title, items, icon });
111
+ };
112
+ useEffect(() => {
113
+ const onFloat = (e) => { const d = e && e.detail; if (!d) return; setPipeMenu({ x: d.x, right: d.right, y: d.y, title: d.title, items: d.items, icon: d.icon }); };
114
+ window.addEventListener('cx-float-menu', onFloat);
115
+ return () => window.removeEventListener('cx-float-menu', onFloat);
116
+ }, []);
117
+ useEffect(() => {
118
+ if (!pipeMenu) return;
119
+ const close = () => setPipeMenu(null);
120
+ const t = setTimeout(() => window.addEventListener('click', close, { once: true }), 0);
121
+ return () => { clearTimeout(t); window.removeEventListener('click', close); };
122
+ }, [pipeMenu]);
123
+ // 鼠标跟随光效:--mx/--my 驱动光晕与光标环
124
+ useEffect(() => {
125
+ const el = shellRef.current;
126
+ if (!el) return;
127
+ let raf = 0;
128
+ const onMove = (e) => {
129
+ if (raf) return;
130
+ raf = requestAnimationFrame(() => {
131
+ raf = 0;
132
+ const r = el.getBoundingClientRect();
133
+ el.style.setProperty('--mx', `${Math.round(e.clientX - r.left)}px`);
134
+ el.style.setProperty('--my', `${Math.round(e.clientY - r.top)}px`);
135
+ el.style.setProperty('--cx-cursor', '1');
136
+ });
137
+ };
138
+ const onLeave = () => { try { el.style.setProperty('--cx-cursor', '0'); } catch {} };
139
+ el.addEventListener('pointermove', onMove, { passive: true });
140
+ el.addEventListener('pointerleave', onLeave);
141
+ return () => { el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); cancelAnimationFrame(raf); };
142
+ }, []);
143
+ useEffect(() => {
144
+ const onOp = (e) => {
145
+ const kind = e?.detail?.kind;
146
+ if (!kind) return;
147
+ setOpFx({ kind, key: Date.now() });
148
+ try { window.__cxOpSfx && window.__cxOpSfx(kind); } catch {}
149
+ };
150
+ window.addEventListener('cx-data-op', onOp);
151
+ return () => window.removeEventListener('cx-data-op', onOp);
152
+ }, []);
153
+ const [fx, setFx] = useState(() => ({ ...cxStoreDef('fx'), ...cxRead('fx', {}) }));
154
+ useEffect(() => cxWrite('fx', fx), [fx]);
155
+ const fxSet = (k, v) => setFx((x) => ({ ...x, [k]: v }));
156
+ const [theme, setTheme] = useState(() => { const sys = (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark'; const m = cxReadText('theme', 'system'); return m === 'system' ? sys : m; });
157
+ const lightTheme = themeMode === 'light' || theme === 'light';
158
+ const [langMode, setLangMode] = useState(() => cxReadText('lang', 'system'));
159
+ const prefsReadyRef = useRef(false);
160
+ useEffect(() => {
161
+ (async () => {
162
+ try {
163
+ const st = await api('stateGet');
164
+ const p = st && st.prefs;
165
+ if (p && typeof p === 'object') {
166
+ if (p.theme) { setThemeMode(p.theme); cxWriteText('theme', p.theme); }
167
+ if (p.lang) { setLangMode(p.lang); cxWriteText('lang', p.lang); }
168
+ if (p.view) setViewMode(p.view);
169
+ if (p.work) setWorkMode(p.work);
170
+ }
171
+ } catch {}
172
+ prefsReadyRef.current = true;
173
+ })();
174
+ }, []);
175
+ useEffect(() => {
176
+ if (!prefsReadyRef.current) return;
177
+ const tmr = setTimeout(() => { try { api('stateSet', { name: 'prefs', value: { theme: themeMode, lang: langMode, view: viewMode, work: workMode } }).catch(() => {}); } catch {} }, 800);
178
+ return () => clearTimeout(tmr);
179
+ }, [themeMode, langMode, viewMode, workMode]);
180
+
181
+ useEffect(() => {
182
+ /* 实测回报:把真实 DOM 的计算字号与 bundle 版本写入服务端,便于核对 */
183
+ let tries = 0;
184
+ const timer = setInterval(() => {
185
+ tries += 1;
186
+ const el = document.querySelector('.cx-compose-input');
187
+ if (!el && tries < 10) return;
188
+ clearInterval(timer);
189
+ try {
190
+ const cs = el ? window.getComputedStyle(el) : null;
191
+ api('stateSet', { name: 'clientCheck', value: {
192
+ at: new Date().toISOString(),
193
+ version: String(CLIENT_CSS).split('?v=')[1] || '',
194
+ found: !!el,
195
+ count: document.querySelectorAll('.cx-compose-input').length,
196
+ fontSize: cs ? cs.fontSize : '',
197
+ lineHeight: cs ? cs.lineHeight : '',
198
+ inline: el ? (el.style.getPropertyValue('font-size') || '') : '',
199
+ dpr: window.devicePixelRatio || 1,
200
+ build: CLIENT_BUILD,
201
+ css: Array.from(document.querySelectorAll('link[data-cx-client-css]')).map((l) => String(l.getAttribute('href') || '')),
202
+ /* 布局自检:新 JS 配旧 CSS 时,下面几项会立刻暴露(如 main=flex/row、tools 无边距) */
203
+ layout: (() => {
204
+ try {
205
+ const m = document.querySelector('.cx-msg');
206
+ if (!m) return 'no-message';
207
+ const pick = (sel) => { const n = m.querySelector(sel); if (!n) return '-'; const c = window.getComputedStyle(n); return `${c.display}/${c.flexDirection}/${c.marginLeft}/${c.opacity}`; };
208
+ return `main=${pick('.cx-msg-main')} meta=${pick('.cx-msg-meta')} tools=${pick('.cx-msg-tools')}`;
209
+ } catch { return 'err'; }
210
+ })(),
211
+ /* 几何自检:正文占比与工具栏右边距,比字符串更能证明「看起来对不对」 */
212
+ geo: (() => {
213
+ try {
214
+ const m = document.querySelector('.cx-msg.ai') || document.querySelector('.cx-msg');
215
+ if (!m) return null;
216
+ const r = m.getBoundingClientRect();
217
+ const t = m.querySelector('.cx-msg-tools');
218
+ const b = m.querySelector('.cx-msg-body');
219
+ const tr = t ? t.getBoundingClientRect() : null;
220
+ const br = b ? b.getBoundingClientRect() : null;
221
+ return {
222
+ card: [Math.round(r.width), Math.round(r.height)],
223
+ toolsRightGap: tr ? Math.round(r.right - tr.right) : null,
224
+ bodyRatio: br && r.width ? Number((br.width / r.width).toFixed(2)) : null,
225
+ };
226
+ } catch { return null; }
227
+ })(),
228
+ /* DOM 取证:把第一条消息的真实结构与每层盒模型抄回来,无需再截图 */
229
+ dom: (() => {
230
+ try {
231
+ const m = document.querySelector('.cx-msg.ai') || document.querySelector('.cx-msg');
232
+ if (!m) return null;
233
+ const box = (sel) => {
234
+ const n = sel ? m.querySelector(sel) : m;
235
+ if (!n) return null;
236
+ const r = n.getBoundingClientRect(); const c = window.getComputedStyle(n);
237
+ return { w: Math.round(r.width), h: Math.round(r.height), x: Math.round(r.x),
238
+ d: c.display, fd: c.flexDirection, fg: c.flexGrow, ml: c.marginLeft, wd: c.width };
239
+ };
240
+ return { html: String(m.outerHTML || '').slice(0, 500),
241
+ card: box(null), main: box('.cx-msg-main'), meta: box('.cx-msg-meta'), tools: box('.cx-msg-tools'), body: box('.cx-msg-body') };
242
+ } catch { return null; }
243
+ })(),
244
+ } }).catch(() => {});
245
+ } catch {}
246
+ }, 400);
247
+ return () => clearInterval(timer);
248
+ }, []);
249
+ const [locale, setLocale] = useState(() => { const m = cxReadText('lang', 'system'); return m === 'system' ? browserLang() : m; });
250
+ CURRENT_LANG = locale === 'zh' ? 'zh' : 'en';
251
+ const bootConsole = cxRead('console', {});
252
+ const [consoleOpen, setConsoleOpen] = useState(bootConsole.open === true);
253
+ const [consoleMax, setConsoleMax] = useState(false);
254
+ const consoleBoxRef = useRef(null);
255
+ useEffect(() => { if (!consoleMax) return; return cxMaxApply(consoleBoxRef.current, { offsetTop: 44 }); }, [consoleMax]);
256
+ const [consoleTab, setConsoleTab] = useState(bootConsole.tab || 'activity');
257
+ const [termSplit, setTermSplit] = useState(false);
258
+ const consoleOpenRef = useRef(consoleOpen); consoleOpenRef.current = consoleOpen;
259
+ const viewModeRef = useRef(viewMode); viewModeRef.current = viewMode;
260
+ const initialReqLog = cxRead('reqlog', []);
261
+ const reqLogRef = useRef(initialReqLog);
262
+ const [reqLog, setReqLog] = useState(initialReqLog);
263
+ useEffect(() => cxWrite('reqlog', reqLog.slice(-50)), [reqLog]);
264
+ const pushReq = (e) => {
265
+ try {
266
+ reqLogRef.current = [...reqLogRef.current, e].slice(-50);
267
+ if (consoleOpenRef.current) setReqLog(reqLogRef.current);
268
+ } catch {}
269
+ };
270
+ const saveRemote = (name, value) => { try { api('stateSet', { name, value }).catch(() => {}); } catch {} };
271
+ useEffect(() => { const t = setTimeout(() => { if (reqLog.length) saveRemote('reqlog', reqLog); }, 900); return () => clearTimeout(t); }, [reqLog]);
272
+ useEffect(() => { const t = setTimeout(() => { if (Object.keys(selMap).length) saveRemote('selMap', selMap); }, 900); return () => clearTimeout(t); }, [selMap]);
273
+ useEffect(() => {
274
+ (async () => {
275
+ try {
276
+ const st = await api('stateGet');
277
+ if (!st) return;
278
+ if (Array.isArray(st.reqlog) && st.reqlog.length) { reqLogRef.current = st.reqlog.slice(-50); setReqLog((cur) => (cur.length >= st.reqlog.length ? cur : reqLogRef.current)); }
279
+ if (st.selMap && typeof st.selMap === 'object') setSelMap((cur) => (Object.keys(cur).length ? cur : st.selMap));
280
+ } catch {}
281
+ })();
282
+ }, []);
283
+ const [consoleHeight, setConsoleHeight] = useState(Number(bootConsole.height) || 190);
284
+ const [events, setEvents] = useState([]);
285
+ const [sort, setSort] = useState(() => { const v = cxRead('console', {}); return v.sort && v.sort.key ? v.sort : { key: '', dir: 1 }; });
286
+ const [detail, setDetail] = useState(null);
287
+ const parseDebug = (m) => {
288
+ const text = String(m || '').trim().replace(/^↳\s*/, '');
289
+ const mm = /^(GET|POST|PUT|DELETE|PATCH)\s+(\S+)([\s\S]*)$/i.exec(text);
290
+ return mm ? { method: mm[1].toUpperCase(), url: mm[2], rest: mm[3], raw: text } : { method: '', url: text, rest: '', raw: text };
291
+ };
292
+ const [files, setFiles] = useState(null);
293
+ useEffect(() => cxWrite('console', { open: consoleOpen, tab: consoleTab, height: consoleHeight, sort }), [consoleOpen, consoleTab, consoleHeight, sort]);
294
+ useEffect(() => {
295
+ const onToast = (e) => setToast({ id: Date.now(), text: String(e?.detail?.text || ''), kind: e?.detail?.kind || 'ok' });
296
+ window.addEventListener('cx-toast', onToast);
297
+ return () => window.removeEventListener('cx-toast', onToast);
298
+ }, []);
299
+ useEffect(() => {
300
+ if (!toast) return;
301
+ const t = setTimeout(() => setToast(null), toast.kind === 'err' ? 4200 : 2600);
302
+ return () => clearTimeout(t);
303
+ }, [toast]);
304
+ const loadPref = (k, d) => cxRead(k, d);
305
+ const [nodeStatusFilter, setNodeStatusFilter] = useState(() => loadPref('filter-node', []));
306
+ const [spaceStatusFilter, setSpaceStatusFilter] = useState(() => loadPref('filter-space', []));
307
+ const [appSpaceFilter, setAppSpaceFilter] = useState(() => loadPref('filter-app', []));
308
+ const [storeSpaceFilter, setStoreSpaceFilter] = useState(() => loadPref('filter-store', []));
309
+ useEffect(() => cxWrite('filter-node', nodeStatusFilter), [nodeStatusFilter]);
310
+ useEffect(() => cxWrite('filter-space', spaceStatusFilter), [spaceStatusFilter]);
311
+ useEffect(() => cxWrite('filter-app', appSpaceFilter), [appSpaceFilter]);
312
+ useEffect(() => cxWrite('filter-store', storeSpaceFilter), [storeSpaceFilter]);
313
+ const t = useCallback((key) => term(locale, key), [locale]);
314
+ const tp = useCallback((key) => locale === 'zh' ? `${term('zh', key)} / ${term('en', key)}` : term('en', key), [locale]);
315
+ useEffect(() => {
316
+ const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: light)') : null;
317
+ const apply = () => setTheme(themeMode === 'system' ? (mq && mq.matches ? 'light' : 'dark') : themeMode);
318
+ apply();
319
+ if (!mq) return;
320
+ mq.addEventListener?.('change', apply);
321
+ return () => mq.removeEventListener?.('change', apply);
322
+ }, [themeMode]);
323
+ useEffect(() => {
324
+ const apply = () => setLocale(langMode === 'system' ? browserLang() : langMode);
325
+ apply();
326
+ window.addEventListener?.('languagechange', apply);
327
+ return () => window.removeEventListener?.('languagechange', apply);
328
+ }, [langMode]);
329
+ useEffect(() => cxWriteText('theme', themeMode), [themeMode]);
330
+ const restoredRef = useRef(restored);
331
+ useEffect(() => {
332
+ if (lightTheme) return; /* 浅色主题不播过场 */
333
+ if (restoredRef.current) { restoredRef.current = false; return; } /* 从快照恢复:直接进界面,不播过场 */
334
+ if (MODE_INTRO_SEEN.has(viewMode)) return;
335
+ MODE_INTRO_SEEN.add(viewMode);
336
+ setIntro(viewMode);
337
+ }, [viewMode, lightTheme]);
338
+
339
+ useEffect(() => cxWriteText('lang', langMode), [langMode]);
340
+ useEffect(() => cxWriteText('view', viewMode), [viewMode]);
341
+ useEffect(() => cxWriteText('chatfull', chatFull ? '1' : '0'), [chatFull]);
342
+ const shellRef = useRef(null);
343
+ useEffect(() => {
344
+ if (fx.sound === 'off') return;
345
+ let ac = null;
346
+ const ctx = () => { ac = ac || new (window.AudioContext || window.webkitAudioContext)(); return ac; };
347
+ const tone = (freq, dur, gain, type = 'sine', slide) => {
348
+ try {
349
+ const a = ctx();
350
+ const o = a.createOscillator(); const g = a.createGain();
351
+ o.type = type; o.frequency.value = freq;
352
+ if (slide) o.frequency.exponentialRampToValueAtTime(slide, a.currentTime + dur);
353
+ g.gain.value = gain; o.connect(g); g.connect(a.destination);
354
+ const t0 = a.currentTime; g.gain.setValueAtTime(gain, t0);
355
+ g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
356
+ o.start(t0); o.stop(t0 + dur);
357
+ } catch {}
358
+ };
359
+ const noise = (dur, gain) => {
360
+ try {
361
+ const a = ctx();
362
+ const b = a.createBuffer(1, Math.max(1, Math.floor(a.sampleRate * dur)), a.sampleRate);
363
+ const d = b.getChannelData(0);
364
+ for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length);
365
+ const src = a.createBufferSource(); src.buffer = b;
366
+ const f = a.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 2600;
367
+ const g = a.createGain(); g.gain.value = gain;
368
+ src.connect(f); f.connect(g); g.connect(a.destination); src.start();
369
+ } catch {}
370
+ };
371
+ const play = (kind) => {
372
+ if (kind === 'key') {
373
+ if (fx.sound === 'cs') { noise(0.015, 0.03); return; }
374
+ if (fx.sound === 'ra' || fx.sound === 'sc2') { tone(1200, 0.015, 0.02, 'square'); return; }
375
+ if (fx.sound === 'wow') { tone(620, 0.02, 0.018, 'triangle'); return; }
376
+ if (fx.sound === 'chip') { tone(880, 0.015, 0.02, 'square'); return; }
377
+ tone(980, 0.018, 0.018);
378
+ return;
379
+ }
380
+ if (fx.sound === 'cs') {
381
+ if (kind === 'click') { noise(0.07, 0.16); tone(150, 0.06, 0.05, 'square', 80); }
382
+ else noise(0.02, 0.05);
383
+ } else if (fx.sound === 'ra') {
384
+ if (kind === 'click') { tone(540, 0.06, 0.05, 'square'); setTimeout(() => tone(780, 0.07, 0.05, 'square'), 60); }
385
+ else tone(1400, 0.02, 0.02, 'square');
386
+ } else if (fx.sound === 'wow') {
387
+ if (kind === 'click') { tone(196, 0.16, 0.06, 'sawtooth', 147); setTimeout(() => tone(294, 0.18, 0.05, 'triangle'), 90); }
388
+ else tone(392, 0.04, 0.02, 'triangle');
389
+ } else if (fx.sound === 'sc2') {
390
+ if (kind === 'click') { tone(1568, 0.05, 0.04, 'square'); setTimeout(() => tone(2093, 0.05, 0.035, 'square'), 55); }
391
+ else tone(2093, 0.02, 0.02, 'square');
392
+ } else if (fx.sound === 'chip') {
393
+ if (kind === 'click') { [660, 880, 1320].forEach((f, i) => setTimeout(() => tone(f, 0.06, 0.04, 'square'), i * 55)); }
394
+ else tone(1760, 0.02, 0.02, 'square');
395
+ } else {
396
+ if (kind === 'click') tone(880, 0.07, 0.05);
397
+ else tone(1320, 0.03, 0.02);
398
+ }
399
+ };
400
+ const opSound = (k) => {
401
+ if (fx.sound === 'off') return;
402
+ const seq = { create: [[523, 0.06], [659, 0.06], [784, 0.09]], update: [[880, 0.05], [1174, 0.06]], delete: [[330, 0.07], [196, 0.1]], refresh: [[1400, 0.04], [1800, 0.03]] }[k] || [];
403
+ if (fx.sound === 'cs') {
404
+ if (k === 'create') { noise(0.05, 0.12); setTimeout(() => noise(0.05, 0.12), 80); }
405
+ else if (k === 'update') noise(0.05, 0.1);
406
+ else if (k === 'delete') tone(120, 0.12, 0.08, 'square', 70);
407
+ else noise(0.03, 0.06);
408
+ return;
409
+ }
410
+ const type = fx.sound === 'ra' || fx.sound === 'chip' ? 'square' : fx.sound === 'wow' ? 'sawtooth' : 'sine';
411
+ seq.forEach(([f, d], i) => setTimeout(() => tone(f, d, 0.05, type), i * 70));
412
+ };
413
+ window.__cxOpSfx = opSound;
414
+ const onClick = () => play('click');
415
+ const onOver = (e) => { if (e.target.closest('.cx-btn, .cx-sel-btn, .cx-drop, .cx-compose-plus, .cx-compose-send')) play('hover'); };
416
+ const el = shellRef.current;
417
+ if (!el) return;
418
+ const onKey = (e) => {
419
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
420
+ if (e.key.length === 1 || e.key === 'Backspace' || e.key === 'Enter') play('key');
421
+ };
422
+ el.addEventListener('click', onClick, true);
423
+ el.addEventListener('pointerover', onOver, true);
424
+ el.addEventListener('keydown', onKey, true);
425
+ return () => { el.removeEventListener('click', onClick, true); el.removeEventListener('pointerover', onOver, true); el.removeEventListener('keydown', onKey, true); try { delete window.__cxOpSfx; } catch {} };
426
+ }, [fx.sound]);
427
+ // 切换管理模式/对话模式时,让当前选中的卡片闪一下
428
+ useEffect(() => {
429
+ const el = shellRef.current;
430
+ if (!el) return;
431
+ el.classList.remove('cx-view-flash');
432
+ void el.offsetWidth;
433
+ el.classList.add('cx-view-flash');
434
+ const t = setTimeout(() => { try { el.classList.remove('cx-view-flash'); } catch {} }, 1500);
435
+ return () => clearTimeout(t);
436
+ }, [viewMode]);
437
+ React.useLayoutEffect(() => {
438
+ const el = shellRef.current; if (!el) return;
439
+ const check = () => {
440
+ try {
441
+ const rect = el.getBoundingClientRect();
442
+ el.style.setProperty('--cx-shell-left', rect.left + 'px');
443
+ el.style.setProperty('--cx-shell-top', rect.top + 'px');
444
+ el.style.setProperty('--cx-shell-width', rect.width + 'px');
445
+ el.style.setProperty('--cx-shell-height', rect.height + 'px');
446
+ const left = rect.left;
447
+ const target = parseFloat(getComputedStyle(el).getPropertyValue('--cx-host-left')) || 168;
448
+ const pad = left < 120 ? Math.max(0, target - 16 - left) : 0;
449
+ el.style.setProperty('--cx-host-left-pad', pad + 'px');
450
+ el.classList.toggle('host-chrome', pad > 0);
451
+ } catch {}
452
+ };
453
+ check();
454
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(check) : null;
455
+ if (ro) { ro.observe(el); ro.observe(document.documentElement); }
456
+ window.addEventListener('resize', check);
457
+ const t = setTimeout(check, 400);
458
+ return () => { if (ro) ro.disconnect(); window.removeEventListener('resize', check); clearTimeout(t); };
459
+ }, []);
460
+ const [initState, setInitState] = useState(null);
461
+ const [welcome, setWelcome] = useState(null);
462
+ const prevInit = useRef(null);
463
+ useEffect(() => {
464
+ const now = !!initState;
465
+ const was = !!prevInit.current;
466
+ prevInit.current = now;
467
+ if (now === was) return;
468
+ /* 浅色主题:进入初始化时不播,初始化完成后的 READY 保留 */
469
+ if (now && lightTheme) { setWelcome(null); return; }
470
+ setWelcome(now ? { title: 'INITIALIZE', sub: 'CONTEXTOS · SETUP' } : { title: 'READY', sub: 'CONTEXTOS · ONLINE' });
471
+ }, [initState]);
472
+
473
+ const [nodesLoaded, setNodesLoaded] = useState(() => cxRead('nodes', []).length > 0);
474
+ const autoInitRef = useRef(false);
475
+ const initBusyRef = useRef(false);
476
+ const [showDevices, setShowDevices] = useState(false);
477
+ const [avatarData, setAvatarData] = useState({ active: '', items: [] });
478
+ const [bgData, setBgData] = useState({ active: '', items: [] });
479
+ const [bg, setBg] = useState(null);
480
+ const [avatar, setAvatar] = useState(null);
481
+ // 任一状态变化都写回整份快照(节流 400ms,pagehide 兜底 flush)
482
+ useEffect(() => {
483
+ cxSessionSave({
484
+ node, space, app, view: viewMode, mode: workMode,
485
+ tabs: webTabs.map((t) => ({ base: t.base || t.url, title: t.title, app: t.app || null })),
486
+ active: activeWeb,
487
+ avatar: avatar?.uid || '', world: bg?.id || '',
488
+ chatFull, console: { open: consoleOpen, tab: consoleTab, height: consoleHeight },
489
+ });
490
+ }, [node, space, app, viewMode, workMode, webTabs, activeWeb, avatar, bg, chatFull, consoleOpen, consoleTab, consoleHeight]);
491
+ const [showAvatars, setShowAvatars] = useState(false);
492
+ const [busy, setBusy] = useState('');
493
+ const [error, setError] = useState('');
494
+ const initialLogs = [{ t: new Date().toLocaleTimeString(), m: 'CONTROL PLANE READY · 选择节点开始', k: 'ok' }];
495
+ const logsRef = useRef(initialLogs);
496
+ const [logs, setLogs] = useState(initialLogs);
497
+ const [debug, setDebug] = useState([]);
498
+ const logRef = useRef(null);
499
+ const log = useCallback((m, k = '') => {
500
+ if (ACTIVITY.fn !== log) { ACTIVITY.fn = log; const buf = ACTIVITY.buf.splice(0); buf.forEach((x) => log(x.m, x.k)); }
501
+ const entry = { t: new Date().toLocaleTimeString(), m, k, el: LOCATE.last || '' };
502
+ logsRef.current = [...logsRef.current.slice(-80), entry];
503
+ if (consoleOpenRef.current) setLogs(logsRef.current);
504
+ setTimeout(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, 30);
505
+ }, []);
506
+ useEffect(() => {
507
+ const onDown = (e) => { LOCATE.last = locatable(e.target); };
508
+ document.addEventListener('pointerdown', onDown, true);
509
+ return () => document.removeEventListener('pointerdown', onDown, true);
510
+ }, []);
511
+ useEffect(() => {
512
+ if (!consoleOpen) return;
513
+ setLogs(logsRef.current);
514
+ setReqLog(reqLogRef.current);
515
+ }, [consoleOpen]);
516
+ useEffect(() => {
517
+ const buf = [];
518
+ let timer = 0;
519
+ const flush = () => {
520
+ timer = 0;
521
+ if (!buf.length) return;
522
+ const batch = buf.splice(0);
523
+ setDebug((ds) => [...ds.slice(-200), ...batch].slice(-200));
524
+ };
525
+ const onDebug = (e) => {
526
+ if (!consoleOpenRef.current && viewModeRef.current !== 'dev') return;
527
+ const text = String(e?.detail?.text || '');
528
+ if (!text) return;
529
+ buf.push({ t: new Date().toLocaleTimeString(), m: text, method: String(e?.detail?.method || ''), url: String(e?.detail?.url || ''), request: e?.detail?.request || null, requestHeaders: e?.detail?.requestHeaders || {}, response: e?.detail?.response || null, responseHeaders: e?.detail?.responseHeaders || {}, status: e?.detail?.status || 0, size: e?.detail?.size || 0, ms: e?.detail?.ms || 0, logId: String(e?.detail?.logId || ''), el: LOCATE.last || '' });
530
+ if (!timer) timer = setTimeout(flush, 80);
531
+ };
532
+ window.addEventListener('cx-debug', onDebug);
533
+ return () => { clearTimeout(timer); window.removeEventListener('cx-debug', onDebug); };
534
+ }, []);
535
+ useEffect(() => {
536
+ const buf = [];
537
+ let timer = 0;
538
+ const flush = () => {
539
+ timer = 0;
540
+ if (!buf.length) return;
541
+ const batch = buf.splice(0);
542
+ setEvents((xs) => [...xs.slice(-399), ...batch].slice(-400));
543
+ };
544
+ const push = (entry) => {
545
+ buf.push(entry);
546
+ if (!timer) timer = setTimeout(flush, 80);
547
+ };
548
+ const onEvent = (e) => {
549
+ if (!consoleOpenRef.current && viewModeRef.current !== 'dev') return;
550
+ const d = e?.detail || {};
551
+ const at = new Date().toISOString().slice(11, 23);
552
+ push({ id: `${at}-${Math.random().toString(36).slice(2, 6)}`, at, kind: d.kind || 'event', text: String(d.text || ''), ok: d.ok !== false, ms: d.ms, error: String(d.error || ''), el: LOCATE.last || '' });
553
+ };
554
+ const onClick = (e) => {
555
+ if (!consoleOpenRef.current && viewModeRef.current !== 'dev') return;
556
+ const btn = e.target && e.target.closest ? e.target.closest('button, .cx-menu-item, .cx-pm-card, .cx-chip, [role="button"]') : null;
557
+ if (!btn) return;
558
+ const label = String(btn.getAttribute('title') || btn.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40);
559
+ const cls = String(btn.className || '').split(' ').slice(0, 2).join('.');
560
+ const at = new Date().toISOString().slice(11, 23);
561
+ push({ id: `${at}-${Math.random().toString(36).slice(2, 6)}`, at, kind: 'click', text: `${cls} · ${label}`, ok: true, el: locatable(btn) });
562
+ };
563
+ window.addEventListener('cx-event', onEvent);
564
+ document.addEventListener('click', onClick, true);
565
+ return () => { clearTimeout(timer); window.removeEventListener('cx-event', onEvent); document.removeEventListener('click', onClick, true); };
566
+ }, []);
567
+ useEffect(() => { setSort({ key: '', dir: 1 }); setDetail(null); }, [consoleTab]);
568
+ useEffect(() => { if (consoleTab !== 'files') return; api('files').then(setFiles).catch(() => setFiles({ error: 'files 接口不可用(重启 DSH 后生效)', files: [] })); }, [consoleTab]);
569
+ useEffect(() => {
570
+ const el = logRef.current;
571
+ if (!el) return;
572
+ setTimeout(() => { if (el) el.scrollTop = el.scrollHeight; }, 30);
573
+ }, [logs, debug, events, files, consoleTab]);
574
+ const stateRows = (() => {
575
+ if (consoleTab !== 'state') return [];
576
+ const liveRows = [
577
+ { k: '', t: 'LIVE node', m: String(node ?? '') },
578
+ { k: '', t: 'LIVE space', m: space || '' },
579
+ { k: '', t: 'LIVE app', m: app?.name || '' },
580
+ { k: '', t: 'LIVE avatar', m: avatar?.name || '' },
581
+ { k: '', t: 'LIVE scene', m: bg?.name || '' },
582
+ { k: '', t: 'LIVE view', m: viewMode },
583
+ { k: '', t: 'LIVE locale', m: locale },
584
+ ];
585
+ try {
586
+ return [...liveRows, ...cxStoreDump().map((x) => ({ k: 'ok', t: x.key, m: String(x.value || '').slice(0, 260) }))];
587
+ } catch { return liveRows; }
588
+ })();
589
+ const prettyJson = (v) => { try { return JSON.stringify(JSON.parse(v), null, 2); } catch { return String(v ?? ''); } };
590
+ const rowKey = (r) => `${r.t}|${r.req || r.text || r.m || ''}`;
591
+ const locate = useCallback((id) => {
592
+ if (!id) return;
593
+ const el = document.querySelector('[data-cx-id="' + String(id).replace(/"/g, '') + '"]');
594
+ if (!el) { log(`定位失败:控件 ${id} 不在当前视图`, 'err'); return; }
595
+ try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch { el.scrollIntoView(); }
596
+ el.classList.remove('cx-locate-flash');
597
+ void el.offsetWidth;
598
+ el.classList.add('cx-locate-flash');
599
+ setTimeout(() => { try { el.classList.remove('cx-locate-flash'); } catch {} }, 1800);
600
+ log(`定位到控件 ${id}`, 'info');
601
+ }, [log]);
602
+ const openDetail = (r) => {
603
+ if (r && r.el) locate(r.el);
604
+ const key = rowKey(r);
605
+ if (consoleTab === 'request') {
606
+ const it = r.raw || {};
607
+ const u = it.usage || {};
608
+ const cost = (() => { const c = u.cost ?? u.total_cost ?? u.total_price; if (c !== undefined && c !== null) return String(c); const i = Number(u.input_tokens || u.prompt_tokens || 0); const o = Number(u.output_tokens || u.completion_tokens || 0); return (i || o) ? `≈¥${((i * 0.000002) + (o * 0.000008)).toFixed(6)}` : '—'; })();
609
+ setDetail({
610
+ key, row: r, title: '',
611
+ fields: [['TIME', new Date(it.at).toLocaleString()], ['MS', it.ms], ['BYTES', `↑${it.reqBytes || 0} ↓${it.resBytes || 0}`], ['TOKENS', it.tokens], ['COST', cost], ['MODE/VIEW', `${it.mode || '-'} / ${(it.context && it.context.view) || '-'}`]],
612
+ body: `REQUEST:\n${it.message || ''}\n\nPARAMS:\n${JSON.stringify(it.context || {}, null, 2)}\n\nRESPONSE:\n${it.reply || ''}${it.action ? `\n\nACTION:\n${JSON.stringify(it.action, null, 2)}` : ''}`,
613
+ });
614
+ return;
615
+ }
616
+ if (consoleTab === 'activity') { setDetail({ key, row: r, title: 'ACTIVITY', fields: [['TIME', r.t], ['TYPE', r.k || '—'], ['MESSAGE', r.m]] }); return; }
617
+ if (consoleTab === 'events') { setDetail({ key, row: r, title: 'EVENT', fields: [['TIME', r.t], ['KIND', r.kind], ['RESULT', r.ok], ['MS', r.ms ?? '—']], body: r.text }); return; }
618
+ if (consoleTab === 'debug') { traceDebugRow(r); setDetail({ key, row: r, node, space, title: '', fields: [['TIME', r.t], ['LOG.ID', r.logId || '—'], ['STATUS', r.status || '—'], ['MS', `${r.ms || 0} ms`], ['BYTES', `${r.size || 0} B`], ['METHOD', r.method], ['CMD', debugCmdOf(r.req)], ['ACTION', debugActionOf(r.req)], ['URL', r.req], ...debugMetaFields(r)], requestHeaders: r.requestHeaders || {}, responseHeaders: r.responseHeaders || {}, body: r.response ? JSON.stringify(r.response, null, 2) : (typeof r.raw === 'string' ? r.raw : ''), table: debugResponseTable(r), params: debugParamsOf(r) }); return; }
619
+ if (consoleTab === 'state') { setDetail({ key, row: r, title: 'STATE', fields: [['KEY', r.t]], body: prettyJson(r.m) }); return; }
620
+ if (consoleTab === 'files') {
621
+ setDetail({ key, row: r, title: 'FILE', fields: [['NAME', r.t], ['SIZE', r.m], ['MODIFIED', r.x]], body: '加载中…' });
622
+ if (r.t && r.t !== 'DIR') {
623
+ api('file', { name: r.t }).then((d) => setDetail((cur) => (cur && cur.key === key ? { ...cur, body: d.content } : cur)))
624
+ .catch((e) => setDetail((cur) => (cur && cur.key === key ? { ...cur, body: String(e?.message || e) } : cur)));
625
+ }
626
+ }
627
+ };
628
+ const consoleTable = (() => {
629
+ if (consoleTab === 'activity') return { cols: [{ k: 't', label: 'TIME' }, { k: 'k', label: 'TYPE' }, { k: 'm', label: 'MESSAGE' }, { k: 'loc', label: '' }], rows: logs.map((l) => ({ ...l, loc: l.el ? '⌖' : '' })) };
630
+ if (consoleTab === 'request') return { cols: [{ k: 't', label: 'TIME' }, { k: 'ms', label: 'MS' }, { k: 'tokens', label: 'TOKENS' }, { k: 'msg', label: 'MESSAGE' }], rows: (reqLog || []).map((r) => ({ t: new Date(r.at).toLocaleTimeString(), ms: r.ms, tokens: r.tokens, msg: String(r.message || '').replace(/\s+/g, ' ').slice(0, 120), raw: r })) };
631
+ if (consoleTab === 'events') return { cols: [{ k: 't', label: 'TIME' }, { k: 'kind', label: 'KIND' }, { k: 'text', label: 'DETAIL' }, { k: 'ms', label: 'MS' }, { k: 'ok', label: 'RESULT' }], rows: events.map((e) => ({ t: e.at, kind: String(e.kind || '').toUpperCase(), text: `${e.text}${e.error ? ` · ${e.error}` : ''}`, ms: e.ms ?? '', ok: e.ok === false ? 'ERR' : 'OK', el: e.el, loc: e.el ? '⌖' : '' })) };
632
+ if (consoleTab === 'debug') return { cols: [{ k: 't', label: 'TIME' }, { k: 'status', label: 'STATUS' }, { k: 'ms', label: 'MS' }, { k: 'method', label: 'METHOD' }, { k: 'cmd', label: 'CMD' }, { k: 'action', label: 'ACTION' }, { k: 'req', label: 'URL' }, { k: 'loc', label: '' }], rows: debug.map((l) => { const p = parseDebug(l.m); const url = l.url || p.url; return { t: l.t, status: l.status || '', ms: l.ms || '', method: l.method || p.method || '—', cmd: debugCmdOf(url), action: debugActionOf(url), req: url, rest: p.rest, raw: l.response ? l : p.raw, response: l.response, requestHeaders: l.requestHeaders, responseHeaders: l.responseHeaders, logId: l.logId, size: l.size, request: l.request, el: l.el, loc: l.el ? '⌖' : '' }; }) };
633
+ if (consoleTab === 'state') return { cols: [{ k: 't', label: 'KEY' }, { k: 'm', label: 'VALUE' }], rows: stateRows };
634
+ if (consoleTab === 'files') {
635
+ if (files?.error) return { cols: [{ k: 't', label: 'ERROR' }], rows: [{ t: files.error }] };
636
+ const rows = (files?.files || []).map((f) => ({ t: f.name, m: f.size, x: f.mtime }));
637
+ if (files?.dir) rows.unshift({ t: 'DIR', m: '', x: files.dir });
638
+ return { cols: [{ k: 't', label: 'FILE' }, { k: 'm', label: 'SIZE' }, { k: 'x', label: 'MODIFIED' }], rows };
639
+ }
640
+ return { cols: [], rows: [] };
641
+ })();
642
+ const sortKey = sort.key && consoleTable.cols.some((c) => c.k === sort.key) ? sort.key : (consoleTable.cols[0]?.k || '');
643
+ const sortedRows = (() => {
644
+ const dir = sort.dir || 1;
645
+ return [...consoleTable.rows].sort((a, b) => {
646
+ const va = a[sortKey], vb = b[sortKey];
647
+ const na = Number(va), nb = Number(vb);
648
+ if (String(va ?? '').trim() !== '' && String(vb ?? '').trim() !== '' && !Number.isNaN(na) && !Number.isNaN(nb)) return (na - nb) * dir;
649
+ return String(va ?? '').localeCompare(String(vb ?? '')) * dir;
650
+ });
651
+ })();
652
+ const showToast = (text, kind = 'ok') => { try { window.dispatchEvent(new CustomEvent('cx-toast', { detail: { text, kind } })); } catch {} };
653
+ const act = useCallback(async (label, fn) => {
654
+ setBusy(label); setError('');
655
+ log(`→ ${label}`, 'run');
656
+ try { const r = await fn(); log(`✓ ${label}`, 'ok'); if (!/^LOAD /i.test(label)) { showToast(label, 'ok'); setDataPulse((x) => x + 1); } return r; }
657
+ catch (e) { const m = String(e?.message || e); setError(m); log(`✗ ${label}: ${m}`, 'err'); showToast(`${label}: ${m}`, 'err'); return undefined; }
658
+ finally { setBusy(''); }
659
+ }, [log]);
660
+
661
+ const loadNodes = useCallback(async () => {
662
+ const d = await api('nodes');
663
+ const list = d.nodes || [];
664
+ setNodes(list);
665
+ setNodesLoaded(true);
666
+ activity(`加载节点 ${list.length} 个(${list.filter((n) => n.state === 'running').length} 运行中)`, 'ok');
667
+ cxWrite('nodes', list);
668
+ try { api('stateSet', { name: 'nodesCache', value: list }).catch(() => {}); } catch {}
669
+ setNode((cur) => cur != null ? (list.some((n) => n.port === cur) ? cur : list[0]?.port ?? null) : (list.find((n) => n.state === 'running')?.port ?? list[0]?.port ?? null));
670
+ return list;
671
+ }, []);
672
+ const spacesSeqRef = useRef(0);
673
+ const loadSpaces = useCallback(async (port) => {
674
+ const seq = ++spacesSeqRef.current;
675
+ if (!port) { setSpaces([]); return []; }
676
+ try { const d = await api('spaces', { node: port }); if (seq === spacesSeqRef.current) setSpaces(d || []); activity(`加载空间 ${(d || []).length} 个(节点 ${port})`, 'ok'); return d || []; } catch { if (seq === spacesSeqRef.current) setSpaces([]); return []; }
677
+ }, []);
678
+ const appsSeqRef = useRef(0);
679
+ const appsCacheRef = useRef({ key: '', at: 0, data: null, promise: null });
680
+ const actorUid = avatar?.uid || '';
681
+ const actorRef = useRef('');
682
+ actorRef.current = actorUid;
683
+ const loadApps = useCallback(async (port, sp, uid = '') => {
684
+ const who = uid || actorRef.current;
685
+ if (!port || !who) { setApps([]); return []; }
686
+ uid = who;
687
+ const key = `${port}:${sp || ''}:${uid || ''}`;
688
+ const cache = appsCacheRef.current;
689
+ if (cache.key === key && cache.promise) return cache.promise;
690
+ if (cache.key === key && Date.now() - cache.at < 600 && cache.data) return cache.data;
691
+ const seq = ++appsSeqRef.current;
692
+ const promise = (async () => {
693
+ try { const d = await api('apps', { node: port, space: sp || '', uid }); if (seq === appsSeqRef.current) setApps(d || []); activity(`加载应用 ${(d || []).length} 个(${sp || '全部空间'})`, 'ok'); appsCacheRef.current = { key, at: Date.now(), data: d || [], promise: null }; return d || []; }
694
+ catch (e) {
695
+ if (/unknown op|unknown op apps/i.test(String(e?.message || e))) {
696
+ const d = await fallbackApps(port, sp); if (seq === appsSeqRef.current) setApps(d); appsCacheRef.current = { key, at: Date.now(), data: d, promise: null }; return d;
697
+ }
698
+ if (seq === appsSeqRef.current) setApps([]); appsCacheRef.current = { key, at: Date.now(), data: [], promise: null }; return [];
699
+ }
700
+ })();
701
+ appsCacheRef.current = { ...cache, key, at: cache.at, data: cache.data, promise };
702
+ return promise;
703
+ }, []);
704
+ const loadAvatars = useCallback(async (port) => {
705
+ if (!port) { setAvatarData({ active: '', items: [] }); setAvatar(null); return null; }
706
+ try {
707
+ const d = await api('avatars', { node: port });
708
+ setAvatarData(d || { active: '', items: [] });
709
+ activity(`加载数字分身 ${(d?.items || []).length} 个`, 'ok');
710
+ const want = (cxSessionGet() || {}).avatar || '';
711
+ const a = (d?.items || []).find((x) => x.uid === want) || (d?.items || []).find((x) => x.uid === d?.active) || d?.items?.[0] || null;
712
+ setAvatar(a);
713
+ return a;
714
+ } catch { setAvatarData({ active: '', items: [] }); setAvatar(null); return null; }
715
+ }, []);
716
+ const applyScene = useCallback((b) => {
717
+ const p = b?.params || {};
718
+ setThemeMode(p.theme || 'system');
719
+ setLangMode(p.lang || 'system');
720
+ }, []);
721
+ const loadBackgrounds = useCallback(async (port) => {
722
+ if (!port) { setBgData({ active: '', items: [] }); setBg(null); return null; }
723
+ try {
724
+ const d = await api('backgrounds', { node: port });
725
+ setBgData(d || { active: '', items: [] });
726
+ activity(`加载赛博场景 ${(d?.items || []).length} 个`, 'ok');
727
+ const wantBg = (cxSessionGet() || {}).world || '';
728
+ const b = (d?.items || []).find((x) => x.id === wantBg) || (d?.items || []).find((x) => x.id === d?.active) || d?.items?.[0] || null;
729
+ setBg(b);
730
+ applyScene(b);
731
+ return b;
732
+ } catch { setBgData({ active: '', items: [] }); setBg(null); return null; }
733
+ }, [applyScene]);
734
+ const openInit = useCallback(() => {
735
+ setInitState({ phase: 'setup', running: false, done: false, error: '', params: { ...INIT_DEFAULTS }, steps: INIT_STEPS.map((x) => ({ ...x, status: 'wait', detail: '' })), logs: [] });
736
+ }, []);
737
+ const runInit = useCallback(async (params = {}) => {
738
+ if (initBusyRef.current) return;
739
+ initBusyRef.current = true;
740
+ const cfg = { ...INIT_DEFAULTS, ...params, avatar: { ...(params.avatar || {}) }, scene: { ...(params.scene || {}) }, project: { ...(params.project || {}) } };
741
+ const steps = INIT_STEPS.map((x) => ({ ...x, status: 'wait', detail: '' }));
742
+ setInitState({ phase: 'running', running: true, done: false, error: '', params: cfg, steps, logs: [] });
743
+ const trace = (entry) => setInitState((prev) => {
744
+ if (!prev) return prev;
745
+ const logs = [...(prev.logs || [])];
746
+ const i = entry.id ? logs.findIndex((x) => x.id === entry.id) : -1;
747
+ if (i >= 0) logs[i] = { ...logs[i], ...entry };
748
+ else logs.push({ id: entry.id || `${Date.now()}-${logs.length}`, t: entry.t || new Date().toLocaleTimeString(), ms: entry.ms || 0, op: entry.op || '', impact: entry.impact || '', status: entry.status || 'run' });
749
+ return { ...prev, logs };
750
+ });
751
+ const brief = (v) => { try { const raw = JSON.stringify(v || {}); return raw.length > 360 ? raw.slice(0, 360) + '…' : raw; } catch { return ''; } };
752
+ const call = async (op, args) => {
753
+ const id = `api-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
754
+ const t = new Date().toLocaleTimeString();
755
+ trace({ id, t, op: `API ${op}`, impact: brief(args), status: 'run' });
756
+ const started = Date.now();
757
+ try { const r = await api(op, args); trace({ id, t, op: `API ${op}`, impact: brief(args), ms: Date.now() - started, status: 'ok' }); return r; }
758
+ catch (e) { trace({ id, t, op: `API ${op}`, impact: brief(args), ms: Date.now() - started, status: 'error' }); throw e; }
759
+ };
760
+ const mark = (id, status, detail = '') => { trace({ op: `${status.toUpperCase()} ${id}`, impact: detail, status }); setInitState((prev) => prev ? ({ ...prev, steps: prev.steps.map((x) => x.id === id ? { ...x, status, detail } : x) }) : prev); };
761
+ const finish = () => setInitState((prev) => prev ? ({ ...prev, phase: 'done', running: false, done: true, steps: prev.steps.map((x) => ({ ...x, status: x.status === 'wait' ? 'done' : x.status })) }) : prev);
762
+ let current = 'node';
763
+ try {
764
+ let nodePort = 0;
765
+ if (cfg.mode === 'remote') {
766
+ mark('node', 'run', cfg.remoteUrl || '');
767
+ const r = await call('remoteLogin', { url: cfg.remoteUrl, user: cfg.remoteUser, pass: cfg.remotePass });
768
+ mark('node', 'done', `已连接 ${cfg.remoteUrl} · sessid ${String(r.sessid).slice(0, 10)}…`);
769
+ ['spaces', 'avatar', 'scene', 'project'].forEach((id) => mark(id, 'done', T('远程连接:跳过本地步骤')));
770
+ current = 'chat'; mark('chat', 'run');
771
+ cxWriteText('init', '1');
772
+ setViewMode('chat'); setChatFull(true);
773
+ mark('chat', 'done', T('远程会话已建立'));
774
+ finish();
775
+ return;
776
+ }
777
+ if (cfg.mode === 'docker') {
778
+ mark('node', 'run', `docker ${cfg.image || ''} · :${cfg.port}`);
779
+ const r = await call('dockerDeploy', { port: Number(cfg.port) || 9188, dir: cfg.dir || '', image: cfg.image || '' });
780
+ nodePort = r.port;
781
+ } else if (cfg.mode === 'remoteDeploy') {
782
+ mark('node', 'run', `ssh ${cfg.sshUser || ''}@${cfg.host || ''} · :${cfg.port}`);
783
+ const r = await call('remoteDeploy', { host: cfg.host, user: cfg.sshUser, ssh_port: cfg.sshPort, port: Number(cfg.port) || 9188, dir: cfg.dir || '', image: cfg.image || '' });
784
+ nodePort = r.port;
785
+ } else {
786
+ const list = await loadNodes();
787
+ const found = list.find((n) => Number(n.port) === Number(cfg.port) && n.state === 'running');
788
+ nodePort = found?.port;
789
+ if (!nodePort) {
790
+ const existing = list.find((n) => Number(n.port) === Number(cfg.port));
791
+ mark('node', 'run', `port ${cfg.port} · ${cfg.dir}`);
792
+ if (existing) {
793
+ await call('start', { port: Number(cfg.port) });
794
+ nodePort = existing.port;
795
+ } else {
796
+ const mode = 'auto';
797
+ await call('deploy', { port: Number(cfg.port) || 9188, dir: cfg.dir, repo: cfg.repo, mode });
798
+ nodePort = Number(cfg.port) || 9188;
799
+ }
800
+ }
801
+ const alive = (await loadNodes()).find((n) => Number(n.port) === Number(nodePort));
802
+ if (!alive || alive.state !== 'running') throw new Error(`节点 :${nodePort} 未就绪(${alive?.state || 'missing'}),请查看 ${cfg.dir || '节点目录'}/var/log/deploy.log`);
803
+ }
804
+ mark('node', 'done', `node :${nodePort}`);
805
+ setInitState((prev) => prev ? { ...prev, ctx: { ...(prev.ctx || {}), node: nodePort } } : prev);
806
+
807
+ current = 'spaces';
808
+ mark('spaces', 'run', T('云社区 · 云办公'));
809
+ const wanted = cfg.spaces?.length ? cfg.spaces : ['community', 'enterprise'];
810
+ let have = [];
811
+ try { have = await api('spaces', { node: nodePort }) || []; } catch {}
812
+ const hasKey = (k) => have.some((sp) => String(sp.name || '').includes(k));
813
+ const missing = wanted.filter((k) => !hasKey(k));
814
+ if (!missing.length) {
815
+ mark('spaces', 'done', T('已存在,跳过'));
816
+ } else {
817
+ await call('officialSpaces', { node: nodePort, keys: missing });
818
+ mark('spaces', 'done', T('云社区 + 云办公已就绪'));
819
+ }
820
+
821
+ current = 'avatar';
822
+ mark('avatar', 'run', cfg.avatar.name || '');
823
+ const a = await call('createAvatar', { node: nodePort, name: cfg.avatar.name || '我的数字分身', company: cfg.avatar.company || '云轩领航', city: cfg.avatar.city || '深圳市', persona: '专业、亲和、直接', preferences: '技术、产品、效率', style: '简洁、结构化' });
824
+ if (cfg.avatar.image) await call('updateAvatar', { node: nodePort, uid: a.uid, patch: { image: cfg.avatar.image } }).catch(() => {});
825
+ await call('setAvatar', { node: nodePort, uid: a.uid });
826
+ mark('avatar', 'done', `uid ${String(a.uid).slice(0, 10)}…${cfg.avatar.image ? ' · ' + T('已上传头像') : ''}`);
827
+ setInitState((prev) => prev ? { ...prev, ctx: { ...(prev.ctx || {}), avatar: a } } : prev);
828
+
829
+ current = 'scene';
830
+ mark('scene', 'run', cfg.scene.name || '');
831
+ try {
832
+ const b = await call('createBackground', { node: nodePort, name: cfg.scene.name || '我的赛博世界' });
833
+ const sceneId = b?.created?.id || b?.id || '';
834
+ if (sceneId && cfg.scene.image) await call('updateBackground', { node: nodePort, id: sceneId, patch: { image: cfg.scene.image } }).catch(() => {});
835
+ if (sceneId) await call('setBackground', { node: nodePort, id: sceneId });
836
+ mark('scene', 'done', `${cfg.scene.name || T('我的赛博世界')}${cfg.scene.image ? ' · ' + T('已上传背景') : ''}`);
837
+ } catch (e) {
838
+ mark('scene', 'done', T('跳过:背景服务未就绪'));
839
+ }
840
+
841
+ current = 'project';
842
+ mark('project', 'run', cfg.project.name || '');
843
+ let projectReady = false;
844
+ let projectError = null;
845
+ for (let attempt = 0; attempt < 3; attempt++) {
846
+ try { await call('ensureProject', { node: nodePort, uid: a.uid }); projectReady = true; break; }
847
+ catch (e) { projectError = e; await new Promise((resolve) => setTimeout(resolve, 800)); }
848
+ }
849
+ if (!projectReady) throw projectError || new Error('project instance is not ready');
850
+ const t = await call('pmCreate', { node: nodePort, uid: a.uid, kind: 'target', title: cfg.project.name || '我的第一个项目', content: cfg.project.goal || '' });
851
+ const p = await call('pmCreate', { node: nodePort, uid: a.uid, kind: 'plan', target_uid: t.uid, title: `${cfg.project.name || '我的第一个项目'} · 计划`, content: cfg.project.plan || '' });
852
+ mark('project', 'done', `1 ${T('目标')} · 1 ${T('计划')}`);
853
+
854
+ current = 'chat';
855
+ mark('chat', 'run');
856
+ setNode(nodePort);
857
+ await loadSpaces(nodePort);
858
+ const active = await loadAvatars(nodePort);
859
+ await loadBackgrounds(nodePort).catch(() => null);
860
+ await loadApps(nodePort, '', actorUid);
861
+ cxWriteText('init', '1');
862
+ setViewMode('chat');
863
+ setChatFull(true);
864
+ mark('chat', 'done', T('对话已就绪'));
865
+ finish();
866
+ } catch (e) {
867
+ const label = (INIT_STEPS.find((x) => x.id === current) || {}).label || current;
868
+ setInitState((prev) => prev ? { ...prev, phase: 'error', running: false, done: false, failedStep: current, error: `${label}失败:${String(e?.message || e)}`, steps: prev.steps.map((x) => x.id === current || x.status === 'run' ? { ...x, status: 'err' } : x) } : prev);
869
+ } finally {
870
+ initBusyRef.current = false;
871
+ }
872
+ }, [loadNodes, loadAvatars, loadBackgrounds, loadSpaces, loadApps]);
873
+
874
+ const retryInit = useCallback(async () => {
875
+ const st = initState;
876
+ if (!st?.params) return;
877
+ if (st.failedStep !== 'project' || !st.ctx?.node || !st.ctx?.avatar) return runInit(st.params);
878
+ const cfg = st.params;
879
+ const nodePort = st.ctx.node;
880
+ const a = st.ctx.avatar;
881
+ setInitState((prev) => prev ? { ...prev, phase: 'running', running: true, error: '', failedStep: '', logs: [...(prev.logs || []), { t: new Date().toLocaleTimeString(), op: 'RETRY project', impact: 'ensureProject + pmCreate', status: 'run' }] } : prev);
882
+ try {
883
+ await api('ensureProject', { node: nodePort, uid: a.uid });
884
+ const t = await api('pmCreate', { node: nodePort, uid: a.uid, kind: 'target', title: cfg.project?.name || '我的第一个项目', content: cfg.project?.goal || '' });
885
+ await api('pmCreate', { node: nodePort, uid: a.uid, kind: 'plan', target_uid: t.uid, title: `${cfg.project?.name || '我的第一个项目'} · 计划`, content: cfg.project?.plan || '' });
886
+ setInitState((prev) => prev ? { ...prev, phase: 'done', running: false, done: true, steps: prev.steps.map((x) => x.id === 'project' ? { ...x, status: 'done' } : x) } : prev);
887
+ } catch (e) {
888
+ setInitState((prev) => prev ? { ...prev, phase: 'error', running: false, error: `重试失败:${String(e?.message || e)}`, failedStep: 'project' } : prev);
889
+ }
890
+ }, [initState, runInit]);
891
+ const loadStore = useCallback(async (port) => {
892
+ if (!port) { setStore([]); return []; }
893
+ try { const d = await api('storeApps', { node: port, lang: locale }); setStore(d || []); return d || []; } catch { setStore([]); return []; }
894
+ }, [locale]);
895
+
896
+ useEffect(() => {
897
+ if (bootedRef.current) return;
898
+ bootedRef.current = true;
899
+ let alive = true;
900
+ (async () => {
901
+ // 节点信息一律现查 /c/runtime(缓存只用于首屏占位),保证 version/builtAt/sizeText 是最新的
902
+ if (alive) act('LOAD NODES', loadNodes);
903
+ })();
904
+ return () => { alive = false; };
905
+ }, [act, loadNodes]);
906
+ const autoSpaceRef = useRef({});
907
+ useEffect(() => {
908
+ if (!node || !spaces.length) return;
909
+ if (autoSpaceRef.current[node]) return;
910
+ autoSpaceRef.current[node] = true;
911
+ const saved = selMapRef.current[node] || {};
912
+ const pick = (saved.space && spaces.some((x) => x.name === saved.space)) ? saved.space : (spaces.find((x) => x.status === 'start')?.name || spaces[0].name);
913
+ setSpace(pick);
914
+ rememberSel(node, { space: pick });
915
+ }, [node, spaces]);
916
+ const autoAppRef = useRef({});
917
+ useEffect(() => {
918
+ if (!node || !space || !apps.length) return;
919
+ const k = `${node}:${space}`;
920
+ if (autoAppRef.current[k]) return;
921
+ autoAppRef.current[k] = true;
922
+ const saved = (selMapRef.current[node] || {}).appKey || '';
923
+ const pick = apps.find((a) => appKeyOf(a) === saved) || apps[0];
924
+ setApp(pick);
925
+ rememberSel(node, { appKey: appKeyOf(pick) });
926
+ }, [node, space, apps]);
927
+ const firstNodeEffectRef = useRef(true);
928
+ useEffect(() => {
929
+ const keep = firstNodeEffectRef.current && bootSelRef.current?.node;
930
+ firstNodeEffectRef.current = false;
931
+ if (!keep) { setSpace(null); setApps([]); setApp(null); }
932
+ setAppMode('recent');
933
+ if (!node) return;
934
+ act('LOAD CONTEXT', async () => {
935
+ // boot order: node → active avatar → spaces → data; nothing asks the
936
+ // node for data before the identity is known.
937
+ await loadAvatars(node);
938
+ await Promise.all([loadSpaces(node), loadBackgrounds(node)]);
939
+ });
940
+ }, [node, act, loadSpaces, loadApps, loadAvatars, loadBackgrounds]);
941
+ const selectSpace = (name) => {
942
+ if (!node) return;
943
+ if (space === name) { setSpace(null); setApp(null); rememberSel(node, { space: null, appKey: null }); return; }
944
+ setSpace(name); setApp(null);
945
+ rememberSel(node, { space: name, appKey: null });
946
+ };
947
+ const selectApp = (a) => {
948
+ const key = appKeyOf(a);
949
+ if (app && appKeyOf(app) === key) { setApp(null); rememberSel(node, { appKey: null }); return; }
950
+ setApp(a); rememberSel(node, { appKey: key });
951
+ };
952
+ const prevSelSpaceRef = useRef(space);
953
+ useEffect(() => { const changed = prevSelSpaceRef.current !== space; prevSelSpaceRef.current = space; if (changed) setApp(null); if (node && actorUid) act('LOAD RECENT', () => loadApps(node, space, actorUid)); }, [node, space, actorUid, act, loadApps]);
954
+ useEffect(() => {
955
+ if (!avatar?.project?.place_uid) return;
956
+ const tick = async () => {
957
+ if (document.hidden) return;
958
+ try {
959
+ const d = await api('projectStatus', { node, uid: avatar.uid });
960
+ const n = (v) => Number(String(v || '0').split('x')[0]) || 0;
961
+ const tasks = n(d.counts?.task), reports = n(d.counts?.report);
962
+ if (tasks > reports) log(`⏰ 项目提醒:${tasks - reports} 个任务还没有报告,去完成并提交报告`, 'run');
963
+ } catch { /* project status is best effort */ }
964
+ };
965
+ const timer = setInterval(tick, 60000);
966
+ return () => clearInterval(timer);
967
+ }, [avatar?.project?.place_uid, node, log]);
968
+
969
+
970
+ useEffect(() => {
971
+ if (!nodesLoaded || autoInitRef.current) return;
972
+ autoInitRef.current = true;
973
+ let done = false;
974
+ done = cxReadText('init', '') === '1';
975
+ if (!done) openInit();
976
+ }, [nodesLoaded, openInit]);
977
+
978
+ const refreshAll = () => act('REFRESH', async () => {
979
+ await loadNodes();
980
+ if (!node) return;
981
+ const [a] = await Promise.all([loadAvatars(node), loadSpaces(node)]);
982
+ await loadApps(node, space, actorUid);
983
+ if (appMode === 'store') await loadStore(node);
984
+ });
985
+ const openUrl = (url) => { try { window.open(url, '_blank', 'noopener'); } catch {} };
986
+ const withActor = (raw) => {
987
+ try {
988
+ const u = new URL(raw, window.location.origin);
989
+ if (!/^(127\.0\.0\.1|localhost)$/.test(u.hostname)) return raw;
990
+ if (u.searchParams.has('sessid')) return raw;
991
+ // 统一去掉项目空间路径末尾的 /(/s/<space>/ → /s/<space>)
992
+ if (/^\/s\/[^/]+\/$/.test(u.pathname)) u.pathname = u.pathname.slice(0, -1);
993
+ // user.uid 只对「应用级」命令有意义:/s/<pod>/c/web.team.<mod>.portal 及其子命令。
994
+ // 系统级命令(/c/dream、/c/xterm、/c/vimer、/s/<pod>/c/runtime …)不认它,带上只会污染日志。
995
+ const appLevel = /^\/s\/[^/]+\/c\/web\.team\.[^/]+\.[^/]+/.test(u.pathname);
996
+ let actor = avatar?.uid || '';
997
+ if (!actor) actor = cxReadText('actor', '');
998
+ if (appLevel && actor) u.searchParams.set('user.uid', actor);
999
+ return u.toString();
1000
+ } catch { return raw; }
1001
+ };
1002
+ const withView = (raw) => {
1003
+ try {
1004
+ const u = new URL(raw, window.location.origin);
1005
+ if (/^\/s\/[^/]+\/$/.test(u.pathname)) u.pathname = u.pathname.slice(0, -1);
1006
+ u.searchParams.set('theme', theme);
1007
+ u.searchParams.set('language', locale === 'zh' ? 'zh' : 'en');
1008
+ return u.toString();
1009
+ } catch { return raw; }
1010
+ };
1011
+ useEffect(() => { if (avatar?.uid) cxWriteText('actor', avatar.uid); }, [avatar?.uid]);
1012
+ const webTabsRef = useRef([]);
1013
+ webTabsRef.current = webTabs;
1014
+ const openWeb = (url, title, appMeta) => {
1015
+ if (!url) return '';
1016
+ setDebug((ds) => [...ds.slice(-200), { t: new Date().toLocaleTimeString(), m: `OPEN ${url}` }]);
1017
+ const base0 = withActor(url);
1018
+ const found = webTabsRef.current.find((t) => (t.base || t.url) === base0);
1019
+ if (found) { setActiveWeb(found.id); return found.id; }
1020
+ const id = `w${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`;
1021
+ setActiveWeb(id);
1022
+ setWebTabs((tabs) => [...tabs, { id, base: base0, url: withView(base0), title: title || url, reload: 0, app: appMeta || null }]);
1023
+ return id;
1024
+ };
1025
+ useEffect(() => {
1026
+ setWebTabs((tabs) => tabs.length ? tabs.map((t) => { const b = withActor(t.base || t.url); return { ...t, base: b, url: withView(b) }; }) : tabs);
1027
+ }, [theme, locale, avatar?.uid]);
1028
+ useEffect(() => {
1029
+ try {
1030
+ cxWrite('sel', {
1031
+ node, space,
1032
+ app: app ? { space: app.space || '', command: app.command || '', name: app.name || '', title: app.title || '', place_uid: app.place_uid || '' } : null,
1033
+ });
1034
+ } catch {}
1035
+ }, [node, space, app]);
1036
+ useEffect(() => { if (space && spaces.length && !spaces.some((x) => x.name === space)) setSpace(null); }, [spaces]);
1037
+ useEffect(() => {
1038
+ if (!app || !apps.length) return;
1039
+ const found = apps.find((a) => (app.place_uid && a.place_uid === app.place_uid) || (a.space === app.space && a.command === app.command && (a.name === app.name || a.title === app.title)));
1040
+ if (found && found !== app) setApp(found);
1041
+ }, [apps]);
1042
+ useEffect(() => {
1043
+ try {
1044
+ cxWrite('webtabs', {
1045
+ active: activeWeb || '',
1046
+ tabs: webTabs.map((t) => ({ id: t.id, base: t.base || t.url, title: t.title, app: t.app || null })),
1047
+ });
1048
+ } catch {}
1049
+ }, [webTabs, activeWeb]);
1050
+ const activeTab = webTabs.find((t) => t.id === activeWeb) || webTabs[webTabs.length - 1] || null;
1051
+ const [webAddr, setWebAddr] = useState('');
1052
+ const [webHist, setWebHist] = useState({});
1053
+ const [webMax, setWebMax] = useState(false);
1054
+ const webBoxRef = useRef(null);
1055
+ useEffect(() => { if (!webMax) return; return cxMaxApply(webBoxRef.current); }, [webMax]);
1056
+ useEffect(() => { setWebAddr(activeTab?.base || activeTab?.url || ''); }, [activeTab?.id, activeTab?.base]);
1057
+ useEffect(() => { if (!activeTab) return; const u = activeTab.base || activeTab.url; setWebHist((h) => (h[activeTab.id] ? h : { ...h, [activeTab.id]: { list: [u], idx: 0 } })); }, [activeTab?.id]);
1058
+ const setWebUrl = (id, url) => setWebTabs((tabs) => tabs.map((t) => { if (t.id !== id) return t; const b = withActor(url); return { ...t, base: b, url: withView(b), title: b, reload: (t.reload || 0) + 1 }; }));
1059
+ const goBack = () => { const cur = activeTab && webHist[activeTab.id]; if (!cur || cur.idx <= 0) return; const idx = cur.idx - 1; setWebHist((h) => ({ ...h, [activeTab.id]: { ...cur, idx } })); setWebUrl(activeTab.id, cur.list[idx]); };
1060
+ const goFwd = () => { const cur = activeTab && webHist[activeTab.id]; if (!cur || cur.idx >= cur.list.length - 1) return; const idx = cur.idx + 1; setWebHist((h) => ({ ...h, [activeTab.id]: { ...cur, idx } })); setWebUrl(activeTab.id, cur.list[idx]); };
1061
+ const tabIcon = (t) => {
1062
+ const raw = String(t?.app?.icon || t?.app?.place_avatar || '').trim();
1063
+ if (!raw) return h(UiIcon, { name: 'web', size: 12 });
1064
+ const src = /^https?:/i.test(raw) ? raw : (node ? `http://127.0.0.1:${node}${raw.startsWith('/') ? '' : '/'}${raw}` : raw);
1065
+ return h('img', { className: 'cx-webtab-ico', src, alt: '' });
1066
+ };
1067
+ const currentApp = activeTab ? (activeTab.app || null) : app;
1068
+ const firstTabAppRef = useRef(true);
1069
+ useEffect(() => {
1070
+ if (!activeTab) return;
1071
+ if (firstTabAppRef.current) { firstTabAppRef.current = false; if (!bootSelRef.current?.app) setApp(activeTab.app || null); return; }
1072
+ setApp(activeTab.app || null);
1073
+ }, [activeTab?.id]);
1074
+ const closeWeb = (id) => setWebTabs((tabs) => {
1075
+ const next = tabs.filter((t) => t.id !== id);
1076
+ if (activeWeb === id) { const last = next[next.length - 1] || null; setActiveWeb(last?.id || null); setApp(last?.app || null); }
1077
+ return next;
1078
+ });
1079
+ const closeOthers = (id) => {
1080
+ setWebTabs((tabs) => {
1081
+ const keep = tabs.find((t) => t.id === id);
1082
+ if (keep) { setActiveWeb(keep.id); setApp(keep.app || null); }
1083
+ return keep ? [keep] : [];
1084
+ });
1085
+ };
1086
+ const closeAll = () => { setWebTabs([]); setActiveWeb(null); setApp(null); };
1087
+ const reloadTab = (id) => setWebTabs((tabs) => tabs.map((t) => t.id === id ? { ...t, url: withView(t.base || t.url), reload: (t.reload || 0) + 1 } : t));
1088
+ const navWeb = (id, url) => {
1089
+ const raw = String(url || '').trim();
1090
+ if (!raw) return;
1091
+ const full0 = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
1092
+ const full = withActor(full0);
1093
+ setWebHist((h) => { const cur = h[id] || { list: [], idx: -1 }; const list = cur.list.slice(0, cur.idx + 1); list.push(full); return { ...h, [id]: { list, idx: list.length - 1 } }; });
1094
+ setWebTabs((tabs) => tabs.map((t) => t.id === id ? { ...t, base: full, url: withView(full), title: full, reload: (t.reload || 0) + 1 } : t));
1095
+ setActiveWeb(id);
1096
+ };
1097
+ const manageWeb = (id) => { setActiveWeb(id); setViewMode('manage'); };
1098
+ const reloadWeb = () => activeWeb && reloadTab(activeWeb);
1099
+ const openExternal = () => { if (activeTab?.url) openUrl(activeTab.url); };
1100
+ const selectCreated = async (r) => {
1101
+ setAppMode('recent');
1102
+ const list = await loadApps(node, space, actorUid);
1103
+ const found = (r?.place_uid && list.find((a) => a.place_uid === r.place_uid))
1104
+ || (r?.command ? { name: (r.command.split('.')[2] || r.name), title: r.name, command: r.command, space: r.space, place_uid: r.place_uid, city_name: r.city, company_name: r.company, service_name: r.service?.name || '', place: r.place, functions: [] } : null);
1105
+ if (found) setApp(found);
1106
+ return found;
1107
+ };
1108
+ const chooseAvatar = async (a) => {
1109
+ try {
1110
+ await api('setAvatar', { node, uid: a.uid });
1111
+ setAvatar(a);
1112
+ await loadApps(node, space, actorUid);
1113
+ } catch (e) { log(`切换分身失败:${String(e?.message || e)}`, 'err'); }
1114
+ };
1115
+ useEffect(() => {
1116
+ if (!bg) return;
1117
+ const p = bg.params || {};
1118
+ if ((p.theme || 'system') === themeMode && (p.lang || 'system') === langMode) return;
1119
+ const t = setTimeout(() => {
1120
+ api('updateBackground', { node, id: bg.id, patch: { params: { theme: themeMode, lang: langMode } } })
1121
+ .then((r) => { setBgData(r); setBg((r.items || []).find((x) => x.id === bg.id) || bg); })
1122
+ .catch(() => {});
1123
+ }, 500);
1124
+ return () => clearTimeout(t);
1125
+ }, [themeMode, langMode, bg?.id]);
1126
+ const chooseBg = async (b) => {
1127
+ try { await api('setBackground', { node, id: b.id }); setBg(b); applyScene(b); } catch (e) { log(`切换背景失败:${String(e?.message || e)}`, 'err'); }
1128
+ };
1129
+ const updateScene = async (payload) => {
1130
+ if (!bg) return;
1131
+ try {
1132
+ const { name, ...params } = payload || {};
1133
+ const merged = { ...(bg.params || {}), ...params };
1134
+ const patch = { params: merged };
1135
+ if (name) patch.name = name;
1136
+ const r = await api('updateBackground', { node, id: bg.id, patch });
1137
+ setBgData(r);
1138
+ const nb = (r.items || []).find((x) => x.id === bg.id) || bg;
1139
+ setBg(nb); applyScene(nb);
1140
+ } catch (e) { log(`保存场景失败:${String(e?.message || e)}`, 'err'); }
1141
+ };
1142
+ const createBg = async () => {
1143
+ try {
1144
+ const r = await api('createBackground', { node, name: '' });
1145
+ setBgData({ active: r.active, items: r.items });
1146
+ setBg(r.created || (r.items || []).find((x) => x.id === r.active) || null);
1147
+ } catch (e) { log(`新建背景失败:${String(e?.message || e)}`, 'err'); }
1148
+ };
1149
+ const readImage = (file, cb, max = 720, quality = 0.82) => {
1150
+ try {
1151
+ const img = new Image(); const url = URL.createObjectURL(file);
1152
+ img.onload = () => {
1153
+ const scale = Math.min(1, max / Math.max(img.width || 1, img.height || 1));
1154
+ const c = document.createElement('canvas');
1155
+ c.width = Math.max(1, Math.round((img.width || 1) * scale)); c.height = Math.max(1, Math.round((img.height || 1) * scale));
1156
+ c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
1157
+ URL.revokeObjectURL(url);
1158
+ cb(c.toDataURL('image/jpeg', quality));
1159
+ };
1160
+ img.onerror = () => { URL.revokeObjectURL(url); log('图片读取失败', 'err'); };
1161
+ img.src = url;
1162
+ } catch (e) { log(`图片读取失败:${String(e?.message || e)}`, 'err'); }
1163
+ };
1164
+ const upBgImage = (file) => {
1165
+ if (!file || !bg) return;
1166
+ readImage(file, async (data) => {
1167
+ try {
1168
+ const r = await api('updateBackground', { node, id: bg.id, patch: { image: data } });
1169
+ if (avatar?.uid) api('saveUserMedia', { node, uid: avatar.uid, kind: 'background', dataUrl: data, name: avatar.name || '' }).catch(() => {});
1170
+ setBgData(r); setBg((r.items || []).find((x) => x.id === bg.id) || { ...bg, image: data });
1171
+ } catch (e) { log(`上传背景失败:${String(e?.message || e)}`, 'err'); }
1172
+ });
1173
+ };
1174
+ const upAvatarImage = (file) => {
1175
+ if (!file || !avatar) return;
1176
+ readImage(file, async (data) => {
1177
+ try {
1178
+ await api('updateAvatar', { node, uid: avatar.uid, patch: { image: data } });
1179
+ api('saveUserMedia', { node, uid: avatar.uid, kind: 'avatar', dataUrl: data, name: avatar.name || '' }).catch(() => {});
1180
+ const d = await api('avatars', { node });
1181
+ setAvatarData(d);
1182
+ setAvatar((d.items || []).find((x) => x.uid === avatar.uid) || { ...avatar, image: data });
1183
+ } catch (e) { log(`上传头像失败:${String(e?.message || e)}`, 'err'); }
1184
+ });
1185
+ };
1186
+ const updateAvatarProfile = async (patch) => {
1187
+ if (!avatar) return;
1188
+ try {
1189
+ await api('updateAvatar', { node, uid: avatar.uid, patch });
1190
+ const d = await api('avatars', { node });
1191
+ setAvatarData(d);
1192
+ setAvatar((d.items || []).find((x) => x.uid === avatar.uid) || { ...avatar, ...patch });
1193
+ } catch (e) { log(`修改分身失败:${String(e?.message || e)}`, 'err'); }
1194
+ };
1195
+ const openNode = (port) => { setApp(null); openWeb(`http://127.0.0.1:${port}/`, `node :${port}`); };
1196
+ const [webFocus, setWebFocus] = useState(false);
1197
+ const openSpace = (sp) => { if (!sp) return ''; setApp(null); return openWeb(`http://${CX_HOST}:${node}/s/${encodeURIComponent(sp)}`, sp); };
1198
+ // 应用实例的界面地址:system_uid 走查询参数(框架用它定位实例),
1199
+ // 路径段留空,避免被节点当成数据接口返回 JSON。
1200
+ const appUrl = (a) => cxWebUrl({
1201
+ node, pod: a.space || space || '', command: a.command, place: a.place || 'system',
1202
+ placeUid: a.place_uid || a['system_uid'] || '', uid: avatar?.uid || a.user_uid || '',
1203
+ });
1204
+ const openApp = (a) => { if (!a) return ''; setApp(a); return openWeb(appUrl(a), a.title || a.name, a); };
1205
+ const buildApp = (a) => act(`BUILD ${a.name}`, async () => { await api('buildSpace', { node, space: a.space || space }); await loadApps(node, space, actorUid); });
1206
+ const initSpace = (sp) => act(`INIT ${sp}`, async () => { await api('initSpace', { node, space: sp }); log(`skeleton ready: ${sp}`); });
1207
+ const buildSpace = (sp) => act(`BUILD ${sp}`, async () => { await api('buildSpace', { node, space: sp }); await loadApps(node, sp, actorUid); });
1208
+ const startNode = (port) => act(`START :${port}`, async () => { await api('start', { port }); await loadNodes(); });
1209
+ const stopNode = (port) => act(`STOP :${port}`, async () => { await api('stop', { port }); await loadNodes(); });
1210
+ const deleteNode = (port) => {
1211
+ if (!window.confirm(`删除节点 :${port}?目录和台账都会删除。`)) return;
1212
+ act(`DELETE :${port}`, async () => { await api('deleteNode', { port }); await loadNodes(); });
1213
+ };
1214
+ const startSpace = (sp) => act(`START ${sp}`, async () => { await api('spaceStart', { node, space: sp }); await loadSpaces(node); });
1215
+ const stopSpace = (sp) => act(`STOP ${sp}`, async () => { await api('spaceStop', { node, space: sp }); await loadSpaces(node); });
1216
+ const deleteSpace = (sp) => {
1217
+ if (!window.confirm(`删除项目空间 ${sp}?目录和记录都会删除。`)) return;
1218
+ act(`DELETE ${sp}`, async () => { await api('deleteSpace', { node, space: sp }); await loadSpaces(node); await loadApps(node, '', actorUid); });
1219
+ };
1220
+ const deployOfficial = (keys) => act(`DEPLOY OFFICIAL ${keys.join(',')}`, async () => {
1221
+ const out = await api('officialSpaces', { node, keys });
1222
+ await loadSpaces(node); await loadApps(node, '', actorUid);
1223
+ log(`official: ${out.map((x) => x.space || x.key).join(', ')}`);
1224
+ return out;
1225
+ });
1226
+ const showStore = () => { setAppMode('store'); act('LOAD STORE', () => loadStore(node)); };
1227
+ const backRecent = () => { setAppMode('recent'); act('LOAD RECENT', () => loadApps(node, space, actorUid)); };
1228
+ const openStoreCreate = (service) => { setStoreService(service); setModal('storeInstance'); };
1229
+ const storeAct = (service, action) => act(`STORE ${action} ${service.name}`, async () => {
1230
+ await api('storeAction', { node, space: service.space || '20240724-community', service_uid: service.uid, action });
1231
+ await loadStore(node);
1232
+ });
1233
+ // 商店模板的「编程」:vimer 直接定位到该模块的 portal.go
1234
+ // 远程 / 容器节点:取 token 或 sessid(本机节点免登录,取到的只是本机票据)
1235
+ const nodeAuth = (port) => act('NODE AUTH ' + port, async () => {
1236
+ const r = await api('nodeAuth', { node: port });
1237
+ if (!r || r.ok === false) {
1238
+ window.dispatchEvent(new CustomEvent('cx-toast', { detail: { text: '取凭证失败:' + (r && r.error || '未知错误'), kind: 'err' } }));
1239
+ return;
1240
+ }
1241
+ const d = r.data || r;
1242
+ const who = d.sessid ? `sessid ${String(d.sessid).slice(0, 12)}…` : `token ${String(d.token).slice(0, 12)}…`;
1243
+ try { await navigator.clipboard.writeText(d.sessid || d.token || ''); window.dispatchEvent(new CustomEvent('cx-toast', { detail: { text: '已复制 ' + who } })); }
1244
+ catch { window.dispatchEvent(new CustomEvent('cx-toast', { detail: { text: '已获取 ' + who } })); }
1245
+ });
1246
+ const storeCode = (service) => {
1247
+ const mod = String(service.command || '').split('.')[2] || service.name;
1248
+ const url = `http://${CX_HOST}:${node}/s/${encodeURIComponent(service.space)}/c/vimer`
1249
+ + `?file=${encodeURIComponent(`${mod}/portal.go`)}&path=${encodeURIComponent('src/')}&debug=true`
1250
+ + `#src/:${mod}/portal.go:1`;
1251
+ openCardTool(url, service.title || service.name);
1252
+ };
1253
+ // 商店模板的「配置」:门户表视图
1254
+ const storeConfig = (service) => {
1255
+ const url = `http://${CX_HOST}:${node}/s/${encodeURIComponent(service.space)}/c/${service.command}?view=table&debug=true`;
1256
+ openCardTool(url, service.title || service.name);
1257
+ };
1258
+
1259
+ const startConsoleResize = (e) => {
1260
+ e.preventDefault();
1261
+ const startY = e.clientY, startH = consoleHeight;
1262
+ const move = (ev) => setConsoleHeight(Math.max(80, Math.min(460, startH + startY - ev.clientY)));
1263
+ const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
1264
+ window.addEventListener('pointermove', move);
1265
+ window.addEventListener('pointerup', up);
1266
+ };
1267
+ const nodeRows = nodes.filter((n) => !nodeStatusFilter.length || nodeStatusFilter.includes(n.state));
1268
+ const spaceRows = spaces.filter((s) => !spaceStatusFilter.length || spaceStatusFilter.includes(s.status));
1269
+ const appRows = apps.filter((a) => !appSpaceFilter.length || appSpaceFilter.includes(a.space || space || ''));
1270
+ const appSpaceOptions = [...new Set(apps.map((a) => a.space || space).filter(Boolean))].map((v) => ({ value: v, label: v }));
1271
+ const stateLabel = (v) => ({ running: t('running'), stopped: t('stopped'), offline: t('offline'), missing: t('missing'), starting: t('starting') })[v] || v;
1272
+ const nodeStatusOptions = [...new Set(nodes.map((n) => n.state).filter(Boolean))].map((v) => ({ value: v, label: stateLabel(v) }));
1273
+ const spaceStatusOptions = [...new Set(spaces.map((s) => s.status).filter(Boolean))].map((v) => ({ value: v, label: v === 'start' ? t('running') : v === 'stop' ? t('offline') : v }));
1274
+ const storeRows = store.filter((s) => !storeSpaceFilter.length || storeSpaceFilter.includes(s.space));
1275
+ const storeSpaceOptions = [...new Set(store.map((s) => s.space).filter(Boolean))].map((v) => ({ value: v, label: v }));
1276
+ const activeNode = nodes.find((n) => n.port === node);
1277
+ const activeSpace = spaces.find((s) => s.name === space);
1278
+ const running = nodes.filter((n) => n.state === 'running').length;
1279
+ const stage = activeTab ? 4 : app ? 3 : space ? 2 : node ? 1 : 0;
1280
+
1281
+ const [ideProjects, setIdeProjects] = useState(() => cxRead('ide', []));
1282
+ const [ideActive, setIdeActive] = useState(() => cxReadText('ide-active', ''));
1283
+ const [ideFile, setIdeFile] = useState(null);
1284
+ useEffect(() => cxWrite('ide', ideProjects), [ideProjects]);
1285
+ useEffect(() => cxWriteText('ide-active', ideActive), [ideActive]);
1286
+ useEffect(() => { const key = `${node}:${space}`; if (ideProjects.some((x) => x.key === key)) setIdeActive(key); }, [node, space]);
1287
+ const selectIdeProject = (key) => { const p = ideProjects.find((x) => x.key === key); if (!p) return; setIdeActive(key); setNode(p.node); setSpace(p.space); };
1288
+ const closeIdeProject = (key) => { setIdeProjects((ps) => ps.filter((x) => x.key !== key)); if (ideActive === key) setIdeActive(''); };
1289
+ // 节点/空间右键常用工具:一律开在聊天框自带的网页标签里
1290
+ const openCardTool = (url, title) => { const id = openWeb(url, title || url); if (id) setChatTab(id); return id; };
1291
+ // 首页由节点/空间自身的代码决定,插件只打开根地址,不写入任何配置
1292
+ // 某个工具页是否已经在网页标签里打开
1293
+ const isOpenUrl = (url) => (webTabs || []).some((t) => String(t.url || '') === String(url || ''));
1294
+ // 危险按键来自命令自省 meta._style(dream 的 stop/remove 等)
1295
+ const [actionStyle, setActionStyle] = useState({});
1296
+ useEffect(() => {
1297
+ if (!node) return;
1298
+ api('commandStyle', { node, command: 'dream' })
1299
+ .then((r) => setActionStyle((r && r.style) || {}))
1300
+ .catch(() => setActionStyle({}));
1301
+ }, [node]);
1302
+ const tone = (a) => actionStyle[a] || '';
1303
+ const openHome = (sp) => openCardTool(
1304
+ `http://127.0.0.1:${node}${sp ? `/s/${encodeURIComponent(sp)}` : ''}`,
1305
+ sp ? `${sp} · home` : 'home',
1306
+ );
1307
+ const openIde = async (port, sp) => {
1308
+ const n = Number(port);
1309
+ setNode(n);
1310
+ setViewMode('dev');
1311
+ let target = sp || '';
1312
+ if (sp === undefined) {
1313
+ try { target = ((await api('spaces', { node: n })) || [])[0]?.name || ''; } catch { /* keep */ }
1314
+ }
1315
+ setSpace(target || '');
1316
+ if (target) {
1317
+ const key = `${n}:${target}`;
1318
+ setIdeProjects((ps) => (ps.some((x) => x.key === key) ? ps : [...ps, { key, node: n, space: target }]));
1319
+ setIdeActive(key);
1320
+ }
1321
+ };
1322
+ const chatProps = { open: true, booted: nodesLoaded, full: true, theme, locale, themeMode, setThemeMode, langMode, setLangMode, view: viewMode, mode: workMode, onModeChange: setWorkMode, onRequest: pushReq, fx, onToggleFull: () => setChatFull((v) => !v), onInit: openInit, onClose: () => setViewMode('manage'), nodes, node, spaces, space, apps, app, onSelectNode: setNode, onSelectSpace: setSpace, onSelectApp: (a) => { setApp(a); openApp(a); }, ideFile, onSelectAvatar: chooseAvatar, avatars: avatarData.items, backgrounds: bgData.items, bg, onSelectBg: chooseBg, onCreateBg: createBg, onUploadBg: upBgImage, onUploadAvatar: upAvatarImage, onUpdateScene: updateScene, onUpdateAvatar: updateAvatarProfile, onCreateAvatar: () => setShowAvatars(true), webTabs, onOpenWebTab: openWeb, onCloseWebTab: closeWeb, onNavWebTab: navWeb, onReloadWebTab: reloadTab, onManageWebTab: manageWeb, onRefresh: () => refreshAll(), onOpenApp: openApp, onData: () => app && (setViewMode('chat'), setFormReq({ app, at: Date.now() })), formReq, onStore: showStore, onDevices: () => setShowDevices(true), avatar, cards: {
1323
+ selectNode: setNode, startNode, stopNode, deleteNode, openNode, openIde,
1324
+ openTerminal: (a) => api('openTerminal', { node: a?.node || node, space: a?.space || space }).then(() => true),
1325
+ rebuild: (a) => api('buildSpace', { node: a?.node || node, space: a?.space }).then((r) => { refreshAll(); return r; }),
1326
+ selectSpace, openSpace, startSpace, stopSpace, deleteSpace, selectApp, openApp, openCardTool, openHome, isOpenUrl, tone,
1327
+ }, onOpenDev: () => setViewMode('dev') };
1328
+ return h('div', { className: 'cx-shell', 'data-theme': theme, 'data-color': fx.color, 'data-light': fx.light, 'data-style': fx.style, 'data-view': viewMode, 'data-webfocus': webFocus ? '1' : '0', ref: shellRef },
1329
+ h('style', null, CRITICAL_CSS),
1330
+ h('div', { className: 'cx-water', 'aria-hidden': 'true' }, Array.from({ length: 3 }, (_, i) => h('i', { key: i, style: { left: ['18%', '62%', '84%'][i], top: ['72%', '26%', '58%'][i], animationDelay: `${i * 2.2}s` } }))),
1331
+ h(CursorComet),
1332
+ pipeMenu ? h('div', { className: 'cx-pipe-menu-fixed', style: pipeMenu.right != null ? { right: pipeMenu.right, top: pipeMenu.y } : { left: pipeMenu.x, top: pipeMenu.y }, onClick: (e) => e.stopPropagation() },
1333
+ pipeMenu.title ? h('div', { className: 'cx-pipe-menu-title' }, pipeMenu.title) : null,
1334
+ // 菜单开着的时候网页标签也可能变化(如「关闭所有网页」),这里实时计算打开状态
1335
+ h(MenuList, { items: (pipeMenu.items || []).map((it) => (it && it.url ? { ...it, open: isOpenUrl(it.url) } : it)), onPick: () => setPipeMenu(null) })) : null,
1336
+ h('div', { className: 'cx-cursor-ring', 'aria-hidden': 'true' }),
1337
+ opFx ? h('div', { key: opFx.key, className: `cx-op-fx cx-op-${opFx.kind}`, 'aria-hidden': 'true' }) : null,
1338
+ intro && !lightTheme ? h(ModeIntro, { key: intro, mode: intro, onDone: () => setIntro(null) }) : null,
1339
+ welcome && (welcome.title === 'READY' || !lightTheme) ? h(WelcomeBurst, { key: `w${welcome.title}${Date.now()}`, title: welcome.title, sub: welcome.sub, onDone: () => setWelcome(null) }) : null,
1340
+ h('header', { className: 'cx-header' },
1341
+ sidebarHidden ? h('button', { className: 'cx-btn cx-sb-toggle', title: '显示 / 隐藏 DSH 侧边栏', onClick: () => { try { CX_LAYOUT && CX_LAYOUT.toggleSidebar && CX_LAYOUT.toggleSidebar(); } catch {} } }, '☰') : null,
1342
+ h('div', { className: 'cx-brand' }, h('div', { className: 'cx-mark' }, h(CtxMark, { variant: 'devops', size: 22 })), h('div', null, h('div', { className: 'cx-title' }, 'CONTEXTOS · DEVOPS'), h('div', { className: 'cx-sub' }, `${t('cluster')} · ${t('modules')} · ${t('automation')} · v__CLIENT_CSS_VERSION__`))),
1343
+ h('div', { className: 'cx-views' },
1344
+ [['chat', '对话', 'CHAT'], ['manage', '管理', 'MANAGE'], ['dev', '开发', 'DEV'], ['monitor', '监控', 'MONITOR']].map(([k, zh, en]) =>
1345
+ h(UiAffordance, { key: k, as: 'button', icon: k, zh: locale === 'zh' ? zh : en, en: locale === 'zh' ? en : '', mode: 'short', className: `cx-view${viewMode === k ? ' on' : ''}`, onClick: () => setViewMode(k), title: zh + ' / ' + en }))),
1346
+ h('div', { className: 'cx-actions' },
1347
+ h(DropSelect, { value: fx.sound, title: '声效', onChange: (v) => fxSet('sound', v), options: [{ value: 'soft', label: T('柔和') }, { value: 'cs', label: T('CS 风') }, { value: 'ra', label: T('红警风') }, { value: 'wow', label: T('魔兽风') }, { value: 'sc2', label: T('星际风') }, { value: 'chip', label: T('8-bit') }, { value: 'off', label: T('静音') }] }),
1348
+ h(DropSelect, { value: fx.light, title: '动效', onChange: (v) => fxSet('light', v), options: [{ value: 'high', label: T('动效·强') }, { value: 'mid', label: T('动效·中') }, { value: 'low', label: T('动效·弱') }, { value: 'micro', label: T('动效·微') }, { value: 'off', label: T('动效·关') }] }),
1349
+ h(DropSelect, { value: fx.color, title: '色系', onChange: (v) => fxSet('color', v), options: [{ value: 'cyan', label: T('青') }, { value: 'green', label: T('绿') }, { value: 'violet', label: T('紫') }, { value: 'amber', label: T('琥珀') }, { value: 'rose', label: T('玫红') }, { value: 'ice', label: T('冰蓝') }, { value: 'warm', label: T('暖橙') }] }),
1350
+ h(DropSelect, { value: fx.style, title: '风格', onChange: (v) => fxSet('style', v), options: [{ value: 'tech', label: T('科技风') }, { value: 'classic', label: T('古典风') }, { value: 'desert', label: T('沙漠风') }, { value: 'rainforest', label: T('雨林风') }, { value: 'cyber', label: T('赛博风') }, { value: 'steam', label: T('蒸汽风') }, { value: 'minimal', label: T('极简风') }] }),
1351
+ h(DropSelect, { value: themeMode, title: t('light') + ' / ' + t('dark'), onChange: setThemeMode, options: [{ value: 'system', label: t('system') }, { value: 'light', label: t('light') }, { value: 'dark', label: t('dark') }] }),
1352
+ h(DropSelect, { value: langMode, title: t('chinese') + ' / English', onChange: setLangMode, options: [{ value: 'system', label: t('system') }, { value: 'zh', label: '中文' }, { value: 'en', label: 'English' }] }),
1353
+ h('button', { className: 'cx-btn', onClick: openInit, disabled: !!initState?.running, title: t('initialize') }, t('initialize')))),
1354
+ h('div', { className: 'cx-pipe' },
1355
+ h(UiAffordance, { as: 'span', icon: 'server', zh: t('serve'), mode: 'short', iconSize: 18, onClick: (e) => openPipe(e, t('serve'), (nodes || []).map((n) => ({ label: `:${n.port} · ${n.name || n.state}`, on: n.port === node, go: () => setNode(n.port) })), 'server'), className: `cx-stage cx-stage-title${stage >= 1 ? ' on' : ''}` }, h('span', { className: 'cx-aff-value' }, activeNode ? `:${activeNode.port}` : t('deploy'))),
1356
+ h('span', { className: 'cx-arrow' }, '›'),
1357
+ h(UiAffordance, { as: 'span', icon: 'space', zh: t('space'), mode: 'short', iconSize: 18, onClick: (e) => openPipe(e, t('space'), (spaces || []).map((sp) => ({ label: `${sp.name} · ${sp.status || ''}`, on: sp.name === space, go: () => setSpace(sp.name) })), 'space'), className: `cx-stage cx-stage-title${stage >= 2 ? ' on' : ''}` }, h('span', { className: 'cx-aff-value' }, space || t('create'))),
1358
+ h('span', { className: 'cx-arrow' }, '›'),
1359
+ h(UiAffordance, { as: 'span', icon: 'app', zh: t('recent'), mode: 'short', iconSize: 18, onClick: (e) => openPipe(e, t('recent'), (apps || []).map((a) => ({ label: a.title || a.name, on: app && app.place_uid === a.place_uid, go: () => openApp(a) })), 'app'), className: `cx-stage cx-stage-title${stage >= 3 ? ' on' : ''}` }, h('span', { className: 'cx-aff-value' }, app ? app.title || app.name : t('recent'))),
1360
+ h('span', { className: 'cx-arrow' }, '›'),
1361
+ h(UiAffordance, { as: 'span', icon: 'web', zh: t('web'), mode: 'short', iconSize: 18, onClick: (e) => openPipe(e, t('web'), (webTabs || []).map((tb) => ({ label: tb.title || tb.url, on: tb.id === activeTab?.id, go: () => navWeb(tb.id) })), 'web'), className: `cx-stage cx-stage-title${stage >= 4 ? ' on' : ''}` }, h('span', { className: 'cx-aff-value' }, activeTab ? (activeTab.title || 'page') : 'tabs')),
1362
+ h('div', { className: 'cx-chips' },
1363
+ h(UiAffordance, { as: 'button', icon: 'avatar', zh: t('avatar'), mode: 'short', onClick: (e) => openPipe(e, t('avatar'), (avatarData.items || []).map((a) => ({ label: a.name || a.uid, on: a.uid === avatar?.uid, go: () => chooseAvatar(a) })), 'avatar'), className: 'cx-chip', title: t('avatar') }, h('b', null, avatar?.name || '—')),
1364
+ h(UiAffordance, { as: 'button', icon: 'image', zh: t('scene'), mode: 'short', onClick: (e) => openPipe(e, t('scene'), (bgData.items || []).map((b) => ({ label: b.name, on: b.id === bg?.id, go: () => chooseBg(b) })), 'image'), className: 'cx-chip', title: `${t('scene')}: ${bg?.name || T('默认')}` }, h('b', null, bg?.name || T('默认'))),
1365
+ h(UiAffordance, { as: 'button', icon: 'factory', zh: t('mode'), mode: 'short', onClick: (e) => openPipe(e, t('mode'), CHAT_MODES.map((m) => ({ label: pipeModeLabel(m.id), on: m.id === workMode, go: () => setWorkMode(m.id) })), 'factory'), className: 'cx-chip', title: t('mode') }, h('b', null, pipeModeLabel(workMode))),
1366
+ h(UiAffordance, { as: 'button', icon: 'layers', zh: t('view'), mode: 'short', onClick: (e) => openPipe(e, t('view'), [['chat', '对话'], ['manage', '管理'], ['dev', '开发']].map(([k, zh]) => ({ label: pipeViewLabel(k), on: k === viewMode, go: () => setViewMode(k) })), 'layers'), className: 'cx-chip', title: t('view') }, h('b', null, pipeViewLabel(viewMode))))),
1367
+ error ? h('div', { className: 'cx-err' }, error) : null,
1368
+ h('div', { className: `cx-chat-host${chatFull ? ' full' : ''}`, style: viewMode === 'chat' ? null : { display: 'none' } }, h(ChatPanel, { ...chatProps, docked: !chatFull, onWebFocus: setWebFocus })),
1369
+ devMounted ? h('div', { className: 'cx-dev-host', style: viewMode === 'dev' ? null : { display: 'none' } }, h(DevWorkspace, { locale, node, space, bg, theme, active: viewMode === 'dev', nodes, spaces, projects: ideProjects, activeKey: ideActive, onSelectProject: selectIdeProject, onCloseProject: closeIdeProject, onSelectNode: (p) => setNode(p), onSelectSpace: (sp) => setSpace(sp), onActiveFile: setIdeFile, requests: reqLog, onPick: openIde })) : null,
1370
+ viewMode === 'monitor' ? h('div', { className: 'cx-monitor-host' },
1371
+ h(MonitorView, { nodes, spaces, apps, events, logs, reqLog, locale, onRefresh: () => loadNodes() })) : null,
1372
+ h('div', { className: `cx-body${busy ? ' busy' : ''}`, style: viewMode === 'manage' ? null : { display: 'none' } },
1373
+ h(Panel, {
1374
+ icon: 'server', title: tp('serve'), count: `${nodeRows.length}/${nodes.length} NODES`,
1375
+ tools: [h(MultiFilter, { key: 'f', label: t('status'), options: nodeStatusOptions, value: nodeStatusFilter, onChange: setNodeStatusFilter }), h('span', { key: 'sp', className: 'cx-tools-spacer' }), h(IconButton, { name: 'refresh', key: 'r', className: 'cx-btn xs cx-icon-btn cx-tool-refresh', onClick: () => act('REFRESH NODES', loadNodes), title: t('refresh') }), h(IconButton, { name: 'plus', key: 'd', className: 'cx-btn xs pri cx-icon-btn', onClick: () => setModal('deploy'), title: t('create') })],
1376
+ }, nodeRows.length ? nodeRows.map((n) => h(NodeCard, { key: n.dir || n.port, n, active: node === n.port, busy: !!busy, locale, onSelect: setNode, onStart: startNode, onStop: stopNode, onDelete: deleteNode, onOpen: openNode, onIde: openIde, onTool: openCardTool, onHome: () => openHome(''), onAuth: nodeAuth, isOpen: isOpenUrl, tone })) : h('div', { className: 'cx-empty' }, '还没有节点。先部署一个:copy / download / source。')),
1377
+ h(Panel, {
1378
+ icon: 'space', title: tp('space'), count: node ? `${spaceRows.length}/${spaces.length} SPACES` : t('serve'),
1379
+ tools: [h(MultiFilter, { key: 'f', label: t('status'), options: spaceStatusOptions, value: spaceStatusFilter, onChange: setSpaceStatusFilter }), h('span', { key: 'sp', className: 'cx-tools-spacer' }), h(IconButton, { name: 'refresh', key: 'r', className: 'cx-btn xs cx-icon-btn cx-tool-refresh', disabled: !node, onClick: () => act('REFRESH SPACES', () => loadSpaces(node)), title: t('refresh') }), h(IconButton, { name: 'plus', key: 'c', className: 'cx-btn xs pri cx-icon-btn', disabled: !node, onClick: () => setModal('space'), title: t('create') })],
1380
+ }, !node
1381
+ ? h('div', { className: 'cx-empty' }, '先选择左侧服务节点。')
1382
+ : h(React.Fragment, null,
1383
+ spaceRows.length ? spaceRows.map((s) => h(SpaceCard, { key: s.name, s, node, active: space === s.name, busy: !!busy, onSelect: selectSpace, onOpen: openSpace, onStart: startSpace, onStop: stopSpace, onDelete: deleteSpace, onIde: openIde, onTool: openCardTool, onHome: () => openHome(s.name), isOpen: isOpenUrl, tone, locale })) : h('div', { className: 'cx-empty' }, '该节点还没有项目空间。创建空空间后自动 git init。'),
1384
+ h(OfficialSpaces, { node, onDeploy: deployOfficial, locale }))),
1385
+ h(Panel, {
1386
+ icon: 'app',
1387
+ title: appMode === 'store' ? t('store') : tp('recent'),
1388
+ count: appMode === 'store' ? `${store.length} SERVICES` : (node ? `${appRows.length}/${apps.length} APPS` : 'SELECT NODE'),
1389
+ tools: appMode === 'store'
1390
+ ? [h(MultiFilter, { key: 'f', label: t('space'), options: storeSpaceOptions, value: storeSpaceFilter, onChange: setStoreSpaceFilter }), h('button', { key: 'b', className: 'cx-btn xs pri', onClick: backRecent }, '← ' + t('recent')), h('span', { key: 'sp', className: 'cx-tools-spacer' }), h(IconButton, { name: 'refresh', key: 'r', className: 'cx-btn xs cx-icon-btn cx-tool-refresh', disabled: !node, onClick: () => act('REFRESH STORE', () => loadStore(node)), title: t('refresh') }), h(IconButton, { name: 'plus', key: 't', className: 'cx-btn xs cx-icon-btn', disabled: !space, onClick: () => setModal('app'), title: t('create') + ' ' + t('template') })]
1391
+ : [h(MultiFilter, { key: 'f', label: t('space'), options: appSpaceOptions, value: appSpaceFilter, onChange: setAppSpaceFilter }), h('button', { key: 's', className: 'cx-btn xs pri', disabled: !node, onClick: showStore }, t('store')), h('span', { key: 'sp', className: 'cx-tools-spacer' }), h(IconButton, { name: 'refresh', key: 'r', className: 'cx-btn xs cx-icon-btn cx-tool-refresh', disabled: !node, onClick: () => act('REFRESH RECENT', () => loadApps(node, space, actorUid)), title: t('refresh') }), h(IconButton, { name: 'plus', key: 'c', className: 'cx-btn xs cx-icon-btn', disabled: !space, onClick: () => setModal('app'), title: t('create') + ' ' + t('template') })],
1392
+ },
1393
+ appMode === 'store'
1394
+ ? (storeRows.length ? storeRows.map((s) => h(StoreRow, { key: `${s.space}:${s.command}:${s.uid}`, s, onCreate: openStoreCreate, busy: !!busy, onAction: storeAct, onCode: storeCode, onConfig: storeConfig, locale, node })) : h('div', { className: 'cx-empty' }, '应用商店加载中,或当前节点没有 service。'))
1395
+ : (!node ? h('div', { className: 'cx-empty' }, '先选择左侧服务节点。') : appRows.length ? appRows.map((a) => h(AppRow, { key: `${a.space}:${a.command}:${a.place_uid || a.name}`, a, active: app?.place_uid === a.place_uid, onSelect: selectApp, onOpen: openApp, locale, node })) : h('div', { className: 'cx-empty' }, '该用户最近还没有应用。用「应用商店」创建,或 AI 生成模板。'))),
1396
+ h(Panel, {
1397
+ icon: 'web', title: tp('web'), count: webTabs.length ? `${webTabs.length} TABS` : 'NO TAB',
1398
+ },
1399
+ h('div', { className: `cx-web${webMax ? ' max' : ''}`, ref: webBoxRef },
1400
+ h('div', { className: 'cx-webtabs' },
1401
+ webTabs.map((t) => h('div', { key: t.id, className: `cx-webtab${t.id === activeTab?.id ? ' on' : ''}`, onClick: () => { setActiveWeb(t.id); setApp(t.app || null); }, onContextMenu: (e) => { e.preventDefault(); e.stopPropagation(); setWebMenu({ x: e.clientX, y: e.clientY, id: t.id, tab: t }); } },
1402
+ tabIcon(t),
1403
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, t.title || t.url),
1404
+ h(IconButton, { name: 'close', className: 'x', onClick: (e) => { e.stopPropagation(); closeWeb(t.id); } }))),
1405
+ h(IconButton, { name: 'plus', className: 'cx-btn xs', disabled: !node, onClick: () => node && openNode(node) })),
1406
+ activeTab ? h('div', { className: 'cx-addrbar' },
1407
+ h(IconButton, { name: 'arrowLeft', className: 'nav', disabled: !(webHist[activeTab.id] && webHist[activeTab.id].idx > 0), onClick: goBack, title: T('后退') }),
1408
+ h(IconButton, { name: 'arrowRight', className: 'nav', disabled: !(webHist[activeTab.id] && webHist[activeTab.id].idx < webHist[activeTab.id].list.length - 1), onClick: goFwd, title: T('前进') }),
1409
+ h(IconButton, { name: 'refresh', className: 'nav', onClick: reloadWeb, title: T('刷新') }),
1410
+ h('input', { className: 'cx-addr-input', value: webAddr, placeholder: 'https://…', onChange: (e) => setWebAddr(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') navWeb(activeTab.id, webAddr); } }),
1411
+ h(IconButton, { name: webMax ? 'restore' : 'fullscreen', className: 'nav', onClick: () => setWebMax((v) => !v), title: webMax ? T('恢复') : T('最大化') }),
1412
+ h(MenuButton, { items: [
1413
+ { label: '复制地址', action: () => { try { navigator.clipboard.writeText(activeTab.base || activeTab.url); } catch {} } },
1414
+ { label: '在浏览器打开', action: () => { try { window.open(activeTab.base || activeTab.url, '_blank'); } catch {} } },
1415
+ { label: '刷新', action: reloadWeb },
1416
+ { label: '关闭标签', danger: true, action: () => closeWeb(activeTab.id) },
1417
+ ] })) : null,
1418
+ h('div', { className: 'cx-webframe' },
1419
+ !webTabs.length
1420
+ ? h('div', { className: 'cx-empty' }, '点击最近应用 OPEN,或节点/空间 OPEN,在此多标签打开。')
1421
+ : viewMode !== 'manage'
1422
+ ? h('div', { className: 'cx-empty' }, '切换到管理视图加载网页')
1423
+ : [webTabs.map((t) => h('iframe', {
1424
+ key: `${t.id}#${t.reload || 0}`, src: t.url, title: t.title || 'web', allow: 'clipboard-read; clipboard-write',
1425
+ className: t.id === activeTab?.id ? 'on' : '',
1426
+ onLoad: () => setWebLoaded((m) => m[t.id] ? m : { ...m, [t.id]: 1 }),
1427
+ })),
1428
+ activeTab && !webLoaded[activeTab.id] ? h('div', { key: 'loadmask', className: 'cx-webmask' }, '加载中…') : null])))),
1429
+
1430
+ consoleOpen ? h('div', { className: `cx-console${consoleMax ? ' max' : ''}`, ref: consoleBoxRef, style: consoleMax ? null : { height: consoleHeight } },
1431
+ h('div', { className: 'cx-console-h' },
1432
+ h('div', { className: 'cx-console-tabs' },
1433
+ h('button', { className: `cx-console-tab${consoleTab === 'events' ? ' on' : ''}`, onClick: () => setConsoleTab('events') }, 'EVENTS', h('span', null, events.length)),
1434
+ h('button', { className: `cx-console-tab${consoleTab === 'activity' ? ' on' : ''}`, onClick: () => setConsoleTab('activity') }, 'ACTIVITY', h('span', null, logs.length)),
1435
+ h('button', { className: `cx-console-tab${consoleTab === 'request' ? ' on' : ''}`, onClick: () => setConsoleTab('request') }, 'REQUEST', h('span', null, (reqLog || []).length)),
1436
+ h('button', { className: `cx-console-tab${consoleTab === 'debug' ? ' on' : ''}`, onClick: () => setConsoleTab('debug') }, 'DEBUG / URL', h('span', null, debug.length)),
1437
+ h('button', { className: `cx-console-tab${consoleTab === 'state' ? ' on' : ''}`, onClick: () => setConsoleTab('state') }, 'STATE', h('span', null, stateRows.length)),
1438
+ h('button', { className: `cx-console-tab${consoleTab === 'files' ? ' on' : ''}`, onClick: () => setConsoleTab('files') }, 'FILES', h('span', null, files?.files?.length ?? '')),
1439
+ h('button', { className: `cx-console-tab${consoleTab === 'terminal' ? ' on' : ''}`, onClick: () => { setDetail(null); setConsoleTab('terminal'); } }, 'TERMINAL')),
1440
+ h('span', { className: 'cx-console-status' }, busy ? `RUNNING: ${busy}` : 'IDLE'),
1441
+ h('div', { className: 'cx-console-actions' },
1442
+ consoleTab === 'terminal' ? h('div', { className: 'cx-term-acts' },
1443
+ h('button', { className: 'cx-term-act', onClick: () => consoleTermDo(node, space, 'refresh'), title: T('刷新') }, T('刷新')),
1444
+ h('button', { className: 'cx-term-act', onClick: () => consoleTermDo(node, space, 'clear'), title: T('清屏') }, T('清屏')),
1445
+ h('button', { className: 'cx-term-act', onClick: () => consoleTermDo(node, space, 'reconnect'), title: T('重连') }, T('重连')),
1446
+ h('button', { className: `cx-term-act${termSplit ? ' on' : ''}`, onClick: () => setTermSplit((v) => !v), title: T('分屏') }, T('分屏'))) : null,
1447
+ h('button', { className: 'cx-console-clear', onClick: () => { setLogs([]); logsRef.current = []; setReqLog([]); reqLogRef.current = []; setDebug([]); setEvents([]); setFiles(null); setDetail(null); }, title: '清空' }, '清空'),
1448
+ h(IconButton, { name: consoleMax ? 'restore' : 'fullscreen', onClick: () => setConsoleMax((v) => !v), title: consoleMax ? T('恢复') : T('最大化') }),
1449
+ h(IconButton, { name: 'close', onClick: () => setConsoleOpen(false), title: T('关闭') }))),
1450
+ h('div', { className: 'cx-console-body' },
1451
+ consoleTab === 'terminal' ? h('div', { className: `cx-console-terms${termSplit ? ' split' : ''}` },
1452
+ h(ConsoleTerminal, { node, space, slot: '' }),
1453
+ termSplit ? h(ConsoleTerminal, { node, space, slot: '-b' }) : null) : h('div', { className: 'cx-console-main', ref: logRef },
1454
+ h('table', { className: 'cx-console-table' },
1455
+ h('thead', null, h('tr', null, consoleTable.cols.map((c) => h('th', { key: c.k, onClick: (e) => { e.stopPropagation(); setSort((sp) => sp.key === c.k ? { key: c.k, dir: -sp.dir } : { key: c.k, dir: 1 }); } }, c.label, sortKey === c.k ? (sort.dir > 0 ? ' ▲' : ' ▼') : '')))),
1456
+ h('tbody', null, sortedRows.map((r, i) => h('tr', {
1457
+ key: i,
1458
+ className: `${String(r.ok) === 'ERR' ? 'err' : ''}${detail && detail.key === rowKey(r) ? ' on' : ''}`,
1459
+ onClick: () => openDetail(r),
1460
+ }, consoleTable.cols.map((c) => h('td', { key: c.k, className: c.k === 'loc' ? 'cx-loc' : '' }, String(r[c.k] ?? '')))))),
1461
+ ),
1462
+ sortedRows.length === 0 ? h('div', { className: 'cx-line' }, h('span', { className: 'm' }, consoleTab === 'files' ? '没有数据文件(服务端重启后可用)' : '等待操作…')) : null),
1463
+ detail && consoleTab !== 'terminal' ? h(DebugDetail, { detail, onClose: () => setDetail(null) }) : null),
1464
+ h('div', { className: 'cx-console-resize', onPointerDown: startConsoleResize })) : h('button', { className: 'cx-console-open', onClick: () => setConsoleOpen(true) }, 'CONSOLE'),
1465
+ webMenu ? h('div', { className: 'cx-ctxmenu', style: { left: Math.min(webMenu.x, Math.max(0, (typeof window !== 'undefined' ? window.innerWidth : 1200) - 210)), top: Math.min(webMenu.y, Math.max(0, (typeof window !== 'undefined' ? window.innerHeight : 800) - 220)) }, onMouseDown: (e) => e.stopPropagation() },
1466
+ h('div', { className: 'cx-ctxmenu-t' }, webMenu.tab?.title || webMenu.tab?.url || 'WEB'),
1467
+ [['关闭', () => closeWeb(webMenu.id)], ['关闭其它标签', () => closeOthers(webMenu.id)], ['关闭全部标签', closeAll], ['刷新', () => reloadTab(webMenu.id)], ['外部打开', () => openUrl(webMenu.tab?.url || '')]].map(([label, action]) => h('button', { key: label, onClick: () => { setWebMenu(null); action(); } }, T(label))),
1468
+ webMenu.tab?.app ? h(React.Fragment, null,
1469
+ h('button', { onClick: () => { setWebMenu(null); setApp(webMenu.tab.app); setModal('code'); } }, T('编辑代码')),
1470
+ h('button', { onClick: () => { setWebMenu(null); setApp(webMenu.tab.app); setViewMode('chat'); setFormReq({ app: webMenu.tab.app, at: Date.now() }); } }, T('添加数据')),
1471
+ h('button', { onClick: () => { setWebMenu(null); setApp(webMenu.tab.app); setModal('instance'); } }, T('创建应用实例'))) : null) : null,
1472
+ modal === 'deploy' ? h(DeployModal, { onClose: () => setModal(null), onDone: () => { setModal(null); act('LOAD NODES', loadNodes); }, onScan: () => { setModal(null); setShowDevices(true); } }) : null,
1473
+ modal === 'space' ? h(SpaceModal, { node, onClose: () => setModal(null), onDone: (name) => { setModal(null); act('LOAD SPACES', async () => { await loadSpaces(node); setSpace(name); }); } }) : null,
1474
+ modal === 'app' ? h(AppModal, { node, space, onClose: () => setModal(null), onDone: (r) => { setModal(null); const synthetic = { name: r.name, title: r.help, command: r.command, space: r.space, path: r.path, place: r.place || '', functions: [], recent: false }; setApps((xs) => [synthetic, ...xs.filter((a) => !(a.name === synthetic.name && a.command === synthetic.command))]); setApp(synthetic); openApp(synthetic); act('LOAD RECENT', () => loadApps(node, space, actorUid)); } }) : null,
1475
+ modal === 'data' && currentApp ? h(DataModal, { node, space: currentApp.space || space, app: currentApp, onClose: () => setModal(null) }) : null,
1476
+ modal === 'code' && currentApp ? h(CodeModal, { node, space: currentApp.space || space, app: currentApp, onClose: () => setModal(null) }) : null,
1477
+ modal === 'instance' && currentApp ? h(InstanceModal, { node, space: currentApp.space || space, app: currentApp, avatar, onClose: () => setModal(null), onDone: (r) => { setModal(null); act('LOAD RECENT', () => selectCreated(r)); } }) : null,
1478
+ modal === 'storeInstance' && storeService ? h(StoreInstanceModal, { node, service: storeService, avatar, onClose: () => setModal(null), onDone: (r) => { setModal(null); setStoreService(null); act('LOAD RECENT', () => selectCreated(r)); } }) : null,
1479
+ showDevices ? h(DeviceModal, { node, onClose: () => setShowDevices(false) }) : null,
1480
+ showAvatars ? h(AvatarModal, { node, data: avatarData, active: avatar, onClose: () => setShowAvatars(false), onSelect: (a) => { setAvatar(a); setShowAvatars(false); }, onCreated: async (a) => { const d = await api('avatars', { node }); setAvatarData(d); const found = (d.items || []).find((x) => x.uid === a.uid) || a; setAvatar(found); setShowAvatars(false); }, onUpdated: async (a) => { const d = await api('avatars', { node }); setAvatarData(d); const found = (d.items || []).find((x) => x.uid === a.uid) || a; setAvatar(found); } }) : null,
1481
+ h(InitOverlay, { state: initState, onClose: () => setInitState(null), onStart: runInit, onRetry: retryInit, onLoginRemote: (cfg) => api('remoteLogin', cfg) }),
1482
+ dataPulse ? h('div', { key: dataPulse, className: 'cx-data-flash', 'aria-hidden': 'true' }) : null,
1483
+ toast ? h('div', { key: toast.id, className: `cx-toast ${toast.kind}`, role: 'status', 'aria-live': 'polite' }, h('span', { className: 'cx-toast-mark' }, toast.kind === 'err' ? '!' : '✓'), h('span', null, toast.text)) : null,
1484
+ busy ? h('div', { className: 'cx-busy-overlay', role: 'status', 'aria-live': 'polite' },
1485
+ h('div', { className: 'cx-busy-card' }, h('span', { className: 'cx-busy-spinner' }), h('div', null, h('b', null, busy), h('div', { className: 'cx-busy-sub' }, locale === 'zh' ? '正在等待接口返回…' : 'Waiting for API…')))) : null,
1486
+ viewMode !== 'chat' ? h('button', { className: 'cx-chat-fab', onClick: () => setViewMode('chat'), title: 'AI Copilot' }, 'AI') : null);
1487
+ }
1488
+
1489
+ class Boundary extends React.Component {
1490
+ constructor(props) { super(props); this.state = { err: null }; }
1491
+ static getDerivedStateFromError(e) { return { err: e }; }
1492
+ componentDidCatch(e) { reportError(String(e?.message || e)); }
1493
+ render() {
1494
+ if (!this.state.err) return this.props.children;
1495
+ return h('div', { className: 'cx-error-pane', role: 'alert' },
1496
+ h('div', { className: 'cx-error-title' }, T('界面出现错误')),
1497
+ h('div', { className: 'cx-error-msg' }, String(this.state.err?.message || this.state.err)),
1498
+ h('div', { className: 'cx-error-actions' },
1499
+ h('button', { className: 'cx-btn', onClick: () => this.setState({ err: null }) }, T('重试')),
1500
+ h('button', { className: 'cx-btn pri', onClick: () => location.reload() }, T('刷新页面')),
1501
+ h('button', { className: 'cx-btn', onClick: () => this.setState({ err: null }) }, T('返回'))));
1502
+ }
1503
+ }
1504
+
1505
+ return {
1506
+ inject: ['slots', 'layout'],
1507
+ apply(ctx) {
1508
+ try { CX_LAYOUT = ctx.layout || (ctx.get && ctx.get('layout')) || null; } catch {}
1509
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id: 'dsh-contextos-devops', order: 20, label: () => 'ContextOS DevOps' }, Icon));
1510
+ ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: 'dsh-contextos-devops' }, () => h(Boundary, null, h(Page))));
1511
+ // 输入框内最左:ContextOS 芯片(切换服务节点 / 打开控制台)
1512
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({ name: 'conversation.input.left', id: 'dsh-contextos-devops', order: 0 }, (props) => h(CtxChip, { sessionId: (props && props.sessionId) || '' })));
1513
+ // 会话标题栏右侧:ContextOS 按钮(打开控制台)
1514
+ ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({ name: 'conversation.session.header.utilities', id: 'dsh-contextos-devops-center', order: 10 }, (props) => h(CtxHeaderButton, { sessionId: (props && props.sessionId) || '' })));
1515
+ },
1516
+ };
1517
+ },
1518
+ });