dsh-contextos-devops 0.0.0-stage → 1.48.32

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 +92 -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,1422 @@
1
+ function ChatPanel({ open, booted, fx, onClose, nodes, node, spaces, space, apps, app, avatar, ideFile, view, mode, onModeChange, onRequest, webTabs, onOpenWebTab, onCloseWebTab, onNavWebTab, onReloadWebTab, onManageWebTab, full, docked, theme, locale, themeMode, setThemeMode, langMode, setLangMode, onToggleFull, onInit, onSelectNode, onSelectSpace, onSelectApp, onSelectAvatar, avatars, backgrounds, bg, onSelectBg, onCreateBg, onUploadBg, onUploadAvatar, onUpdateScene, onUpdateAvatar, onCreateAvatar, onRefresh, onOpenApp, onData, onStore, onDevices, formReq, cards, onOpenDev, onWebFocus }) {
2
+ const tt = (key) => term(locale, key);
3
+ const sectionTitle = (cn, en) => locale === 'zh' ? `${cn} ${en}` : en;
4
+ const modeLabel = (m) => locale === 'zh' ? m.name : ({ avatar: 'Digital Avatar', factory: 'Software Factory', iot: 'Cyber World' })[m.id] || m.name;
5
+ const [pick, setPick] = useState(null);
6
+ const [files, setFiles] = useState([]);
7
+ const [showMore, setShowMore] = useState(false);
8
+ const [folds, setFolds] = useState({ map: true, targets: false, plans: false, tasks: false, reports: false, feedbacks: false });
9
+ useEffect(() => cxWrite('folds', folds), [folds]);
10
+ const fold = (k) => folds[k] === true;
11
+ const [calFull, setCalFull] = useState(false);
12
+ const calRef = useRef(null);
13
+ const [mapFull, setMapFull] = useState(false);
14
+ const mapApiRef = useRef(null);
15
+ const mapSecRef = useRef(null);
16
+ useEffect(() => { if (!mapFull) return; return cxMaxApply(mapSecRef.current, { bg: 'var(--cx-surface-1)', padding: 8 }); }, [mapFull]);
17
+ useEffect(() => { if (!calFull) return; return cxMaxApply(calRef.current); }, [calFull]);
18
+ useEffect(() => { if (!calFull) return; const close = (e) => { if (e.key === 'Escape') setCalFull(false); }; window.addEventListener('keydown', close); return () => window.removeEventListener('keydown', close); }, [calFull]);
19
+ const [calStart, setCalStart] = useState('');
20
+ const [calEnd, setCalEnd] = useState('');
21
+ useEffect(() => cxWrite('cal', { ...cxRead('cal', {}), start: calStart, end: calEnd }), [calStart, calEnd]);
22
+ const [planStatusFilter, setPlanStatusFilter] = useState([]);
23
+ const [taskStatusFilter, setTaskStatusFilter] = useState([]);
24
+ const [reportTypeFilter, setReportTypeFilter] = useState([]);
25
+ const [feedbackStatusFilter, setFeedbackStatusFilter] = useState([]);
26
+ const [pmForm, setPmForm] = useState(null);
27
+ const sessionStateRef = useRef({ key: '', ready: false, msgs: [] });
28
+ const seedingRef = useRef(false);
29
+ const fileRef = useRef(null);
30
+ const avatarImgRef = useRef(null);
31
+ const bgImgRef = useRef(null);
32
+ const [showLeft, setShowLeft] = useState(true);
33
+ const [showRight, setShowRight] = useState(true);
34
+ useEffect(() => cxWrite('panels', { left: showLeft, right: showRight }), [showLeft, showRight]);
35
+ const [ctxMenu, setCtxMenu] = useState(null);
36
+ const [sceneOpen, setSceneOpen] = useState(false);
37
+ // 聊天面板当前标签:按 URL 基准恢复(标签 id 每次启动都会重排,不能存 id)
38
+ const tabIdOfBase = (base) => { const t = (webTabs || []).find((x) => (x.base || x.url) === base); return t ? t.id : null; };
39
+ const [chatTab, setChatTab] = useState(() => {
40
+ try {
41
+ const base = (cxSessionGet() || {}).chatTab;
42
+ if (!base || base === 'chat') return 'chat';
43
+ return tabIdOfBase(base) || 'chat';
44
+ } catch { return 'chat'; }
45
+ });
46
+ useEffect(() => {
47
+ // 切换节点/空间后标签会被重建,此时回退到对话
48
+ if (chatTab !== 'chat' && !(webTabs || []).some((t) => t.id === chatTab)) { setChatTab('chat'); return; }
49
+ const t = (webTabs || []).find((x) => x.id === chatTab);
50
+ cxSessionSave({ chatTab: t ? (t.base || t.url) : 'chat' });
51
+ }, [chatTab, webTabs]);
52
+ const [replyPop, setReplyPop] = useState(false);
53
+ const [chatLoaded, setChatLoaded] = useState({});
54
+ const [webMax, setWebMax] = useState(() => !!(cxSessionGet() || {}).webMax);
55
+ useEffect(() => { cxSessionSave({ webMax }); }, [webMax]);
56
+ // 网页标签一出现就进入「最大化」:收起左右两栏,让页面占满整个对话区
57
+ useEffect(() => { onWebFocus && onWebFocus(!!webMax && chatTab !== 'chat'); }, [webMax, chatTab]);
58
+ const [turn, setTurn] = useState(null);
59
+ // 链接直接在聊天框自带的网页标签里打开(CHAT 旁边那个 tab),不再另开窗口
60
+ OPEN_LINK = (url, title) => { const id = onOpenWebTab ? onOpenWebTab(url, title) : ''; if (id) setChatTab(id); };
61
+ useEffect(() => {
62
+ const onMsg = (e) => {
63
+ const d = e && e.data;
64
+ if (!d || d.type !== 'ctx-open-url') return;
65
+ const url = String(d.url || '');
66
+ if (!/^https?:\/\//i.test(url)) return;
67
+ const id = onOpenWebTab ? onOpenWebTab(url, url) : '';
68
+ if (id) setChatTab(id);
69
+ };
70
+ window.addEventListener('message', onMsg);
71
+ return () => window.removeEventListener('message', onMsg);
72
+ }, [onOpenWebTab]);
73
+ useEffect(() => { if (chatTab !== 'chat' && !(webTabs || []).some((t) => t.id === chatTab)) setChatTab('chat'); }, [webTabs, chatTab]);
74
+ const [avatarEditOpen, setAvatarEditOpen] = useState(false);
75
+ const openCtx = (e, kind) => { e.preventDefault(); e.stopPropagation(); setCtxMenu({ x: e.clientX, y: e.clientY, kind }); };
76
+ useEffect(() => {
77
+ if (!ctxMenu) return;
78
+ const close = () => setCtxMenu(null);
79
+ const esc = (e) => { if (e.key === 'Escape') close(); };
80
+ window.addEventListener('click', close);
81
+ window.addEventListener('scroll', close, true);
82
+ window.addEventListener('keydown', esc);
83
+ return () => { window.removeEventListener('click', close); window.removeEventListener('scroll', close, true); window.removeEventListener('keydown', esc); };
84
+ }, [ctxMenu]);
85
+ const modeIntro = (m) => (m ? (locale === 'zh' ? m.intro : (m.introEn || m.intro)) : '');
86
+ const modeQuick = (m) => (m ? (locale === 'zh' ? m.quick : (m.quickEn || m.quick)) : []);
87
+ const [msgs, setMsgs] = useState(() => {
88
+ const a = cxRead('chat', []);
89
+ if (Array.isArray(a) && a.length) return a;
90
+ return [{ role: 'ai', text: modeIntro(CHAT_MODES[0]) }];
91
+ });
92
+ const [live, setLive] = useState(null);
93
+ const [traceMsg, setTraceMsg] = useState(null);
94
+ useEffect(() => {
95
+ cxWrite('chat', msgs.slice(-120));
96
+ }, [msgs]);
97
+ /* 布局自愈:消息卡片的结构是「头一行、按钮在行尾、正文满宽」。
98
+ 若页面上还挂着旧版本样式表,级联会把正文挤到右边;这里用内联
99
+ !important 兜底(不依赖任何外部样式),并记录「谁把它挤歪了」。 */
100
+ useEffect(() => {
101
+ let timer = null, observer = null, warned = false;
102
+ const fix = () => {
103
+ try {
104
+ const root = document.querySelector('.cx-chat-b');
105
+ if (!root) return;
106
+ const fixed = [];
107
+ root.querySelectorAll('.cx-msg').forEach((card) => {
108
+ const main = card.querySelector('.cx-msg-main');
109
+ const meta = card.querySelector('.cx-msg-meta');
110
+ const tools = card.querySelector('.cx-msg-tools');
111
+ const body = card.querySelector('.cx-msg-body');
112
+ const want = [
113
+ [card, 'display', 'flex', 'display'],
114
+ [card, 'align-items', 'flex-start', 'alignItems'],
115
+ [main, 'display', 'flex', 'display'],
116
+ [main, 'flex-direction', 'column', 'flexDirection'],
117
+ [main, 'align-items', 'stretch', 'alignItems'],
118
+ [main, 'flex', '1 1 auto', 'flexGrow'],
119
+ [main, 'min-width', '0', 'minWidth'],
120
+ [meta, 'display', 'flex', 'display'],
121
+ [meta, 'flex-direction', 'row', 'flexDirection'],
122
+ [meta, 'align-items', 'center', 'alignItems'],
123
+ [meta, 'flex-wrap', 'wrap', 'flexWrap'],
124
+ [tools, 'margin-left', '0', 'marginLeft'],
125
+ [body, 'width', '100%', 'width'],
126
+ [body, 'max-width', '100%', 'maxWidth'],
127
+ ];
128
+ want.forEach(([el, cssKey, val, jsKey]) => {
129
+ if (!el) return;
130
+ const before = window.getComputedStyle(el)[jsKey];
131
+ if (jsKey === 'flexDirection' && String(before) !== val) fixed.push(`${String(el.className).split(' ')[0]}.${jsKey}=${before}`);
132
+ if (el.style.getPropertyValue(cssKey) !== val || el.style.getPropertyPriority(cssKey) !== 'important') el.style.setProperty(cssKey, val, 'important');
133
+ });
134
+ });
135
+ if (fixed.length && !warned && typeof activity === 'function') {
136
+ warned = true;
137
+ activity(`布局自愈 ${fixed.length} 处(旧样式接管):${fixed.slice(0, 4).join(' ')}`, 'warn');
138
+ }
139
+ } catch {}
140
+ };
141
+ const boot = () => { fix(); timer = setTimeout(fix, 400); timer = setTimeout(fix, 1600); };
142
+ boot();
143
+ try {
144
+ const root = document.querySelector('.cx-chat-b');
145
+ if (root && window.MutationObserver) {
146
+ observer = new MutationObserver(() => fix());
147
+ observer.observe(root, { childList: true, subtree: true });
148
+ }
149
+ } catch {}
150
+ return () => { if (timer) clearTimeout(timer); if (observer) observer.disconnect(); };
151
+ }, []);
152
+ const inputRef = useRef(null);
153
+ const lastUserRef = useRef('');
154
+ const [mapTheme, setMapTheme] = useState(theme === 'light' ? 'light' : 'dark');
155
+ const spaceRef = useRef(space);
156
+ useEffect(() => { if (space) spaceRef.current = space; }, [space]);
157
+ useEffect(() => { setMapTheme(theme === 'light' ? 'light' : 'dark'); }, [theme]);
158
+ const draftTimerRef = useRef(null);
159
+ const initialDraft = cxReadText('draft', '');
160
+ useEffect(() => {
161
+ const onKey = (e) => {
162
+ if ((e.metaKey || e.ctrlKey) && String(e.key).toLowerCase() === 'k') {
163
+ e.preventDefault();
164
+ inputRef.current?.focus();
165
+ }
166
+ };
167
+ window.addEventListener('keydown', onKey);
168
+ return () => window.removeEventListener('keydown', onKey);
169
+ }, []);
170
+ const [step, setStep] = useState(null);
171
+ const [busy, setBusy] = useState(false);
172
+ useEffect(() => {
173
+ if (!open || busy) return;
174
+ try { inputRef.current?.focus({ preventScroll: true }); } catch {}
175
+ }, [busy, open]);
176
+ const endRef = useRef(null);
177
+
178
+ const { targetId, setTargetId, planId, setPlanId, taskId, setTaskId, reportId, setReportId, feedbackId, setFeedbackId } = usePmSelection({ avatarUid: avatar?.uid || '' });
179
+ const pmNode = node || (nodes || []).find((n) => n.state === 'running')?.port || (nodes || [])[0]?.port || 0;
180
+ const [traceIds, setTraceIds] = useState([]);
181
+ const [traceLog, setTraceLog] = useState('');
182
+ const traceTimerRef = useRef(0);
183
+ const { pm, pmError, pmRefreshing, loadPm } = usePmData({ node: pmNode, avatarUid: avatar?.uid || '', locale, targetId, planId, taskId, reportId });
184
+ useEffect(() => {
185
+ const onJump = (e) => {
186
+ const uid = String(e?.detail?.uid || '');
187
+ if (!uid) return;
188
+ setTargetId(uid); setPlanId(''); setTaskId(''); setReportId(''); setFeedbackId('');
189
+ };
190
+ window.addEventListener('cx-select-target', onJump);
191
+ return () => window.removeEventListener('cx-select-target', onJump);
192
+ }, []);
193
+ useEffect(() => {
194
+ const onTrace = (e) => {
195
+ setTraceIds(Array.isArray(e?.detail?.uids) ? e.detail.uids : []);
196
+ setTraceLog(String(e?.detail?.logId || ''));
197
+ clearTimeout(traceTimerRef.current);
198
+ traceTimerRef.current = setTimeout(() => { setTraceIds([]); setTraceLog(''); }, 3200);
199
+ };
200
+ window.addEventListener('cx-trace', onTrace);
201
+ return () => { clearTimeout(traceTimerRef.current); window.removeEventListener('cx-trace', onTrace); };
202
+ }, []);
203
+ useEffect(() => {
204
+ if (!open || !avatar?.uid || !pm?.project || (pm.targets || []).length || seedingRef.current) return;
205
+ seedingRef.current = true;
206
+ api('seedProject', { node: pmNode, uid: avatar.uid }).then(() => loadPm()).catch(() => {}).finally(() => { seedingRef.current = false; });
207
+ }, [open, pm?.project?.place_uid, pm?.targets?.length, pmNode, avatar?.uid, loadPm]);
208
+ const targets = pm?.targets || [];
209
+ const activeTarget = targets.find((t) => t.uid === targetId) || targets[0] || null;
210
+ const allPlans = sortPmItems(pm?.plans || []);
211
+ const plans = allPlans.filter((p) => !planStatusFilter.length || planStatusFilter.includes(p.status_name));
212
+ const activePlan = plans.find((p) => p.uid === planId) || plans[0] || null;
213
+ const allTasks = sortPmItems(pm?.tasks || []);
214
+ const tasks = allTasks.filter((t) => (!taskStatusFilter.length || taskStatusFilter.includes(t.status_name)) && (() => { const d = String(t.created_at || '').slice(0, 10); return (!calStart || d >= calStart) && (!calEnd || d <= calEnd); })());
215
+ const activeTask = tasks.find((t) => t.uid === taskId) || tasks[0] || null;
216
+ const allReports = pm?.reports || [];
217
+ const reports = allReports.filter((r) => !reportTypeFilter.length || reportTypeFilter.includes(r.type_name));
218
+ const activeReport = reports.find((r) => r.uid === reportId) || reports[0] || null;
219
+ const allFeedbacks = pm?.feedbacks || [];
220
+ const feedbacks = allFeedbacks.filter((f) => !feedbackStatusFilter.length || feedbackStatusFilter.includes(f.status_name));
221
+ const activeFeedback = feedbacks.find((f) => f.uid === feedbackId) || feedbacks[0] || null;
222
+ const planStatusOptions = pmFilterOptions(allPlans, 'status_name');
223
+ const taskStatusOptions = pmFilterOptions(allTasks, 'status_name');
224
+ const reportTypeOptions = pmFilterOptions(allReports, 'type_name');
225
+ const feedbackStatusOptions = pmFilterOptions(allFeedbacks, 'status_name');
226
+ const calOnChange = useCallback((a, b) => { setCalStart(a); setCalEnd(b); }, []);
227
+ const sessionKey = `dsh-contextos-devops:chat:${avatar?.uid || 'anon'}:${activePlan?.uid || 'none'}`;
228
+ useEffect(() => {
229
+ /* 兜底:即使加载到旧样式(!important 12px),也强制输入框为 13px */
230
+ const el = inputRef.current;
231
+ if (!el) return;
232
+ try {
233
+ el.style.setProperty('font-size', '13px', 'important');
234
+ el.style.setProperty('line-height', '1.65', 'important');
235
+ } catch {}
236
+ /* 把真实渲染结果回报给服务端,便于核对当前加载的 bundle 与计算字号 */
237
+ setTimeout(() => {
238
+ try {
239
+ const cs = window.getComputedStyle(el);
240
+ api('stateSet', { name: 'clientCheckChat', value: {
241
+ at: new Date().toISOString(),
242
+ version: String(CLIENT_CSS).split('?v=')[1] || '',
243
+ fontSize: cs.fontSize,
244
+ lineHeight: cs.lineHeight,
245
+ placeholder: (() => { try { return window.getComputedStyle(el, '::placeholder').fontSize; } catch { return ''; } })(),
246
+ rev: String(CLIENT_CSS),
247
+ } }).catch(() => {});
248
+ } catch {}
249
+ }, 300);
250
+ }, [sessionKey]);
251
+ useEffect(() => {
252
+ if (!open) return;
253
+ const t = setTimeout(() => { try { inputRef.current?.focus({ preventScroll: true }); } catch {} }, 60);
254
+ return () => clearTimeout(t);
255
+ }, [open, sessionKey]);
256
+
257
+ const scrollKeyOf = () => `dsh-contextos-devops:scroll:${sessionKey}`;
258
+ const scrollRestorePendingRef = useRef(true);
259
+ const scrollSaveTimer = useRef(null);
260
+ useEffect(() => { scrollRestorePendingRef.current = true; }, [sessionKey]);
261
+ // 一次性贴底:同步 + rAF + 三档延时,兼顾卡片异步撑高
262
+ const userUpRef = useRef(false);
263
+ const scrollToEnd = () => {
264
+ userUpRef.current = false;
265
+ const put = () => { const el = endRef.current; if (el) el.scrollTop = el.scrollHeight; };
266
+ put();
267
+ requestAnimationFrame(put);
268
+ [60, 200, 420].forEach((t) => setTimeout(put, t));
269
+ };
270
+ useEffect(() => {
271
+ if (!endRef.current) return;
272
+ scrollRestorePendingRef.current = false;
273
+ scrollToEnd();
274
+ }, [msgs, busy, open, view, chatTab, sessionKey, space, node]);
275
+ // 切回聊天视图时再补一次(等布局完成后)
276
+ useEffect(() => {
277
+ if (view !== 'chat' || chatTab !== 'chat') return;
278
+ const t = setTimeout(scrollToEnd, 60);
279
+ return () => clearTimeout(t);
280
+ }, [view, chatTab, open, sessionKey]);
281
+ // 卡片图片/表格异步撑高后,仍然贴底(否则看起来“回滚到最前面”)
282
+ useEffect(() => {
283
+ const el = endRef.current;
284
+ if (!el) return;
285
+ // 只有用户主动向上滚才停止跟随;内容撑高(卡片/表格/图片)时继续贴底
286
+ const el2 = el;
287
+ const onWheel = (e) => { if (e.deltaY < 0) userUpRef.current = true; };
288
+ const onTouch = () => { userUpRef.current = true; };
289
+ const onKey = (e) => { if (e.key === 'PageUp' || e.key === 'ArrowUp' || e.key === 'Home') userUpRef.current = true; };
290
+ el2.addEventListener('wheel', onWheel, { passive: true });
291
+ el2.addEventListener('touchmove', onTouch, { passive: true });
292
+ el2.addEventListener('keydown', onKey);
293
+ const ro = new ResizeObserver(() => { if (!userUpRef.current) el2.scrollTop = el2.scrollHeight; });
294
+ ro.observe(el2);
295
+ if (el2.firstElementChild) ro.observe(el2.firstElementChild);
296
+ return () => {
297
+ el2.removeEventListener('wheel', onWheel);
298
+ el2.removeEventListener('touchmove', onTouch);
299
+ el2.removeEventListener('keydown', onKey);
300
+ ro.disconnect();
301
+ };
302
+ }, [chatTab, view, open, sessionKey, space, node]);
303
+ const onMsgScroll = (e) => {
304
+ const el = e.currentTarget;
305
+ clearTimeout(scrollSaveTimer.current);
306
+ scrollSaveTimer.current = setTimeout(() => cxWriteText(scrollKeyOf(), Math.round(el.scrollTop)), 300);
307
+ };
308
+ useEffect(() => {
309
+ (async () => {
310
+ try {
311
+ let local = null;
312
+ local = cxRead(sessionKey, null);
313
+ if (Array.isArray(local) && local.length) return;
314
+ const st = await api('stateGet');
315
+ const remote = st && st['chat:' + sessionKey];
316
+ if (Array.isArray(remote) && remote.length) {
317
+ setMsgs(remote);
318
+ cxWrite(sessionKey, remote);
319
+ }
320
+ } catch {}
321
+ })();
322
+ }, [sessionKey]);
323
+ const chatReady = !!avatar?.project && !!pm?.project;
324
+ const chatWebTab = chatTab !== 'chat' ? (webTabs || []).find((t) => t.id === chatTab) : null;
325
+ const [addr, setAddr] = useState('');
326
+ const [navHist, setNavHist] = useState({});
327
+ const histKey = chatWebTab ? chatWebTab.id : '';
328
+ const hist = navHist[histKey] || null;
329
+ useEffect(() => { setAddr(chatWebTab?.base || chatWebTab?.url || ''); }, [chatWebTab?.id, chatWebTab?.base]);
330
+ useEffect(() => { if (chatWebTab) setNavHist((h) => (h[chatWebTab.id] ? h : { ...h, [chatWebTab.id]: { list: [chatWebTab.base || chatWebTab.url], idx: 0 } })); }, [chatWebTab?.id]);
331
+ const pushHist = (url) => setNavHist((h) => {
332
+ const cur = h[histKey] || { list: [], idx: -1 };
333
+ const list = cur.list.slice(0, cur.idx + 1);
334
+ if (list[list.length - 1] !== url) list.push(url);
335
+ return { ...h, [histKey]: { list, idx: list.length - 1 } };
336
+ });
337
+ const go = (raw) => {
338
+ const t = String(raw || '').trim();
339
+ if (!t || !chatWebTab) return;
340
+ const full = /^https?:\/\//i.test(t) ? t : `http://${t}`;
341
+ pushHist(full);
342
+ onNavWebTab && onNavWebTab(chatWebTab.id, full);
343
+ };
344
+ const back = () => { const cur = navHist[histKey]; if (!cur || cur.idx <= 0 || !chatWebTab) return; const url = cur.list[cur.idx - 1]; setNavHist((h) => ({ ...h, [histKey]: { ...cur, idx: cur.idx - 1 } })); onNavWebTab && onNavWebTab(chatWebTab.id, url); };
345
+ const fwd = () => { const cur = navHist[histKey]; if (!cur || cur.idx >= cur.list.length - 1 || !chatWebTab) return; const url = cur.list[cur.idx + 1]; setNavHist((h) => ({ ...h, [histKey]: { ...cur, idx: cur.idx + 1 } })); onNavWebTab && onNavWebTab(chatWebTab.id, url); };
346
+ const [pmBooted, setPmBooted] = useState(false);
347
+ useEffect(() => { if (pm) setPmBooted(true); }, [pm]);
348
+ useEffect(() => { const t = setTimeout(() => setPmBooted(true), 1500); return () => clearTimeout(t); }, []);
349
+ const planReady = !!activePlan;
350
+ useEffect(() => {
351
+ if (!open) return;
352
+ const prev = sessionStateRef.current;
353
+ if (prev.key && prev.ready) {
354
+ cxWrite(prev.key, prev.msgs);
355
+ }
356
+ let saved = null;
357
+ saved = cxRead(sessionKey, null);
358
+ const next = Array.isArray(saved) && saved.length ? saved : [{ role: 'ai', text: modeIntro(CHAT_MODES.find((x) => x.id === mode)) || modeIntro(CHAT_MODES[0]) }];
359
+ sessionStateRef.current = { key: sessionKey, ready: false, msgs: next };
360
+ setMsgs(next);
361
+ }, [sessionKey, open]);
362
+ useEffect(() => {
363
+ if (!open) return;
364
+ const cur = sessionStateRef.current;
365
+ if (cur.key !== sessionKey) return;
366
+ if (!cur.ready) { cur.ready = true; cur.msgs = msgs; return; }
367
+ cur.msgs = msgs;
368
+ cxWrite(sessionKey, msgs);
369
+ try { api('stateSet', { name: 'chat:' + sessionKey, value: msgs.slice(-100).map((m) => ({ role: m.role, text: String(m.text || '').slice(0, 4000), t: m.t || '' })) }).catch(() => {}); } catch {}
370
+ }, [msgs, sessionKey, open]);
371
+
372
+ const sanitizeTrace = (v, depth = 0) => {
373
+ if (v == null || typeof v === 'number' || typeof v === 'boolean') return v;
374
+ if (typeof v === 'string') return v.length > 4000 ? v.slice(0, 4000) + '…' : v;
375
+ if (depth > 4) return '[object]';
376
+ if (Array.isArray(v)) return v.slice(0, 50).map((x) => sanitizeTrace(x, depth + 1));
377
+ if (typeof v === 'object') { const o = {}; for (const k of Object.keys(v).slice(0, 40)) o[k] = sanitizeTrace(v[k], depth + 1); return o; }
378
+ return String(v);
379
+ };
380
+ const push = (role, text, meta) => {
381
+ const id = `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`;
382
+ setMsgs((xs) => [...xs, { id, role, text, ...(meta || {}), t: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }]);
383
+ return id;
384
+ };
385
+ // 表单提交成功后把只读状态写回消息(刷新后仍只剩「查看详情」)
386
+ const markFormDone = (id, link, values) => setMsgs((xs) => xs.map((m) => (m.id === id ? { ...m, done: true, link, values } : m)));
387
+ const ai = (text, meta) => push('ai', text, meta);
388
+ const aiCard = (text, card, meta) => push('ai', text, { ...(meta || {}), card });
389
+
390
+ // —— 基础动作 ——
391
+ const run = async (label, fn) => {
392
+ setBusy(true); push('sys', label);
393
+ try { return await fn(); }
394
+ catch (e) { ai(`失败:${String(e?.message || e)}`); push('tool', ''); return undefined; }
395
+ finally { setBusy(false); }
396
+ };
397
+ // 常用调试工具条:编程 / 切节点 / 切空间 / 开发模式
398
+ const ToolRow = ({ ctx }) => h('div', { className: 'cx-toolrow' },
399
+ ctx && ctx.space ? h('button', {
400
+ className: 'cx-btn sm',
401
+ onClick: () => cards?.openTerminal && cards.openTerminal({ node: ctx.node || node, space: ctx.space }),
402
+ }, locale === 'zh' ? '终端' : 'Terminal') : null,
403
+ ctx && ctx.space ? h('button', {
404
+ className: 'cx-btn sm',
405
+ onClick: () => cards?.rebuild && cards.rebuild({ node: ctx.node || node, space: ctx.space }),
406
+ }, locale === 'zh' ? '重新编译' : 'Rebuild') : null,
407
+ h('button', { className: 'cx-btn sm', onClick: () => cards?.openIde && cards.openIde(node) }, locale === 'zh' ? '编程' : 'IDE'),
408
+ nodes && nodes.length
409
+ ? h('label', { className: 'cx-toolsel' }, locale === 'zh' ? '节点' : 'Node',
410
+ h('select', { value: String(node || ''), onChange: (e) => cards?.selectNode(Number(e.target.value)) },
411
+ nodes.map((n) => h('option', { key: n.port, value: String(n.port) }, `:${n.port}`))))
412
+ : null,
413
+ spaces && spaces.length
414
+ ? h('label', { className: 'cx-toolsel' }, locale === 'zh' ? '空间' : 'Space',
415
+ h('select', { value: String(space || ''), onChange: (e) => cards?.selectSpace(e.target.value) },
416
+ spaces.map((sp) => h('option', { key: sp.name, value: sp.name }, sp.name))))
417
+ : null,
418
+ h('button', { className: 'cx-btn sm', onClick: () => onOpenDev && onOpenDev() }, locale === 'zh' ? '开发模式' : 'Dev'));
419
+ const switchMode = (id) => { if (id && id !== mode) onModeChange && onModeChange(id); };
420
+ const actorId = () => cxActorUid(avatar);
421
+ const appHomeUrl = (a, uid) => (!node || !a || !a.space || !a.command) ? '' : cxWebUrl({
422
+ node, pod: a.space, command: a.command, place: a.place || 'system',
423
+ placeUid: uid || a.place_uid || '', uid: actorId(),
424
+ });
425
+ const pmPortalUrl = (kind, uid) => {
426
+ if (!node || !space) return '';
427
+ const q = ['debug=true'];
428
+ if (uid) q.push(`system_uid=${encodeURIComponent(uid)}`);
429
+ return `http://127.0.0.1:${node}/s/${encodeURIComponent(space)}/c/web.team.xiangmuguanli.portal?${q.join('&')}`;
430
+ };
431
+ const deploy = async (port) => {
432
+ const r = await run(`DEPLOY :${port}`, async () => {
433
+ await api('deploy', { port: Number(port), mode: 'copy' });
434
+ await onRefresh();
435
+ onSelectNode(Number(port));
436
+ return true;
437
+ });
438
+ if (r) { ai(`节点 :${port} 已部署。\n下一步:创建项目空间,例如「创建空间 demo」。`); setStep(null); }
439
+ };
440
+
441
+ // —— 应用商店 → 创建实例 ——
442
+ // 创建应用实例:先出确认表单(实例名/城市/公司),确认后再真正创建
443
+ const askInstance = (hit, nm) => {
444
+ const av = avatar || {};
445
+ const nz = (zh, en) => (locale === 'zh' ? zh : en);
446
+ const svc = hit || {};
447
+ const title = svc.title || svc.name || nz('应用', 'App');
448
+ askPush(nz(`创建应用实例 · ${title}`, `Create instance · ${title}`), [
449
+ { name: 'name', label: nz('实例名称', 'Instance name'), value: nm || title, required: true },
450
+ { name: 'city', label: nz('城市', 'City'), value: av.city_name || '深圳市' },
451
+ { name: 'company', label: nz('公司', 'Company'), value: av.company_name || '云轩领航' },
452
+ ], 'instance', { service: svc });
453
+ };
454
+
455
+ const createAppFromStore = async (source) => {
456
+ if (!node) { ai('还没有选中节点。'); return undefined; }
457
+ const direct = source && typeof source === 'object' && source.command ? source : null;
458
+ const kw = direct ? '' : String(source || '').trim();
459
+ return run(`STORE INSTANCE ${direct ? direct.name : (kw || '-')}`, async () => {
460
+ const list = direct ? [direct] : ((await api('storeApps', { node })) || []);
461
+ const norm = (s) => String(s || '').toLowerCase();
462
+ const hit = direct || list.find((s) => kw && (norm(s.title).includes(norm(kw)) || norm(s.help).includes(norm(kw)) || norm(s.command).includes(norm(kw))))
463
+ || list.find((s) => /审批/.test(kw) && /审批|shenpi/.test(String(s.title) + String(s.command) + String(s.help)))
464
+ || list.find((s) => /项目/.test(kw) && /项目|xiangmu/.test(String(s.title) + String(s.command) + String(s.help)))
465
+ || list[0];
466
+ if (!hit) { ai('应用商店里没有可用模板。'); return undefined; }
467
+ const nm = direct ? (direct.title || direct.name) : (kw && !/创建|生成|做一个|应用/.test(kw) ? kw : (hit.title || hit.name));
468
+ askInstance(hit, nm);
469
+ return hit;
470
+ });
471
+ };
472
+
473
+ // —— 往实例里写数据:先出表单 ——
474
+ // 最长公共中文片段(2..6 字),用于把用户原话和「最近应用」标题对上
475
+ const commonRun = (a, b) => {
476
+ const s = String(a || ''), t = String(b || '');
477
+ let best = 0;
478
+ for (let i = 0; i < s.length; i++) {
479
+ for (let n = 2; n <= 6 && i + n <= s.length; n++) {
480
+ const seg = s.slice(i, i + n);
481
+ if (t.includes(seg)) best = Math.max(best, n);
482
+ }
483
+ }
484
+ return best;
485
+ };
486
+ // 只在「最近应用」里找实例:不看当前选中的空间/应用
487
+ const matchRecentApp = (text, pool) => {
488
+ const list = Array.isArray(pool) ? pool.filter((x) => x && x.command) : [];
489
+ if (!list.length) return null;
490
+ let best = null, bestScore = 0;
491
+ list.forEach((x) => {
492
+ const sc = Math.max(commonRun(text, x.title), commonRun(text, x.name), commonRun(text, x.space));
493
+ if (sc > bestScore) { bestScore = sc; best = x; }
494
+ });
495
+ return bestScore >= 2 ? best : list[0];
496
+ };
497
+
498
+ // 应用=目录、功能=文件;读写都走同一条定位链:① 应用 ② 功能 ③ 读/写
499
+ const appOp = async ({ text: rawText, mode = 'write', function: fnArg = '' } = {}) => {
500
+ const text = String(rawText || lastUserRef.current || '');
501
+ if (!text.trim()) return false;
502
+ let list = Array.isArray(apps) ? apps.slice() : [];
503
+ if (!list.length) {
504
+ try { const r = await api('apps', { node, space, uid: actorId() }); if (Array.isArray(r)) list = r; } catch {}
505
+ }
506
+ if (!list.length) { ai('最近应用里还没有应用实例,请先在应用商店创建一个。'); return true; }
507
+
508
+ push('step', `① 定位应用(目录):在 ${list.length} 个最近应用里匹配`);
509
+ let plan = null;
510
+ try {
511
+ plan = await api('resolveWrite', {
512
+ node, text, mode,
513
+ apps: list.map((x) => ({ title: x.title, name: x.name, space: x.space, command: x.command, functions: x.functions })),
514
+ });
515
+ } catch (e) { plan = null; }
516
+ const byPlan = plan && plan.command ? list.find((x) => String(x.command) === String(plan.command)) : null;
517
+ const app0 = byPlan || matchRecentApp(text, list);
518
+ if (!app0) { ai('最近应用里没有匹配的应用,请说清楚是哪个应用。'); return true; }
519
+
520
+ const fns = String(app0.functions || '').split(',').map((x) => x.trim()).filter(Boolean);
521
+ let fn = String((plan && plan.fn) || fnArg || '').trim();
522
+ if (!fn) fn = (/创建|新建|添加|建立|定义/.test(text) && /流程/.test(text)) ? 'flow' : (/发起|提交|申请|送审|报批/.test(text) ? 'request' : '');
523
+ if (fns.length && fn && !fns.includes(fn)) fn = fns.includes('supplier') && /供应商/.test(text) ? 'supplier' : fns[0];
524
+ if (!fn) fn = fns[0] || '';
525
+ if (!fn) { ai('这个应用还没有可用的功能,请先到应用商店重新生成模板。'); return true; }
526
+ push('step', `② 定位功能(文件):${app0.title || app0.name} / ${fn}`);
527
+
528
+ const command = String(app0.command || '').replace(/\.portal$/, '') + '.' + fn;
529
+
530
+ if (mode === 'read') {
531
+ push('step', `③ 读取数据:${command}`);
532
+ let r = null;
533
+ try { r = await api('readData', { node, space: app0.space, command }); } catch (e) { r = null; }
534
+ if (!r || !Array.isArray(r.rows)) { ai(`读取失败:${command}`); return true; }
535
+ const cols = (r.columns || []).slice(0, 5).map((c) => ({ key: c, label: FIELD_ZH[c] || c }));
536
+ if (r.shape === 'detail' && r.fields) {
537
+ // 详情:一条记录,键值对展示
538
+ push('table', '', { table: { title: `${app0.title || app0.name} / ${fn}`, count: 1, detail: Object.entries(r.fields).slice(0, 12).map(([k, v]) => ({ key: k, label: FIELD_ZH[k] || k, value: String(v ?? '') })) } });
539
+ } else {
540
+ push('table', '', { table: { title: `${app0.title || app0.name} / ${fn}`, count: r.rows.length, columns: cols, rows: r.rows.slice(0, 20) } });
541
+ }
542
+ return true;
543
+ }
544
+
545
+ push('step', `③ 查询接口参数:${command}`);
546
+ let fields = [];
547
+ try { fields = await api('appFields', { node, space: app0.space, command }); } catch (e) { fields = []; }
548
+ const fromMeta = Array.isArray(fields) && fields.length > 0;
549
+ if (!fromMeta) {
550
+ fields = [
551
+ { name: 'title', label: '标题', labelEn: 'Title', type: 'text', required: true },
552
+ { name: 'content', label: '内容', labelEn: 'Content', type: 'textarea' },
553
+ ];
554
+ }
555
+ push('step', `④ 生成表单:${fields.length} 个字段${fromMeta ? '' : '(接口未返回字段,用通用字段)'},确认后写入`);
556
+
557
+ const fk = `${node}|${app0.space}|${app0.place_uid}|${fn}`;
558
+ const saved = (typeof readFormState === 'function' ? readFormState(fk) : null) || {};
559
+ const planned = plan && plan.values && Object.keys(plan.values).length ? { ...plan.values } : null;
560
+ const values = saved.values || planned;
561
+ // 表单自带上下文:节点 / 空间 / 命令 / 实例 / 身份,随时能拼出完整接口 URL
562
+ const ctx = {
563
+ node, space: app0.space, // 空间只用名称(pod),绝不用 uid
564
+ command, portal: String(command).replace(/\.[^.]+$/, '.portal'),
565
+ place: app0.place || 'system', // 数据挂在哪个表上 → <place>_uid
566
+ place_uid: app0.place_uid || '', uid: actorId(),
567
+ };
568
+ push('form', '', {
569
+ form: true, app: { ...app0 }, fn, action: 'create', command, ctx,
570
+ fields, fk, home: appHomeUrl(app0, app0.place_uid),
571
+ done: !!saved.done, link: saved.link || '', values,
572
+ });
573
+ return true;
574
+ };
575
+ const writeIntoApp = (args) => appOp({ ...(args || {}), mode: 'write' });
576
+
577
+ // —— 表单提交 ——
578
+ const submitForm = async (m, vals) => {
579
+ const a = m.app || {};
580
+ // 只用表单自带的上下文,不依赖页面下方当前节点/空间的选择
581
+ const ctx = m.ctx || {};
582
+ const fNode = ctx.node || node;
583
+ const fSpace = ctx.space || a.space;
584
+ const cmd = ctx.command || m.command || a.command;
585
+ const sysUid = ctx.place_uid || a.place_uid || '';
586
+ const who = ctx.uid || actorId();
587
+ const place = ctx.place || a.place || 'system';
588
+ // 接口 URL:/s/<pod>/c/<portal>/action/run/<cmd>/action/<action>/<k>/<v>?user.uid=&<place>_uid=&uid=
589
+ const scope = { [`${place}_uid`]: sysUid, 'user.uid': who, 'user_uid': who };
590
+ // user.uid = 当前分身身份;uid 只有「改/删某条记录」时才传
591
+ const r = await api('writeData', { node: fNode, space: fSpace, command: cmd, action: m.action || 'create', fields: vals, scope });
592
+ const uid = (r && (r.uid || r.id)) || '';
593
+ const portal = ctx.portal || String(cmd || '').replace(/\.[^.]+$/, '.portal');
594
+ const url = uid
595
+ ? cxWebUrl({ node: fNode, pod: fSpace, command: cmd, place, placeUid: sysUid, recordUid: uid, uid: who })
596
+ : '';
597
+ ai(locale === 'zh' ? `已提交「${a.title || a.name || '应用'}」` : `Saved to ${a.title || a.name || 'app'}`);
598
+ return { ...(r || {}), url };
599
+ };
600
+
601
+ // 每个应用的默认五功能链(AI 调研失败时的兜底):目标 → 计划 → 任务 → 报告 → 反馈
602
+ const APP_CHAIN = [
603
+ { name: 'target', label: '目标' },
604
+ { name: 'plan', label: '计划' },
605
+ { name: 'task', label: '任务' },
606
+ { name: 'report', label: '报告' },
607
+ { name: 'feedback', label: '反馈' },
608
+ ];
609
+ const CHAIN_VALUE = APP_CHAIN.map((x) => x.name).join(',');
610
+ const CHAIN_TRANS = APP_CHAIN.map((x) => x.label).join(',');
611
+ // 代码生成:选模板 -> 表单确认 -> 落盘 -> 编译
612
+ const codegenAsk = async (text) => {
613
+ const list = await api('codegenList');
614
+ const app0 = app || (apps || [])[0] || {};
615
+ const parts = String(app0.command || '').split('.');
616
+ const zone = parts[1] || 'devops';
617
+ const mod = parts[2] || 'note';
618
+ // 先让宿主解析意图:工具栏 / 行内 + 动作名 + 条件
619
+ let plan = null;
620
+ try {
621
+ plan = await api('codegenPlan', { text, module: mod, fields: [] });
622
+ } catch (e) { plan = null; }
623
+ const tplKey = (plan && plan.template) || (/按钮/.test(text) ? 'buttons' : 'action');
624
+ const tpl = (list || []).find((x) => x.key === tplKey) || (list || [])[0];
625
+ if (!tpl) { ai('没有可用的代码模板。'); return false; }
626
+ const act = (plan && plan.action) || (text.match(/([a-zA-Z][a-zA-Z0-9_]*)/) || [])[1] || 'What';
627
+ askPush(locale === 'zh' ? `生成代码 · ${tpl.title}` : `Code · ${tpl.title}`, [
628
+ { name: 'zone', label: 'zone', value: zone, required: true },
629
+ { name: 'module', label: 'module', value: mod, required: true },
630
+ { name: 'Action', label: 'Action(导出)', value: act.charAt(0).toUpperCase() + act.slice(1), required: true },
631
+ { name: 'action', label: 'action(小写)', value: act.toLowerCase(), required: true },
632
+ { name: 'label', label: '按键文字', value: (plan && plan.label) || '' },
633
+ { name: 'field', label: '条件字段', value: (plan && plan.field) || '' },
634
+ { name: 'op', label: '比较(eq/ne/gt/lt/nonempty/empty)', value: (plan && plan.op) || '' },
635
+ { name: 'value', label: '条件值', value: (plan && plan.value) || '' },
636
+ { name: 'params', label: '参数', value: 'title* content*' },
637
+ ], 'codegen', { template: tpl.key, space: app0.space || space, why: (plan && plan.why) || '' });
638
+ if (plan && plan.why) push('step', plan.why);
639
+ return true;
640
+ };
641
+ const askPush = (title, fields, action, extra) => push('ai', '', { ask: { title, fields, action, ...(extra || {}) } });
642
+ const submitAsk = async (m, vals) => {
643
+ const a = m.ask || {};
644
+ if (!a.action) return;
645
+ markFormDone(m.id);
646
+ if (a.action === 'space') {
647
+ return doCreateSpace(vals.name, vals.repos);
648
+ }
649
+ if (a.action === 'codegen') {
650
+ const sp2 = a.space || spaceRef.current || space;
651
+ return run(`CODEGEN ${a.template}`, async () => {
652
+ const vars = { zone: vals.zone, module: vals.module, Action: vals.Action, action: vals.action, label: vals.label || vals.Action, field: vals.field || 'status', op: vals.op || 'eq', value: vals.value || '', params: vals.params || 'title* content*' };
653
+ const r = await api('codegenApply', { node, space: sp2, template: a.template, vars });
654
+ await onRefresh();
655
+ ai(locale === 'zh'
656
+ ? `已写入 ${r.file}(${r.bytes} 字节),编译${r.built ? '完成' : '未完成'}。`
657
+ : `Wrote ${r.file} (${r.bytes}B), build ${r.built ? 'done' : 'incomplete'}.`);
658
+ return r;
659
+ });
660
+ }
661
+ if (a.action === 'instance') {
662
+ const hit = a.service || {};
663
+ const nm = String(vals.name || '').trim() || hit.title || hit.name || '我的应用';
664
+ return run(`INSTANCE ${nm}`, async () => {
665
+ push('step', locale === 'zh' ? '① 定位模板 ② 等待空间编译就绪 ③ 创建实例' : '① template ② wait for space ③ create');
666
+ const d = await api('createStoreInstance', { node, service: hit, name: nm, city: vals.city || '', company: vals.company || '', uid: actorId() });
667
+ await onRefresh();
668
+ const data = { space: hit.space, command: hit.command, name: hit.name, title: nm, place_uid: d.place_uid || '', user_uid: actorId(), icons: hit.icons, service_name: hit.title };
669
+ ai(locale === 'zh' ? `已创建应用实例「${nm}」(${hit.space})。` : `Instance "${nm}" created in ${hit.space}.`);
670
+ aiCard(locale === 'zh' ? `已从应用商店创建「${nm}」` : `Instance "${nm}" created`, { kind: 'app', entity: { kind: 'app', node, data }, action: { type: 'open', label: locale === 'zh' ? '打开界面' : 'Open', url: appHomeUrl(data, d.place_uid) } });
671
+ return d;
672
+ });
673
+ }
674
+ if (a.action === 'app') {
675
+ return run(`AUTOGEN ${vals.name}`, async () => {
676
+ const nm = appIdent(vals.name || '');
677
+ const sp2 = vals.space || a.space || spaceRef.current || space;
678
+ let finalName = appIdent(nm);
679
+ try {
680
+ const uq = await api('nameCheck', { node, space: sp2, name: finalName });
681
+ if (uq && uq.name) finalName = uq.name;
682
+ } catch (e) { /* 检查失败就按用户填的建,冲突时框架会报错 */ }
683
+ if (finalName !== nm) ai(locale === 'zh' ? `模板名「${nm}」已被占用,改用「${finalName}」。` : `Template "${nm}" is taken, using "${finalName}".`);
684
+ const r = await api('autogenApp', { node, space: sp2, name: finalName, help: vals.help || '', place: vals.place || 'system', value: vals.value || CHAIN_VALUE, trans: vals.trans || CHAIN_TRANS });
685
+ await onRefresh();
686
+ const built = !!r.built;
687
+ ai(built
688
+ ? `应用模板「${vals.help || nm}」已生成并编译完成(${sp2})。`
689
+ : `应用模板「${vals.help || nm}」已生成,编译未完成${r.buildNote ? ':' + r.buildNote : ''}。`);
690
+ const svc = {
691
+ name: r.name,
692
+ title: vals.help || r.name,
693
+ command: `web.team.${r.name}.portal`,
694
+ space: r.space || sp2,
695
+ place: vals.place || 'system',
696
+ help: vals.help || '',
697
+ icons: '',
698
+ uid: '',
699
+ };
700
+ if (!built) {
701
+ // 编译失败:不给模板卡(不可用),给这条错误消息挂上排查工具
702
+ push('tool', '', { tool: { node, space: svc.space, name: svc.name } });
703
+ return r;
704
+ }
705
+ aiCard(locale === 'zh' ? '应用商店模板' : 'Store template', { kind: 'service', entity: { kind: 'service', node, data: svc } });
706
+ // 空间编译/重启期间创建实例会失败,轮询到就绪再提示,界面不阻塞
707
+ let tries = 0;
708
+ const timer = setInterval(async () => {
709
+ tries += 1;
710
+ if (tries > 72) { clearInterval(timer); return; }
711
+ try {
712
+ const st = await api('spaceReady', { node, space: svc.space, command: svc.command });
713
+ if (st && st.ready) {
714
+ clearInterval(timer);
715
+ ai(locale === 'zh' ? `模板已就绪,可以创建实例「${svc.title}」。` : `Template ready: ${svc.title}.`);
716
+ }
717
+ } catch { /* keep polling */ }
718
+ }, 5000);
719
+ return r;
720
+ });
721
+ }
722
+ };
723
+ // 真正创建一个项目空间:建空间 → git init → 刷新 → 切为当前空间 → 卡片
724
+ const doCreateSpace = async (nameArg, reposArg) => {
725
+ const name = String(nameArg || '').trim();
726
+ if (!name) return;
727
+ const repos = String(reposArg || '').trim();
728
+ const reposLabel = repos ? (/^https?:|^git@/.test(repos) ? repos : `https://shylinux.com/x/${repos}`) : 'git init';
729
+ return run(`WORKSPACE ${name}`, async () => {
730
+ const created = await api('createSpace', { node, name, repos });
731
+ await api('initSpace', { node, space: created.name, repos });
732
+ await onRefresh();
733
+ spaceRef.current = created.name;
734
+ onSelectNode(node); onSelectSpace(created.name);
735
+ aiCard('', { entity: {
736
+ kind: 'space', node,
737
+ data: { name: created.name, status: 'start', version: created.version || '', size: created.size || '', help: created.help || '', icons: created.icons || '' },
738
+ title: created.name,
739
+ status: (locale === 'zh' ? '运行中' : 'Running'),
740
+ statusKind: 'ok',
741
+ sub: `:${node} · ${reposLabel}`,
742
+ actions: [
743
+ { label: (locale === 'zh' ? '打开' : 'Open'), url: `http://127.0.0.1:${node}/s/${encodeURIComponent(created.name)}` },
744
+ { label: (locale === 'zh' ? '编程' : 'Code'), url: `http://127.0.0.1:${node}/s/${encodeURIComponent(created.name)}/c/command` },
745
+ ],
746
+ } });
747
+ return created;
748
+ });
749
+ };
750
+
751
+ // 创建应用前:让 AI 依据需求与竞品调研出五个功能及依赖,再进确认表单给用户改。
752
+ // 应用标识:小写字母开头,只留 a-z0-9_,非法时用 app+时间戳兜底
753
+ const appIdent = (name) => {
754
+ const s = String(name || '').toLowerCase().replace(/[^a-z0-9_]/g, '');
755
+ return /^[a-z][a-z0-9_]*$/.test(s) ? s : `app${Date.now().toString(36).slice(-5)}`;
756
+ };
757
+ // 中文名:优先取用户原话里的短语(≤16 字),不要模型写的整段解释
758
+ const shortAppTitle = (text, name) => {
759
+ const t = String(text || '');
760
+ const m = t.match(/(?:创建|新建|搭建|建一个|做一个|生成|来一个|帮我做)(?:一个|个)?\s*([^\s,。,.!?!?的]{2,16})(?:的)?应用/);
761
+ if (m && m[1]) return `${m[1]}应用`;
762
+ const s = String(name || '').trim();
763
+ return s ? `${s} 应用` : '应用';
764
+ };
765
+ const askAppPlan = (sp, name, help, place, text) => run(`PLAN ${name}`, async () => {
766
+ let nm = appIdent(name);
767
+ if (nm !== name) ai(`应用标识「${name}」不合规,改用「${nm}」。`);
768
+ // 模板名必须和云社区 service 列表、目标空间已有模块区分开
769
+ try {
770
+ const uq = await api('nameCheck', { node, space: sp, name: nm });
771
+ if (uq && uq.name && uq.name !== nm) {
772
+ ai(locale === 'zh' ? `模板名「${nm}」已被占用,改用「${uq.name}」。` : `Template "${nm}" is taken, using "${uq.name}".`);
773
+ nm = uq.name;
774
+ }
775
+ } catch (e) { /* 名字检查失败时继续,提交前还有一次 */ }
776
+ const ttl = (help && String(help).trim().length <= 20 && !/[,。;、]/.test(String(help))) ? String(help).trim() : shortAppTitle(text, nm);
777
+ let plan = null;
778
+ try { plan = await api('appPlan', { node, space: sp, name: nm, help: ttl, text: String(text || help || nm) }); } catch (e) { plan = null; }
779
+ const fns = (plan && Array.isArray(plan.functions) && plan.functions.length === 5)
780
+ ? plan.functions
781
+ : APP_CHAIN.map((x, i) => ({ name: x.name, label: x.label, depends: i ? [APP_CHAIN[i - 1].name] : [] }));
782
+ const val = fns.map((f) => f.name).join(',');
783
+ const tr = fns.map((f) => String(f.label || f.name).replace(/[,,、;;]/g, '')).join('、');
784
+ const chain = fns.map((f) => `${f.label}(${f.name})`).join(' → ');
785
+ // 参数表完全来自 service autogen 的自省(meta.autogen),客户端只填值。
786
+ let spec = null;
787
+ try { spec = await api('autogenSpec', { node }); } catch (e) { spec = null; }
788
+ if (!spec || !Array.isArray(spec.fields) || !spec.fields.length) {
789
+ ai(`读取 service autogen 参数失败:${String((spec && spec.error) || 'no fields')}`);
790
+ return;
791
+ }
792
+ // place = 应用在数据库里的表名,默认就是 system;模型给的模块名一律不用
793
+ const placeVal = (place && /^[a-z][a-z0-9_]*$/.test(String(place)) && String(place) !== nm) ? String(place) : 'system';
794
+ const VALUES = { space: sp, name: nm, help: ttl, place: placeVal, value: val, trans: tr };
795
+ const specFields = spec.fields.map((f) => ({
796
+ name: f.name,
797
+ label: f.label || f.name,
798
+ required: !!f.required,
799
+ value: VALUES[f.name] == null ? '' : VALUES[f.name],
800
+ }));
801
+ askPush(locale === 'zh' ? '创建应用模板' : 'Create app template', specFields, 'app', { node, note: (locale === 'zh' ? 'AI 调研的五个功能:' : 'AI-researched functions: ') + chain });
802
+ });
803
+ const generate = async (spec) => {
804
+ let sp = spaceRef.current || space;
805
+ if (!sp) { ai('还没有选中项目空间。先说「创建空间 demo」,或点中间空间。'); return; }
806
+ const raw = typeof spec === 'string' ? { name: spec } : (spec || {});
807
+ const employee = /员工|employee|yuangong/i.test(String(raw.name || '')) || /员工/.test(String(raw.help || ''));
808
+ const base = employee
809
+ ? { name: 'yuangong', help: '员工管理', place: 'employee', value: 'profile,position,skill,experience,education,record', trans: '员工信息,工作岗位,专业技能,工作经验,教育经历,事件记录' }
810
+ : { name: 'app', help: '应用', place: 'system', value: 'profile', trans: '简介' };
811
+ const preset = { ...base, ...raw };
812
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*(\s*[,,、]\s*[a-zA-Z][a-zA-Z0-9_]*)*$/.test(String(preset.value || ''))) {
813
+ preset.value = base.value;
814
+ preset.trans = base.trans;
815
+ }
816
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(preset.name || '')) { ai('应用名需要字母开头,只用字母/数字/下划线。'); return; }
817
+ return askAppPlan(sp, preset.name, preset.help, preset.place, lastUserRef.current);
818
+ };
819
+ const executeAction = async (action) => {
820
+ if (!action || typeof action !== 'object') return;
821
+ const op = String(action.op || '');
822
+ const args = action.args && typeof action.args === 'object' ? action.args : {};
823
+ const ctxApp = app || null;
824
+ const ctxSpace = spaceRef.current || space || ctxApp?.space || '';
825
+ if (op === 'sshHosts') {
826
+ return run('SSH HOSTS', async () => {
827
+ const r = await api('sshHosts');
828
+ const hs = (r && r.knownHosts) || [];
829
+ const sv = (r && r.services) || [];
830
+ ai(`找到 ${sv.length} 个 SSH 服务${sv.length ? '(' + sv.slice(0, 6).map((x) => x.name).join('、') + '…)' : ''},${hs.length} 台连接过的设备`);
831
+ return r;
832
+ });
833
+ }
834
+ if (op === 'sysControl') {
835
+ const act = String(args.action || '').toLowerCase();
836
+ const val = args.value == null ? '' : args.value;
837
+ return run(`SYS ${act}`, async () => {
838
+ const r = await api('sysControl', { action: act, value: val });
839
+ ai(`已执行 ${act}:${String(r?.result == null ? '' : r.result).slice(0, 300)}`);
840
+ return r;
841
+ });
842
+ }
843
+ if (op === 'deploy') return deploy(Number(args.port) || 9188);
844
+ if (op === 'officialSpaces') {
845
+ const keys = (Array.isArray(args.keys) ? args.keys : [args.name]).map((k) => OFFICIAL_KEY[k] || k).filter(Boolean);
846
+ if (!keys.length) { ai('要部署哪个标准空间?云社区/云办公/云研发/云知识/…'); return; }
847
+ return run(`DEPLOY OFFICIAL ${keys.join(',')}`, async () => { await api('officialSpaces', { node, keys }); await onRefresh(); });
848
+ }
849
+ if (op === 'createSpace' && OFFICIAL_KEY[args.name]) {
850
+ return run(`DEPLOY OFFICIAL ${args.name}`, async () => { await api('officialSpaces', { node, keys: [OFFICIAL_KEY[args.name]] }); await onRefresh(); });
851
+ }
852
+ if (op === 'createSpace') {
853
+ const target = String(args.name || 'project').trim();
854
+ if (!node) { ai('还没有选中节点。先说「部署 9188 节点」,或点左侧节点。'); return; }
855
+ const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
856
+ const slug = (target || 'project').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project';
857
+ const guess = /^\d{8}-/.test(target) ? target : `${today}-${slug}`;
858
+ const repoName = guess.replace(/^\d{8}-?/, '') || slug;
859
+ const repos = String(args.repos || '').trim() || `https://shylinux.com/x/${repoName}`;
860
+ // 先出确认表单(名称/仓库可改),确认后走 doCreateSpace;仓库默认指到云社区同名库
861
+ askPush(locale === 'zh' ? '创建项目空间 · 确认参数' : 'Create space · confirm', [
862
+ { name: 'name', label: locale === 'zh' ? '空间名称' : 'Space name', value: guess, required: true },
863
+ { name: 'repos', label: locale === 'zh' ? '代码库(仓库名或完整地址)' : 'Repos (name or full URL)', value: repos, hint: locale === 'zh' ? '默认 https://shylinux.com/x/<仓库名>;清空则创建空空间(git init)' : 'Default https://shylinux.com/x/<repo>; clear it to create an empty space (git init)' },
864
+ ], 'space', { node });
865
+ return;
866
+ }
867
+ if (op === 'initSpace') {
868
+ const target = String(args.space || space || '');
869
+ if (!target) { ai('当前没有项目空间。'); return; }
870
+ return run(`INIT ${target}`, async () => { await api('initSpace', { node, space: target, repos: String(args.repos || '') }); await onRefresh(); });
871
+ }
872
+ if (op === 'autogenApp') return generate(args);
873
+ if (op === 'createApp' || op === 'generateAppCode') {
874
+ const appName = String(args.name || '').trim();
875
+ // Digital-avatar / cyber-world mode never writes code.「创建 X 应用」means:
876
+ // find the matching template in the app store, then create an instance.
877
+ if (mode && mode !== 'factory') {
878
+ const kw = String(appName || args.title || '').trim();
879
+ return createAppFromStore(kw);
880
+ }
881
+ const sp = spaceRef.current || space;
882
+ if (!sp) { ai('还没有选中项目空间。先创建或选择空间。'); return; }
883
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(appName)) { ai('应用名需要字母开头,只用字母/数字/下划线。'); return; }
884
+ return askAppPlan(sp, appName, args.help || args.title || appName, args.place, lastUserRef.current);
885
+
886
+ }
887
+ if (op === 'createInstance') {
888
+ const target = (apps || []).find((a) => a.name === args.name) || app;
889
+ if (!target) { ai('没找到要建实例的应用,先生成/选择一个应用。'); return; }
890
+ const targetSpace = target.space || space || '';
891
+ return run(`INSTANCE ${target.name}`, async () => {
892
+ await api('createInstance', { node, space: targetSpace, command: target.command, place: target.place || args.place || '', name: args.name || target.title || '我的应用', city: args.city || avatar?.city || '深圳市', company: args.company || avatar?.company || '云轩领航', uid: cxActorUid(avatar) });
893
+ await onRefresh();
894
+ });
895
+ }
896
+ if (op === 'buildSpace') {
897
+ const target = String(args.space || space || '');
898
+ if (!target) { ai('当前没有项目空间。'); return; }
899
+ return run(`BUILD ${target}`, async () => { await api('buildSpace', { node, space: target }); await onRefresh(); });
900
+ }
901
+ if (op === 'openApp') {
902
+ const target = (apps || []).find((a) => a.name === args.name) || app;
903
+ if (target) onOpenApp(target); else ai('没有找到这个应用,先在应用列表里选一个。');
904
+ return;
905
+ }
906
+ if (op === 'openNode') {
907
+ window.open(`http://127.0.0.1:${Number(args.port) || node}/`, '_blank', 'noopener');
908
+ return;
909
+ }
910
+ if (op === 'listApps') {
911
+ const list = (apps || []).slice(0, 8).map((a) => `· ${a.title || a.name} [${a.name}]`).join('\n');
912
+ ai(list ? `最近应用:\n${list}` : '当前空间还没有应用。');
913
+ return;
914
+ }
915
+ if (op === 'code') {
916
+ const target = (apps || []).find((a) => a.name === args.name) || app;
917
+ if (target) setModal('code'); else ai('先在应用列表里选一个应用。');
918
+ return;
919
+ }
920
+ if (op === 'pmCreate') {
921
+ return run(`PM CREATE ${args.kind || ''}`, async () => {
922
+ const d = await api('pmCreate', { node, uid: avatar?.uid || '', kind: args.kind || '', title: args.title || '', content: args.content || '', target_uid: args.target_uid || activeTarget?.uid || '', plan_uid: args.plan_uid || activePlan?.uid || '', task_uid: args.task_uid || activeTask?.uid || '', report_uid: args.report_uid || activeReport?.uid || '', begin_time: args.begin_time || '', end_time: args.end_time || '', report_type: args.report_type || 'daily', score: args.score || '5', feedback_status: args.feedback_status || 'canFinish' });
923
+ await loadPm();
924
+ const kindLabel = PM_KIND_LABEL[args.kind] || PM_SHORT[args.kind] || args.kind || '项目数据';
925
+ const rows = [['类型', kindLabel], ['标题', args.title || '—'], ['UID', d.uid ? String(d.uid).slice(0, 12) + '…' : '—']];
926
+ if (args.begin_time) rows.push(['开始', args.begin_time]);
927
+ if (args.end_time) rows.push(['结束', args.end_time]);
928
+ aiCard(`已添加${kindLabel}`, { title: `${kindLabel}创建成功`, rows, link: pmPortalUrl(args.kind, d.uid) ? { label: '打开项目管理', url: pmPortalUrl(args.kind, d.uid) } : null });
929
+ });
930
+ }
931
+ if (op === 'pmList' || op === 'pmTree') {
932
+ return run('PM TREE', async () => { await loadPm(); const rows = [['目标', String((pm?.targets || []).length)], ['计划', String((pm?.plans || []).length)], ['任务', String((pm?.tasks || []).length)], ['报告', String((pm?.reports || []).length)], ['反馈', String((pm?.feedbacks || []).length)]]; aiCard('项目数据已刷新', { title: '项目管理概览', rows, link: pmPortalUrl(args.kind, d.uid) ? { label: '打开项目管理', url: pmPortalUrl(args.kind, d.uid) } : null }); });
933
+ }
934
+ if (op === 'pmModify' || op === 'pmUpdate') {
935
+ return run(`PM MODIFY ${args.kind || ''}`, async () => {
936
+ const itemUid = args.item_uid || (args.kind === 'target' ? activeTarget?.uid : args.kind === 'plan' ? activePlan?.uid : args.kind === 'task' ? activeTask?.uid : args.kind === 'report' ? activeReport?.uid : activeFeedback?.uid) || '';
937
+ await api('pmModify', { node, uid: avatar?.uid || '', kind: args.kind || '', item_uid: itemUid, title: args.title || '', content: args.content || '', begin_time: args.begin_time || '', end_time: args.end_time || '', report_type: args.report_type || '', score: args.score || '', feedback_status: args.feedback_status || '' });
938
+ await loadPm();
939
+ aiCard(`已更新${PM_KIND_LABEL[args.kind] || '项目数据'}`, { title: '更新成功', rows: [['类型', PM_KIND_LABEL[args.kind] || args.kind || '项目数据'], ['标题', args.title || '—']], link: pmPortalUrl(args.kind, d.uid) ? { label: '打开项目管理', url: pmPortalUrl(args.kind, d.uid) } : null });
940
+ });
941
+ }
942
+ if (op === 'pmRemove' || op === 'pmDelete') {
943
+ return run(`PM REMOVE ${args.kind || ''}`, async () => {
944
+ const itemUid = args.item_uid || (args.kind === 'target' ? activeTarget?.uid : args.kind === 'plan' ? activePlan?.uid : args.kind === 'task' ? activeTask?.uid : args.kind === 'report' ? activeReport?.uid : activeFeedback?.uid) || '';
945
+ await api('pmRemove', { node, uid: avatar?.uid || '', kind: args.kind || '', item_uid: itemUid });
946
+ await loadPm();
947
+ aiCard(`已删除${PM_KIND_LABEL[args.kind] || '项目数据'}`, { title: '删除成功', rows: [['类型', PM_KIND_LABEL[args.kind] || args.kind || '项目数据']], link: pmPortalUrl(args.kind, d.uid) ? { label: '打开项目管理', url: pmPortalUrl(args.kind, d.uid) } : null });
948
+ });
949
+ }
950
+ if (op === 'projectStatus') {
951
+ return run('PROJECT STATUS', async () => { const d = await api('projectStatus', { node, uid: avatar?.uid || '' }); const rows = [['目标', String(d.counts?.target || 0)], ['计划', String(d.counts?.plan || 0)], ['任务', String(d.counts?.task || 0)], ['报告', String(d.counts?.report || 0)], ['反馈', String(d.counts?.feedback || 0)]]; aiCard(`项目:${d.project?.name || '未创建'}`, { title: '项目概览', rows, link: pmPortalUrl(args.kind, d.uid) ? { label: '打开项目管理', url: pmPortalUrl(args.kind, d.uid) } : null }); });
952
+ }
953
+ if (op === 'planProject') {
954
+ return run('PLAN PROJECT', async () => {
955
+ const d = await api('planProject', { node, uid: avatar?.uid || '', goal: args.goal || '' });
956
+ await onRefresh();
957
+ ai(`已拆解目标:计划 ${d.plans} · 任务 ${d.tasks} · 报告 ${d.reports} · 反馈 ${d.feedbacks}`);
958
+ });
959
+ }
960
+ if (op === 'createAvatar') {
961
+ return run('CREATE AVATAR', async () => {
962
+ const a = await api('createAvatar', { node, name: args.name || '我的分身', city: args.city || avatar?.city || '深圳市', company: args.company || avatar?.company || '云轩领航', persona: args.persona || avatar?.persona || '', preferences: args.preferences || avatar?.preferences || '', style: args.style || avatar?.style || '', bio: args.bio || '' });
963
+ await onRefresh();
964
+ ai(`分身「${a.name}」已创建,uid=${String(a.uid).slice(0, 10)}…`);
965
+ });
966
+ }
967
+ if (op === 'devices') {
968
+ return run('DISCOVER DEVICES', async () => { const d = await api('devices', { node }); ai(`发现 ${d.devices?.length || 0} 个周边设备(${(d.adapters || []).filter((a) => a.supported).length} 个适配器可用)。`); onDevices?.(); });
969
+ }
970
+ if (op === 'storeApps') {
971
+ if (onStore) onStore();
972
+ return run('APP STORE', async () => { const list = await api('storeApps', { node }); ai(`应用商店:${list.length} 个服务。`); });
973
+ }
974
+ if (op === 'createStoreInstance') return createAppFromStore(args.command || args.name || '');
975
+ if (op === 'writeData' || op === 'data') {
976
+ if (mode !== 'avatar') { ai(locale === 'zh' ? '读写应用数据请在数字分身模式下进行。' : 'App data read/write runs in Digital Avatar mode.'); return; }
977
+ return appOp({ ...args, mode: args.mode === 'read' ? 'read' : 'write', text: args.text || args.prompt || lastUserRef.current || '', function: args.command || args.function || '' });
978
+ }
979
+ ai(`AI 给出了未识别的动作:${op || 'null'}`);
980
+ };
981
+ const handle = async (raw) => {
982
+ const text = String(raw || '').trim();
983
+ if (!text || busy) return;
984
+ lastUserRef.current = text;
985
+ if (!chatReady) { ai('请先完成初始化:服务节点、云社区/云办公、数字分身和项目管理。'); return; }
986
+ const attached = files.map((f) => f.name).join(', ');
987
+ const content = attached ? `${text}\n附件:${attached}` : text;
988
+ push('user', content); clearTimeout(draftTimerRef.current); if (inputRef.current) inputRef.current.value = ''; cxRemove('draft'); setFiles([]); try { inputRef.current?.focus({ preventScroll: true }); } catch {}
989
+ // 读写都走同一条定位链(应用=目录、功能=文件),成功就不再调模型
990
+ const WRITE_RE = /(添加|录入|新增|写入|加一条|新建一条|创建|发起|提交|报批)/;
991
+ const READ_RE = /(查看|查询|看看|列出|列一下|有哪些|列表|显示|统计|多少条)/;
992
+ const DATA_RE = /(数据|记录|列表|明细|清单|内容|情况)/;
993
+ const CODEGEN_RE = /(加|添加|新增|生成|写)[^,。]{0,8}(动作|功能|接口|按钮|代码)/;
994
+ if (mode === 'factory' && CODEGEN_RE.test(text)) {
995
+ setBusy(true);
996
+ try {
997
+ const ok = await codegenAsk(text);
998
+ setBusy(false);
999
+ if (ok) return;
1000
+ } catch (e) {
1001
+ ai(`生成失败:${String(e?.message || e)}`);
1002
+ setBusy(false);
1003
+ return;
1004
+ }
1005
+ }
1006
+ if (mode === 'avatar' && WRITE_RE.test(text) && !/^(怎么|如何|为什么)/.test(text)) {
1007
+ setBusy(true);
1008
+ try {
1009
+ const ok = await appOp({ text, mode: 'write' });
1010
+ if (ok) { setBusy(false); return; }
1011
+ } catch (e) {
1012
+ ai(`执行失败:${String(e?.message || e)}`);
1013
+ setBusy(false);
1014
+ return;
1015
+ }
1016
+ setBusy(false);
1017
+ } else if (mode === 'avatar' && READ_RE.test(text) && DATA_RE.test(text)) {
1018
+ setBusy(true);
1019
+ try {
1020
+ const ok = await appOp({ text, mode: 'read' });
1021
+ if (ok) { setBusy(false); return; }
1022
+ } catch (e) {
1023
+ ai(`执行失败:${String(e?.message || e)}`);
1024
+ setBusy(false);
1025
+ return;
1026
+ }
1027
+ setBusy(false);
1028
+ }
1029
+ const history = msgs.filter((m) => m.role === 'user' || m.role === 'ai').slice(-8).map((m) => ({ role: m.role, text: m.text }));
1030
+ const selectedApp = app ? { name: app.name, title: app.title, command: app.command, space: app.space || space, place_uid: app.place_uid || '', place: app.place || '' } : null;
1031
+ const selectedAvatar = avatar ? { name: avatar.name, uid: avatar.uid, city: avatar.city, company: avatar.company, persona: avatar.persona || '', preferences: avatar.preferences || '', style: avatar.style || '', skills: avatar.skills || [] } : null;
1032
+ const functions = {
1033
+ target: activeTarget ? { uid: activeTarget.uid, title: activeTarget.title, content: activeTarget.content } : null,
1034
+ plan: activePlan ? { uid: activePlan.uid, title: activePlan.title, content: activePlan.content, status: activePlan.status_name, begin_time: activePlan.begin_time, end_time: activePlan.end_time } : null,
1035
+ task: activeTask ? { uid: activeTask.uid, title: activeTask.title, content: activeTask.content, status: activeTask.status_name } : null,
1036
+ report: activeReport ? { uid: activeReport.uid, title: activeReport.title, content: activeReport.content } : null,
1037
+ feedback: activeFeedback ? { uid: activeFeedback.uid, title: activeFeedback.title, content: activeFeedback.content, status: activeFeedback.status_name, score: activeFeedback.score } : null,
1038
+ };
1039
+ const sceneTz = (bg?.params || {}).timezone || (typeof Intl !== 'undefined' ? (Intl.DateTimeFormat().resolvedOptions().timeZone || '') : '');
1040
+ const sceneLoc = (bg?.params || {}).location || (avatar?.city || '');
1041
+ const selectedScene = bg ? { id: bg.id, name: bg.name, theme: themeMode, language: locale === 'zh' ? 'zh' : 'en', timezone: sceneTz, location: sceneLoc } : null;
1042
+ const webPage = chatWebTab ? { url: chatWebTab.base || chatWebTab.url, title: chatWebTab.title || '' } : null;
1043
+ const editorFile = ideFile && ideFile.path ? { node: ideFile.node, space: ideFile.space, path: ideFile.path, language: ideFile.language, dirty: !!ideFile.dirty, content: String(ideFile.content || '').slice(0, 1500) } : null;
1044
+ // 单一命名,不设别名:node / space / app / avatar / scene / mode / view / web / editor
1045
+ const context = {
1046
+ mode,
1047
+ view: view || 'chat',
1048
+ node,
1049
+ space,
1050
+ app: selectedApp,
1051
+ avatar: selectedAvatar,
1052
+ scene: selectedScene,
1053
+ web: webPage,
1054
+ editor: editorFile,
1055
+ experience: { sound: fx?.sound, light: fx?.light, color: fx?.color, style: fx?.style },
1056
+ functions,
1057
+ nodes: (nodes || []).map((n) => ({ port: n.port, name: n.name, state: n.state, env: n.env })),
1058
+ spaces: (spaces || []).map((s) => ({ name: s.name, status: s.status })),
1059
+ apps: [],
1060
+ };
1061
+ let recentApps = Array.isArray(apps) ? apps.slice() : [];
1062
+ if (!recentApps.length) {
1063
+ try { const rr = await api('apps', { node, space, uid: actorId() }); if (Array.isArray(rr)) recentApps = rr; } catch {}
1064
+ }
1065
+ context.apps = (recentApps || []).map((a) => ({ name: a.name, title: a.title, command: a.command }));
1066
+ // 请求上下文只保留一个场景来源:scene。旧会话可能残留 background/bg 别名,
1067
+ // 在发送边界统一剥离,避免同一份赛博世界配置在提示词里重复出现。
1068
+ const requestContext = stripSceneAliases(context);
1069
+ const startedAt = Date.now();
1070
+ setTurn({ at: startedAt, ms: 0, tokens: 0, reqBytes: 0, resBytes: 0, q: text.slice(0, 70), a: '' });
1071
+ setBusy(true); push('sys', 'DSH INTENT');
1072
+ try {
1073
+ setLive(locale === 'zh' ? '② 调用模型:分析意图与参数' : '② Calling model');
1074
+ const r = await api('chat', { message: content, history, context: requestContext, mode });
1075
+ const reply = String(r?.reply || '');
1076
+ const u = r?.usage || null;
1077
+ const tokens = u ? (Number(u.total_tokens) || ((Number(u.input_tokens) || Number(u.prompt_tokens) || 0) + (Number(u.output_tokens) || Number(u.completion_tokens) || 0))) : Math.round((content.length + reply.length) / 4);
1078
+ setTurn({ at: startedAt, ms: Number(r?.ms) || (Date.now() - startedAt), tokens, est: !u, reqBytes: Number(r?.promptBytes) || 0, resBytes: Number(r?.outputBytes) || reply.length, q: text.slice(0, 70), a: reply.slice(0, 80) });
1079
+ const trace = {
1080
+ at: new Date(startedAt).toISOString(),
1081
+ mode,
1082
+ ms: Number(r?.ms) || (Date.now() - startedAt),
1083
+ request: { message: content, history, context: requestContext, promptBytes: Number(r?.promptBytes) || 0 },
1084
+ response: { reply, usage: u || null, outputBytes: Number(r?.outputBytes) || reply.length },
1085
+ actions: [],
1086
+ };
1087
+ const aiId = reply ? ai(reply, { ms: trace.ms, tokens, est: !u, bytes: trace.response.outputBytes, trace }) : null;
1088
+ if (onRequest) onRequest({
1089
+ at: new Date(startedAt).toISOString(),
1090
+ ms: Number(r?.ms) || (Date.now() - startedAt),
1091
+ tokens, usage: u, est: !u,
1092
+ reqBytes: Number(r?.promptBytes) || 0,
1093
+ resBytes: Number(r?.outputBytes) || reply.length,
1094
+ message: String(content).slice(0, 4000),
1095
+ context, mode,
1096
+ reply: String(reply).slice(0, 8000),
1097
+ action: r?.action || null,
1098
+ });
1099
+ const actions = Array.isArray(r?.actions) && r.actions.length ? r.actions : (r?.action ? [r.action] : []);
1100
+ for (const action of actions) {
1101
+ setLive(locale === 'zh' ? `③ 执行动作:${action.op}` : `③ Action: ${action.op}`);
1102
+ let out;
1103
+ try { out = await executeAction(action); } catch (e) { out = { error: String(e?.message || e) }; }
1104
+ trace.actions.push({ op: action.op, args: action.args || {}, result: sanitizeTrace(out) });
1105
+ if (aiId) setMsgs((xs) => xs.map((m) => (m.id === aiId ? { ...m, trace: { ...trace, actions: trace.actions.slice() } } : m)));
1106
+ }
1107
+ // 模型没给动作时,客户端自己判断:写数据类指令直接执行,不再回一句「正在打开…」
1108
+ if (!actions.length && /(添加|录入|新增|写入|加一条|新建一条)/.test(text)) {
1109
+ try { await writeIntoApp({ text }); } catch (e) { ai(`执行失败:${String(e?.message || e)}`); }
1110
+ }
1111
+ } catch (e) {
1112
+ ai(`DSH 意图层调用失败:${String(e?.message || e)}\n请确认 DSH 已配置模型,或重启 DSH 加载 llm 注入。`);
1113
+ } finally {
1114
+ setLive(null);
1115
+ setBusy(false);
1116
+ }
1117
+ };
1118
+ const selector = (kind, key, value, items, sub) => {
1119
+ let menu = null;
1120
+ if (pick === kind) {
1121
+ const children = items.length
1122
+ ? items.map((it) => h('button', {
1123
+ key: it.key, className: `cx-sel-item${it.on ? ' on' : ''}`,
1124
+ onClick: () => { setPick(null); it.go(); },
1125
+ }, it.label, it.sub ? h('div', { className: 's' }, it.sub) : null))
1126
+ : h('div', { className: 'cx-empty', style: { padding: 10 } }, sub || '暂无数据');
1127
+ menu = h('div', { className: 'cx-sel-menu' }, children);
1128
+ }
1129
+ return h('div', { className: 'cx-sel' },
1130
+ h('button', { className: `cx-sel-btn${pick === kind ? ' on' : ''}`, title: value ? `${key}: ${value}` : key, onClick: () => setPick(pick === kind ? null : kind) },
1131
+ h('span', { className: `v${value ? '' : ' ph'}` }, value || key), h('span', { className: 'a' }, '▾')),
1132
+ menu);
1133
+ };
1134
+
1135
+ const fileInput = h('input', {
1136
+ type: 'file', ref: fileRef, multiple: true, style: { display: 'none' },
1137
+ onChange: (e) => { const list = Array.from(e.target.files || []); setFiles((xs) => [...xs, ...list]); e.target.value = ''; },
1138
+ });
1139
+
1140
+ const fmtBytes = (n) => (n >= 1024 ? `${(n / 1024).toFixed(1)}KB` : `${n}B`);
1141
+ const lastAi = [...msgs].reverse().find((m) => m.role === 'ai') || null;
1142
+ const tracePanel = traceMsg ? h('div', { className: 'cx-trace-mask', onClick: () => setTraceMsg(null) },
1143
+ h('div', { className: 'cx-trace', onClick: (e) => e.stopPropagation() },
1144
+ h('div', { className: 'cx-trace-h' },
1145
+ h('span', null, locale === 'zh' ? '请求上下文与返回' : 'Request trace'),
1146
+ h('span', { className: 'cx-trace-sub' }, `${traceMsg.mode || ''} · ${traceMsg.trace?.ms || 0}ms`),
1147
+ h('button', { className: 'cx-trace-x', onClick: () => setTraceMsg(null) }, '✕')),
1148
+ h('div', { className: 'cx-trace-b' }, ['request', 'response', 'actions'].map((k) => h('div', { key: k, className: 'cx-trace-sec' },
1149
+ h('div', { className: 'cx-trace-k' }, k === 'request' ? (locale === 'zh' ? '① 请求(原样发出)' : '① REQUEST') : k === 'response' ? (locale === 'zh' ? '② 模型返回' : '② RESPONSE') : (locale === 'zh' ? '③ 执行动作与结果' : '③ ACTIONS')),
1150
+ h('pre', { className: 'cx-trace-code' }, JSON.stringify(traceMsg.trace?.[k] ?? null, null, 2))))),
1151
+ h('div', { className: 'cx-trace-f' },
1152
+ h('button', { className: 'cx-btn sm', onClick: () => { try { navigator.clipboard.writeText(JSON.stringify(traceMsg.trace, null, 2)); } catch {} } }, T('复制全部'))))) : null;
1153
+ const compose = h('div', { className: 'cx-compose' },
1154
+ chatTab !== 'chat' ? h('div', { className: 'cx-webstatus', onClick: () => setReplyPop((v) => !v) },
1155
+ h('span', { className: `dot${busy ? ' busy' : ''}` }),
1156
+ h('div', { className: 'cx-webstatus-body' },
1157
+ h('div', { className: 'meta' },
1158
+ h('span', null, turn ? new Date(turn.at).toLocaleTimeString() : '—'),
1159
+ h(Elapsed, { from: turn?.at, ms: turn?.ms, active: busy }),
1160
+ h('span', null, `${turn?.est ? '≈' : ''}${turn?.tokens || 0} tok`),
1161
+ h('span', null, `${fmtBytes(turn?.reqBytes || 0)} → ${fmtBytes(turn?.resBytes || 0)}`)),
1162
+ h('div', { className: 'sum' }, turn ? (busy ? `Q: ${turn.q}` : `A: ${turn.a || turn.q}`) : (locale === 'zh' ? '可以就当前网页提问' : 'Ask about this page'))),
1163
+ h(IconButton, { name: replyPop ? 'chevronDown' : 'chevronUp', className: 'arr', onClick: () => setReplyPop((v) => !v), title: replyPop ? T('收起') : T('展开') })) : null,
1164
+ files.length ? h('div', { className: 'cx-compose-files' }, files.map((f, i) => h('span', { key: i, className: 'cx-tag cy' }, f.name, h(IconButton, { name: 'close', className: 'cx-tag-x', onClick: () => setFiles((xs) => xs.filter((_, j) => j !== i)) })))) : null,
1165
+ h('textarea', { key: sessionKey, ref: inputRef, className: 'cx-compose-input', rows: 3, defaultValue: initialDraft, placeholder: 'Message or run a task, / commands, @ files or sessions', disabled: busy, onChange: (e) => { const v = e.target.value; clearTimeout(draftTimerRef.current); draftTimerRef.current = setTimeout(() => cxWriteText('draft', v), 180); }, onKeyDown: (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent?.isComposing) { e.preventDefault(); handle(inputRef.current?.value || ''); } } }),
1166
+ h('div', { className: 'cx-compose-tools', onContextMenu: (e) => {
1167
+ const el = e.target.closest && e.target.closest('.cx-compose-plus, .cx-compose-send');
1168
+ if (!el) return;
1169
+ e.preventDefault();
1170
+ el.classList.remove('cx-shake');
1171
+ void el.offsetWidth;
1172
+ el.classList.add('cx-shake');
1173
+ setTimeout(() => { try { el.classList.remove('cx-shake'); } catch {} }, 520);
1174
+ } },
1175
+ h(IconButton, { name: 'plus', className: 'cx-compose-plus', title: T('添加文件'), onClick: () => fileRef.current && fileRef.current.click() }),
1176
+ selector('serve', tt('serve'), nodes.find((n) => n.port === node)?.name || (node ? `:${node}` : ''), (nodes || []).map((n) => ({ key: n.port, label: n.name || `:${n.port}`, sub: `:${n.port} · ${n.state}`, on: n.port === node, go: () => onSelectNode(n.port) })), '没有服务节点'),
1177
+ selector('space', tt('space'), space, (spaces || []).map((s) => ({ key: s.name, label: s.name, sub: `${s.status} · ${s.version || ''}`, on: s.name === space, go: () => onSelectSpace(s.name) })), '该节点还没有项目空间'),
1178
+ selector('place', tt('applications'), app?.title || app?.name || '', (apps || []).map((a) => ({ key: `${a.space}:${a.command}:${a.place_uid || a.name}`, label: a.title || a.name, sub: `${a.city_name || ''} · ${a.company_name || ''} · ${a.service_name || ''}`, on: app?.place_uid === a.place_uid, go: () => onSelectApp(a) })), '最近应用为空'),
1179
+ h('div', { className: 'cx-compose-right' },
1180
+ selector('avatar', T('我的分身'), avatar?.name || '', (avatars || []).map((a) => ({ key: a.uid, label: a.name || a.username, sub: `${a.city || ''} · ${a.company || ''}`, on: a.uid === avatar?.uid, go: () => onSelectAvatar(a) })), '没有数字分身'),
1181
+ selector('bg', T('我的赛博世界'), bg?.name || '', (backgrounds || []).map((b) => ({ key: b.id, label: b.name, sub: b.image ? T('已上传照片 · 生成赛博世界') : T('默认赛博世界'), on: b.id === bg?.id, go: () => onSelectBg(b) })), '没有背景'),
1182
+ selector('mode', tt('mode'), modeLabel(CHAT_MODES.find((m) => m.id === mode) || CHAT_MODES[0]), CHAT_MODES.map((m) => ({ key: m.id, label: modeLabel(m), sub: m.id, on: m.id === mode, go: () => switchMode(m.id) })), ''),
1183
+ h(IconButton, { name: 'send', className: 'cx-compose-send', disabled: busy, title: T('发送'), onClick: () => handle(inputRef.current?.value || '') }))),
1184
+ fileInput,
1185
+ h('input', { type: 'file', accept: 'image/*', ref: avatarImgRef, style: { display: 'none' }, onChange: (e) => { const f = (e.target.files || [])[0]; e.target.value = ''; f && onUploadAvatar(f); } }),
1186
+ h('input', { type: 'file', accept: 'image/*', ref: bgImgRef, style: { display: 'none' }, onChange: (e) => { const f = (e.target.files || [])[0]; e.target.value = ''; f && onUploadBg(f); } }));
1187
+
1188
+ const renderMsg = (m, i) => m.role === 'form'
1189
+ ? h(CxWriteForm, { key: i, msg: m, lang: locale, onSubmit: submitForm, onDone: markFormDone, onOpen: (url, title) => { if (typeof OPEN_LINK === 'function') OPEN_LINK(url, title); } })
1190
+ : m.role === 'step'
1191
+ ? h('div', { key: i, className: 'cx-step' }, m.text)
1192
+ : m.role === 'table' && m.table
1193
+ ? h('div', { key: i, className: 'cx-dtable' },
1194
+ h('div', { className: 'cx-dtable-h' }, h('span', null, m.table.title), h('span', { className: 'n' }, m.table.detail ? (locale === 'zh' ? '详情' : 'DETAIL') : `${m.table.count} ${locale === 'zh' ? '条' : 'rows'}`)),
1195
+ h('div', { className: 'cx-dtable-wrap' },
1196
+ m.table.detail
1197
+ ? h('table', { className: 'cx-dtable-kv' }, h('tbody', null, m.table.detail.map((f) => h('tr', { key: f.key },
1198
+ h('th', null, f.label), h('td', null, f.value.slice(0, 120))))))
1199
+ : h('table', null,
1200
+ h('thead', null, h('tr', null, m.table.columns.map((c) => h('th', { key: c.key }, c.label)))),
1201
+ h('tbody', null, m.table.rows.map((row, ri) => h('tr', { key: ri }, m.table.columns.map((c) => h('td', { key: c.key }, String(row[c.key] ?? '').slice(0, 40)))))))))
1202
+ : m.role === 'sys'
1203
+ ? h('div', { key: i, className: 'cx-sysline' }, h('span', { className: 'cx-sysdot' }), m.text)
1204
+ : h('div', { key: i, className: `cx-msg-wrap ${m.role}` },
1205
+
1206
+ h('div', { className: `cx-msg ${m.role}`, style: { display: 'flex', flexDirection: m.role === 'user' ? 'row-reverse' : 'row', alignItems: 'flex-start', gap: 8 } },
1207
+ h('span', { className: 'cx-msg-avatar' }, m.role === 'user' ? 'ME' : 'AI'),
1208
+ h('div', { className: 'cx-msg-main', style: { display: 'flex', flexDirection: 'column', alignItems: 'stretch', flex: '1 1 auto', minWidth: 0 } },
1209
+ h('div', { className: 'cx-msg-meta', style: { display: 'flex', alignItems: 'center', gap: 8, width: '100%', marginBottom: 2 } },
1210
+ h('span', null, m.role === 'user' ? 'YOU' : 'COPILOT'),
1211
+ m.t ? h('span', null, m.t) : null,
1212
+ m.role === 'ai' && m.ms ? h('span', { className: 'cx-msg-metrics' }, `${(m.ms / 1000).toFixed(1)}s`) : null,
1213
+ m.role === 'ai' && m.tokens ? h('span', { className: 'cx-msg-metrics' }, `${m.est ? '≈' : ''}${m.tokens} tok`) : null,
1214
+ m.role === 'ai' && m.bytes ? h('span', { className: 'cx-msg-metrics' }, fmtBytes(m.bytes)) : null,
1215
+ h('div', { className: 'cx-msg-tools', style: { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 6 } },
1216
+ m.trace ? h('button', { className: 'cx-msg-tool', title: T('查看请求的上下文与返回'), onClick: () => setTraceMsg(m) }, T('详情')) : null,
1217
+ m.role === 'ai' ? h('button', { className: 'cx-msg-tool', title: T('重试'), onClick: () => { const prev = [...msgs.slice(0, i)].reverse().find((x) => x.role === 'user'); if (prev) handle(prev.text); } }, T('重试')) : null,
1218
+ h('button', { className: 'cx-msg-tool', title: T('引用'), onClick: () => { const el = inputRef.current; if (!el) return; el.value = '> ' + String(m.text || '').split('\n').join('\n> ') + '\n'; el.focus(); } }, T('引用')),
1219
+ h('button', { className: 'cx-msg-tool', title: T('复制'), onClick: () => { try { navigator.clipboard.writeText(m.text); } catch {} } }, T('复制')),
1220
+ h('button', { className: 'cx-msg-tool danger', title: T('删除'), onClick: () => { setMsgs((list) => list.filter((_, j) => j !== i)); } }, T('删除'))),
1221
+ ),
1222
+ h('div', { className: 'cx-msg-body' },
1223
+ h(RichText, { text: m.text }),
1224
+ m.ask ? h(CxAskForm, { m, lang: locale, onSubmit: submitAsk })
1225
+ : m.card && m.card.entity && m.card.entity.kind === 'space' && m.card.entity.data
1226
+ ? h(SpaceCard, { s: m.card.entity.data, node: m.card.entity.node || node, locale, busy: false,
1227
+ onSelect: cards?.selectSpace, onOpen: (x) => { const id = cards?.openSpace?.(x?.name || x); if (id) setChatTab(id); }, onStart: cards?.startSpace, onStop: cards?.stopSpace, onDelete: cards?.deleteSpace, onIde: cards?.openIde, onTool: cards?.openCardTool, onHome: () => cards?.openHome?.(m.card.entity.data?.name || ''), isOpen: cards?.isOpenUrl, tone: cards?.tone })
1228
+ : m.card && m.card.entity && m.card.entity.kind === 'app' && m.card.entity.data
1229
+ ? h(AppRow, { a: m.card.entity.data, node: m.card.entity.node || node, locale, busy: false, onSelect: cards?.selectApp, onOpen: (a) => { const id = cards?.openApp?.(a); if (id) setChatTab(id); } })
1230
+ : m.role === 'tool' ? h(ToolRow, { ctx: m.tool })
1231
+ : m.card && m.card.entity && m.card.entity.kind === 'service' && m.card.entity.data
1232
+ ? h(StoreRow, { s: m.card.entity.data, node: m.card.entity.node || node, locale, busy: false,
1233
+ onCreate: (svc) => createAppFromStore(svc || '') })
1234
+ : m.card && m.card.entity && m.card.entity.kind === 'node' && m.card.entity.data
1235
+ ? h(NodeCard, { n: m.card.entity.data, locale, active: false, busy: false, onSelect: cards?.selectNode, onStart: cards?.startNode, onStop: cards?.stopNode, onDelete: cards?.deleteNode, onOpen: cards?.openNode, onIde: cards?.openIde, onTool: cards?.openCardTool, onHome: () => cards?.openHome?.(''), isOpen: cards?.isOpenUrl, tone: cards?.tone })
1236
+ : m.card && m.card.entity ? h(CxEntityCard, { e: m.card.entity, lang: locale, onOpen: (u, t) => { if (typeof OPEN_LINK === 'function') OPEN_LINK(u, t); } })
1237
+ : m.card && m.card.app ? h('div', { className: 'cx-app-card' },
1238
+ h('div', { className: 'cx-app-card-ico' }, m.card.app.icon ? h('img', { src: m.card.app.icon, alt: '' }) : h('span', { className: 'ph' }, String(m.card.app.title || 'A').slice(0, 1))),
1239
+ h('div', { className: 'cx-app-card-main' },
1240
+ h('div', { className: 'cx-app-card-title' }, m.card.app.title || '应用'),
1241
+ h('div', { className: 'cx-app-card-sub' }, m.card.app.meta || '')),
1242
+ m.card.app.link ? h('button', { className: 'cx-app-card-open', onClick: () => { if (typeof OPEN_LINK === 'function') OPEN_LINK(m.card.app.link.url, m.card.app.title || '打开'); } }, 'OPEN') : null)
1243
+ : m.card ? h('div', { className: 'cx-result-card' },
1244
+ h('div', { className: 'cx-result-title' }, m.card.title || '结果'),
1245
+ h('div', { className: 'cx-result-rows' }, (m.card.rows || []).map(([k, v], j) => h('div', { key: j, className: 'cx-result-row' }, h('span', null, k), h('b', null, String(v ?? '—'))))),
1246
+ m.card.link ? h('button', { className: 'cx-result-link', onClick: () => { if (typeof OPEN_LINK === 'function') OPEN_LINK(m.card.link.url, m.card.link.label || '管理'); } }, m.card.link.label || '打开') : null) : null))));
1247
+
1248
+ const uidCtx = (it) => uidFieldMap(it);
1249
+ const openPm = (kind, mode = 'create', item = null) => {
1250
+ const parent = kind === 'target' ? {} : kind === 'plan' ? (activeTarget ? { target_uid: activeTarget.uid, ...uidCtx(activeTarget) } : null) : kind === 'task' ? (activePlan ? { plan_uid: activePlan.uid, ...uidCtx(activePlan) } : null) : kind === 'report' ? (activeTask ? { task_uid: activeTask.uid, ...uidCtx(activeTask) } : null) : kind === 'feedback' ? (activeReport ? { report_uid: activeReport.uid, task_uid: activeReport.task_uid || '', ...uidCtx(activeReport) } : null) : null;
1251
+ if (!parent) { ai('请先在上级列表选择一个项目。'); return; }
1252
+ setPmForm({ kind, mode, item, parent });
1253
+ };
1254
+ const openPmChild = (kind, parentItem) => {
1255
+ if (!parentItem?.uid) { ai('请先选择一个上级项目。'); return; }
1256
+ let parent = null;
1257
+ if (kind === 'plan') { parent = { target_uid: parentItem.uid, ...uidCtx(parentItem) }; setTargetId(parentItem.uid); setPlanId(''); setTaskId(''); setReportId(''); setFeedbackId(''); }
1258
+ if (kind === 'task') { parent = { plan_uid: parentItem.uid, ...uidCtx(parentItem) }; setPlanId(parentItem.uid); setTaskId(''); setReportId(''); setFeedbackId(''); }
1259
+ if (kind === 'report') { parent = { task_uid: parentItem.uid, ...uidCtx(parentItem) }; setTaskId(parentItem.uid); setReportId(''); setFeedbackId(''); }
1260
+ if (kind === 'feedback') { parent = { report_uid: parentItem.uid, task_uid: parentItem.task_uid || '', ...uidCtx(parentItem) }; setReportId(parentItem.uid); setFeedbackId(''); }
1261
+ if (!parent) { ai('无法确定上级项目。'); return; }
1262
+ setPmForm({ kind, mode: 'create', item: null, parent });
1263
+ };
1264
+ const removePm = async (kind, item) => {
1265
+ if (!item || !window.confirm(`删除「${item.title}」?`)) return;
1266
+ try { await api('pmRemove', { node, uid: avatar?.uid || '', kind, item_uid: item.uid, uid_options: uidFieldMap(item) }); await loadPm(); ai(`已删除${kind}:${item.title}`); }
1267
+ catch (e) { ai(`删除失败:${String(e?.message || e)}`); }
1268
+ };
1269
+ const pmCanCreate = (kind) => kind === 'target'
1270
+ ? true
1271
+ : kind === 'plan' ? !!(activeTarget?.actions || []).includes('planCreate')
1272
+ : kind === 'task' ? !!(activePlan?.actions || []).includes('taskCreate')
1273
+ : kind === 'report' ? !!(activeTask?.actions || []).includes('reportCreate')
1274
+ : kind === 'feedback' ? !!activeReport
1275
+ : false;
1276
+ const pmOps = (kind) => h(PmCreateButton, { kind, allowed: pmCanCreate(kind), onCreate: () => openPm(kind) });
1277
+ const runPmAction = async (kind, item, action) => {
1278
+ const createKind = action === 'planCreate' ? 'plan' : action === 'taskCreate' ? 'task' : action === 'reportCreate' ? 'report' : action === 'feedbackCreate' ? 'feedback' : '';
1279
+ if (createKind) return openPmChild(createKind, item);
1280
+ if (action === 'modify') return openPm(kind, 'edit', item);
1281
+ if (action === 'open') { if (item.link && typeof OPEN_LINK === 'function') OPEN_LINK(item.link, item.title || 'link'); return; }
1282
+ if (action === 'remove') return removePm(kind, item);
1283
+ try { await api('pmAct', { node, uid: avatar?.uid || '', kind, item_uid: item.uid, action, uid_options: uidFieldMap(item) }); await loadPm(); const msg = `${PM_SHORT[kind]}「${item.title}」执行 ${action}`; ai(msg); try { window.dispatchEvent(new CustomEvent('cx-toast', { detail: { text: msg, kind: 'ok' } })); } catch {} }
1284
+ catch (e) { const msg = `操作失败:${String(e?.message || e)}`; ai(msg); try { window.dispatchEvent(new CustomEvent('cx-toast', { detail: { text: msg, kind: 'err' } })); } catch {} }
1285
+ };
1286
+ const pmItem = (item, active, onClick, sub, kind) => h(PmCard, {
1287
+ key: `${item.uid}:${cxCardSig(item)}`, item, kind, active, sub, traceIds, traceLog,
1288
+ onSelect: onClick, onAction: (action) => runPmAction(kind, item, action),
1289
+ });
1290
+ const leftPanel = h('aside', { className: `cx-pm-side${pmRefreshing ? ' refreshing' : ''}` },
1291
+ h('div', { className: 'cx-pm-scroll' },
1292
+ h(PmSectionHead, { icon: 'target', title: sectionTitle(tt('target'), 'TARGETS'), count: targets.length, folded: fold('targets'), onToggle: () => setFolds((f) => ({ ...f, targets: !f.targets })), onRefresh: () => loadPm('target'), tools: pmOps('target') }),
1293
+ pmError ? h('div', { className: 'cx-pm-empty err' }, pmError) : null,
1294
+ !pm && !pmError ? h('div', { className: 'cx-pm-empty' }, T('加载中…')) : null,
1295
+ fold('targets') ? null : targets.length ? h(UiCardList, { items: targets, render: (t) => pmItem(t, activeTarget?.uid === t.uid, () => { setTargetId(t.uid); setPlanId(''); setTaskId(''); setReportId(''); setFeedbackId(''); }, `${t.plans?.length || 0} 个计划`, 'target') }) : h(PmEmpty, { label: T('创建目标'), onCreate: pm?.project && pmCanCreate('target') ? () => openPm('target') : null }, pm?.project ? T('还没有目标') : T('请先初始化项目管理')),
1296
+ h(PmSectionHead, { icon: 'plan', title: sectionTitle(tt('plan'), 'PLANS'), count: plans.length, folded: fold('plans'), style: { marginTop: 12 }, onToggle: () => setFolds((f) => ({ ...f, plans: !f.plans })), onRefresh: () => loadPm('plan'), tools: pmOps('plan') }),
1297
+ fold('plans') ? null : (planStatusOptions.length > 1 ? h(PmFilter, { label: tt('status'), options: planStatusOptions, value: planStatusFilter, onChange: setPlanStatusFilter }) : null),
1298
+ fold('plans') ? null : plans.length ? h(UiCardList, { items: plans, render: (p) => pmItem(p, activePlan?.uid === p.uid, () => { setPlanId(p.uid); setTaskId(''); setReportId(''); setFeedbackId(''); }, `${p.tasks?.length || 0} 任务`, 'plan') }) : h(PmEmpty, { label: T('创建计划'), onCreate: activeTarget && pmCanCreate('plan') ? () => openPm('plan') : null }, activeTarget ? T('该目标下还没有计划') : T('选择目标后显示计划')),
1299
+ ),
1300
+ h('div', { className: `cx-gantt-sec${fold('gantt') ? ' folded' : ''}` },
1301
+ h(PmSectionHead, { icon: 'clock', title: sectionTitle('进度', 'PROGRESS'), count: plans.filter((p) => p.status_name === 'process').length, folded: fold('gantt'), onToggle: () => setFolds((f) => ({ ...f, gantt: !f.gantt })) }),
1302
+ fold('gantt') ? null : h(Gantt, { plans, activeUid: activePlan?.uid })));
1303
+ const rightPanel = h('aside', { className: `cx-pm-side right${pmRefreshing ? ' refreshing' : ''}` },
1304
+ h('div', { className: `cx-calsec${calFull ? ' max' : ''}`, ref: calRef },
1305
+ h(PmSectionHead, { icon: 'clock', title: sectionTitle('时间', 'TIME'), folded: fold('cal'), style: { '--cx-hi': '0' }, onToggle: () => setFolds((f) => ({ ...f, cal: !f.cal })), tools: h(IconButton, { name: calFull ? 'restore' : 'fullscreen', className: 'cx-pm-max', onClick: () => { setCalFull((v) => !v); if (fold('cal')) setFolds((f) => ({ ...f, cal: false })); }, title: calFull ? T('还原') : T('最大化') }) }),
1306
+ fold('cal') ? null : h(PmCalendar, { tasks: allTasks, start: calStart, end: calEnd, onChange: calOnChange, locale })),
1307
+ h(PmSectionHead, { icon: 'task', title: sectionTitle(tt('task'), 'TASKS'), count: tasks.length, folded: fold('tasks'), style: { '--cx-hi': '1' }, onToggle: () => setFolds((f) => ({ ...f, tasks: !f.tasks })), onRefresh: () => loadPm('task'), tools: pmOps('task') }),
1308
+ fold('tasks') ? null : (taskStatusOptions.length > 1 ? h(PmFilter, { label: tt('status'), options: taskStatusOptions, value: taskStatusFilter, onChange: setTaskStatusFilter }) : null),
1309
+ fold('tasks') ? null : tasks.length ? h(UiCardList, { items: tasks, render: (t) => pmItem(t, activeTask?.uid === t.uid, () => { setTaskId(t.uid); setReportId(''); setFeedbackId(''); }, `${t.reports?.length || 0} 报告`, 'task') }) : (allTasks.length ? h('div', { className: 'cx-pm-empty' }, `有 ${allTasks.length} 条任务被筛选条件隐藏 `, h('button', { className: 'cx-btn xs', onClick: () => { setCalStart(''); setCalEnd(''); setTaskStatusFilter([]); } }, T('清除筛选'))) : h(PmEmpty, { label: T('创建任务'), onCreate: activePlan && pmCanCreate('task') ? () => openPm('task') : null }, activePlan ? T('该计划下还没有任务') : T('先选择一个计划'))),
1310
+ h(PmSectionHead, { icon: 'report', title: sectionTitle(tt('report'), 'REPORTS'), count: reports.length, folded: fold('reports'), style: { '--cx-hi': '2', marginTop: 12 }, onToggle: () => setFolds((f) => ({ ...f, reports: !f.reports })), onRefresh: () => loadPm('report'), tools: pmOps('report') }),
1311
+ fold('reports') ? null : (reportTypeOptions.length > 1 ? h(PmFilter, { label: tt('type'), options: reportTypeOptions, value: reportTypeFilter, onChange: setReportTypeFilter }) : null),
1312
+ fold('reports') ? null : reports.length ? h(UiCardList, { items: reports, render: (r) => pmItem(r, activeReport?.uid === r.uid, () => { setReportId(r.uid); setFeedbackId(''); }, `${r.feedbacks?.length || 0} 反馈`, 'report') }) : h(PmEmpty, { label: T('创建报告'), onCreate: activeTask && pmCanCreate('report') ? () => openPm('report') : null }, activeTask ? T('该任务下还没有报告') : T('先选择一个任务')),
1313
+ h(PmSectionHead, { icon: 'feedback', title: sectionTitle(tt('feedback'), 'FEEDBACK'), count: feedbacks.length, folded: fold('feedbacks'), style: { '--cx-hi': '3', marginTop: 12 }, onToggle: () => setFolds((f) => ({ ...f, feedbacks: !f.feedbacks })), onRefresh: () => loadPm('feedback'), tools: pmOps('feedback') }),
1314
+ fold('feedbacks') ? null : (feedbackStatusOptions.length > 1 ? h(PmFilter, { label: tt('status'), options: feedbackStatusOptions, value: feedbackStatusFilter, onChange: setFeedbackStatusFilter }) : null),
1315
+ fold('feedbacks') ? null : feedbacks.length ? h(UiCardList, { items: feedbacks, render: (f) => pmItem(f, activeFeedback?.uid === f.uid, () => setFeedbackId(f.uid), `${f.score || 0} 分`, 'feedback') }) : h(PmEmpty, { label: T('创建反馈'), onCreate: activeReport && pmCanCreate('feedback') ? () => openPm('feedback') : null }, activeReport ? T('该报告下还没有反馈') : T('先选择一个报告')),
1316
+ h('div', { className: `cx-mapsec${fold('map') ? ' folded' : ''}${mapFull ? ' max' : ''}`, ref: mapSecRef },
1317
+ h(PmSectionHead, { icon: 'map', title: sectionTitle('地点', 'LOCATION'), folded: fold('map'), style: { '--cx-hi': '4' }, onToggle: () => setFolds((f) => ({ ...f, map: !f.map })), tools: [
1318
+ h(IconButton, { key: 'loc', name: 'locate', onClick: () => mapApiRef.current && mapApiRef.current.locate && mapApiRef.current.locate(), title: T('定位') }),
1319
+ h(IconButton, { key: 'max', name: mapFull ? 'restore' : 'fullscreen', onClick: () => { setMapFull((v) => !v); if (fold('map')) setFolds((f) => ({ ...f, map: false })); }, title: mapFull ? T('还原') : T('最大化') }),
1320
+ ] }),
1321
+ fold('map') ? null : h(TaskMap, { tasks, activeTask, theme: mapTheme, full: mapFull, onLocateReady: (api) => { mapApiRef.current = api; }, onSelect: (t) => { setTaskId(t.uid); setReportId(''); setFeedbackId(''); } })));
1322
+
1323
+ if (!open) return null;
1324
+ return h('div', { className: `cx-chat${full ? ' full' : ''}${docked ? ' dock' : ''}${webMax && chatTab !== 'chat' ? ' webmax' : ''}`, 'data-theme': theme },
1325
+ h('div', { className: `cx-chat-bg${bg?.image ? ' photo' : ''}` }, bg?.image ? h('img', { src: bg.image, alt: '' }) : (full ? h(ThreeCity) : null)),
1326
+ h('div', { className: 'cx-chat-body' },
1327
+ showLeft ? leftPanel : null,
1328
+ h('div', { className: 'cx-chat-center' },
1329
+ h('div', { className: 'cx-chat-h' },
1330
+ h('div', { className: 'cx-chat-hin' },
1331
+ h('div', { className: 'cx-chat-brand' },
1332
+ h('div', { className: 'cx-chat-orb' }, h(CtxMark, { variant: 'copilot', size: 22 })),
1333
+ h('div', { className: 'cx-chat-copy' }, h('div', { className: 'cx-chat-title' }, 'CONTEXTOS · COPILOT'), h('div', { className: 'cx-chat-sub' }, `${tt('avatar')} · ${tt('factory')} · ${tt('iot')} · ${cxClientTag()}`))),
1334
+ h('div', { className: 'cx-chat-actions' },
1335
+ ...(!docked ? [
1336
+ h(DropSelect, { value: themeMode, title: tt('light') + ' / ' + tt('dark'), onChange: setThemeMode, options: [{ value: 'system', label: tt('system') }, { value: 'light', label: tt('light') }, { value: 'dark', label: tt('dark') }] }),
1337
+ h(DropSelect, { value: langMode, title: tt('chinese') + ' / English', onChange: setLangMode, options: [{ value: 'system', label: tt('system') }, { value: 'zh', label: '中文' }, { value: 'en', label: 'English' }] }),
1338
+ h(IconButton, { name: 'bolt', className: 'cx-btn xs cx-icon-btn', onClick: onInit, title: 'Initialize' }),
1339
+ ] : []),
1340
+ h(IconButton, { name: (full && !docked) ? 'restore' : 'fullscreen', className: 'cx-btn xs cx-icon-btn', onClick: onToggleFull, title: (full && !docked) ? tt('restore') : tt('maximize') }),
1341
+ h(IconButton, { name: 'panelLeft', className: `cx-btn xs cx-icon-btn${showLeft ? ' on' : ''}`, title: showLeft ? T('隐藏左栏') : T('显示左栏'), onClick: () => setShowLeft((v) => !v) }),
1342
+ h(IconButton, { name: 'panelRight', className: `cx-btn xs cx-icon-btn${showRight ? ' on' : ''}`, title: showRight ? T('隐藏右栏') : T('显示右栏'), onClick: () => setShowRight((v) => !v) }),
1343
+ h(IconButton, { name: 'close', className: 'x', onClick: onClose, title: tt('close') })))),
1344
+ h('div', { className: 'cx-chat-tabs' },
1345
+ h('button', { className: `cx-chat-tab${chatTab === 'chat' ? ' on' : ''}`, onClick: () => setChatTab('chat') }, tt('chat')),
1346
+ (webTabs || []).map((t) => h('button', { key: t.id, className: `cx-chat-tab${chatTab === t.id ? ' on' : ''}`, title: t.url, onClick: () => setChatTab(t.id) },
1347
+ h('span', { className: 'nm' }, t.title || 'web'),
1348
+ h(IconButton, { name: 'close', className: 'x', onClick: (e) => { e.stopPropagation(); onCloseWebTab && onCloseWebTab(t.id); if (chatTab === t.id) setChatTab('chat'); } })))),
1349
+ chatWebTab ? h('div', { className: 'cx-addrbar' },
1350
+ h(IconButton, { name: 'arrowLeft', className: 'nav', disabled: !(hist && hist.idx > 0), onClick: back, title: T('后退') }),
1351
+ h(IconButton, { name: 'arrowRight', className: 'nav', disabled: !(hist && hist.idx < hist.list.length - 1), onClick: fwd, title: T('前进') }),
1352
+ h(IconButton, { name: 'refresh', className: 'nav', onClick: () => onReloadWebTab && onReloadWebTab(chatWebTab.id), title: T('刷新') }),
1353
+ h('input', { className: 'cx-addr-input', value: addr, placeholder: 'https://…', onChange: (e) => setAddr(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') go(addr); } }),
1354
+ h(IconButton, { name: webMax ? 'restore' : 'fullscreen', className: 'nav', onClick: () => setWebMax((v) => !v), title: webMax ? tt('restore') : tt('maximize') }),
1355
+ h(MenuButton, { items: [
1356
+ { label: '复制地址', action: () => { try { navigator.clipboard.writeText(chatWebTab.base || chatWebTab.url); } catch {} } },
1357
+ { label: '在浏览器打开', action: () => { try { window.open(chatWebTab.base || chatWebTab.url, '_blank'); } catch {} } },
1358
+ { label: '在管理中打开', action: () => onManageWebTab && onManageWebTab(chatWebTab.id) },
1359
+ { label: '关闭标签', danger: true, action: () => { onCloseWebTab && onCloseWebTab(chatWebTab.id); setChatTab('chat'); } },
1360
+ ] })) : null,
1361
+ h('div', { className: 'cx-chat-webframes', style: chatTab === 'chat' ? { display: 'none' } : null },
1362
+ (webTabs || []).map((t) => h('iframe', { key: t.id, src: t.url, title: t.title || 'web', allow: 'clipboard-read; clipboard-write', onLoad: () => setChatLoaded((m) => (m[t.id] ? m : { ...m, [t.id]: 1 })), style: chatTab === t.id ? null : { display: 'none' } })),
1363
+ chatWebTab && !chatLoaded[chatWebTab.id] ? h('div', { className: 'cx-webmask' }, T('加载中…')) : null),
1364
+ h('div', { className: 'cx-chat-b', ref: endRef, onScroll: onMsgScroll, style: chatTab !== 'chat' ? { display: 'none' } : null, onContextMenu: (e) => { if (e.target.closest('.cx-msg, button, a, input, textarea, select, .cx-sel, .cx-hint, .cx-suggest')) return; openCtx(e, 'bg'); } },
1365
+ h('div', { className: `cx-world${msgs.some((m) => m.role === 'user') ? ' gone' : ''}` },
1366
+ h('div', { className: 'cx-world-kicker' }, 'WELCOME TO'),
1367
+ h('div', { className: 'cx-world-name' }, 'CONTEXTOS WORLD!'),
1368
+ h('div', { className: 'cx-world-sub' }, T('数字分身 · 软件工厂 · 赛博世界')),
1369
+ h('div', { className: 'cx-world-line' })),
1370
+ (booted && !chatReady) ? h('div', { className: 'cx-gate' }, h('div', { className: 'cx-gate-t' }, '先初始化,再开始对话'), h('div', { className: 'cx-gate-d' }, '需要服务节点、云社区/云办公、数字分身和项目管理。'), h('button', { className: 'cx-btn pri', onClick: onInit }, T('初始化'))) : ((pmBooted && !planReady) ? h('div', { className: 'cx-gate' }, h('div', { className: 'cx-gate-t' }, '先创建或选择计划'), h('div', { className: 'cx-gate-d' }, '每个会话属于一个计划。可以在左侧选择目标/计划,或直接对 AI 说「创建目标…」「为该目标创建计划…」。'), h('div', { className: 'cx-gate-actions' }, h('button', { className: 'cx-btn pri', onClick: () => openPm('target') }, '+ 目标'), h('button', { className: 'cx-btn', onClick: loadPm }, '刷新项目数据'))) : null),
1371
+ h('div', { className: 'cx-hero' },
1372
+ h('div', { className: 'cx-hero-avatar', title: '右键:上传头像照片', onContextMenu: (e) => openCtx(e, 'avatar') }, h(ThreeAvatar, { image: avatar?.image })),
1373
+ h('div', { className: 'cx-hero-copy' },
1374
+ h('div', { className: 'cx-hero-kicker' }, 'AI NATIVE OPERATING SYSTEM'),
1375
+ h('div', { className: 'cx-hero-title' }, '你好,我是 ContextOS Copilot'),
1376
+ h('div', { className: 'cx-hero-sub' }, T('用一个对话,部署节点、创建空间、生成应用、管理数字分身与日常任务。')),
1377
+ !msgs.some((m) => m.role === 'user') ? h('div', { className: 'cx-empty-hint' }, T('消息还是空白 · 输入问题,或从下面的建议开始')) : null)),
1378
+ msgs.map(renderMsg),
1379
+ live ? h('div', { className: 'cx-live' }, h('span', { className: 'cx-live-dot' }), h('span', { className: 'cx-live-t' }, live)) : null),
1380
+ h('div', { className: 'cx-hint' },
1381
+ h('div', { className: 'cx-hint-name' }, activePlan ? (locale === 'zh' ? `计划「${activePlan.title}」` : `Plan "${activePlan.title}"`) : T(CHAT_MODES.find((m) => m.id === mode)?.name || '提示词')),
1382
+ h('div', { className: 'cx-hint-quick' }, modeQuick(CHAT_MODES.find((x) => x.id === mode)).map((label) => h('button', {
1383
+ key: label, type: 'button', className: 'cx-hint-chip', title: T('点击填入输入框'),
1384
+ onClick: () => {
1385
+ const el = inputRef.current;
1386
+ if (!el) return;
1387
+ el.value = label;
1388
+ el.focus();
1389
+ try { el.setSelectionRange(el.value.length, el.value.length); el.dispatchEvent(new Event('input', { bubbles: true })); } catch {}
1390
+ },
1391
+ }, label))),
1392
+ h('button', { className: 'cx-hint-more', onClick: () => setShowMore((v) => !v) }, showMore ? T('收起') : T('更多'))),
1393
+ showMore ? h('div', { className: 'cx-hint-panel' }, CHAT_MODES.map((m) => {
1394
+ const note = PRODUCT_NOTES[m.id] || {};
1395
+ return h('button', { key: m.id, className: `cx-hint-card${m.id === mode ? ' on' : ''}`, onClick: () => { switchMode(m.id); setShowMore(false); } },
1396
+ h('div', { className: 'ht' }, T(note.tag || 'MODE'), ' · ', locale === 'zh' ? (note.title || m.name) : modeLabel(m)),
1397
+ h('div', { className: 'hd' }, T(note.text || m.intro)));
1398
+ })) : null,
1399
+ busy ? h('div', { className: 'cx-chat-typing' }, T('Copilot 正在执行…')) : null,
1400
+ compose,
1401
+ chatTab !== 'chat' && replyPop ? h('div', { className: 'cx-webpop' },
1402
+ h('div', { className: 'h' }, h('span', null, locale === 'zh' ? '最近一条回复' : 'LATEST REPLY'), h(IconButton, { name: 'close', onClick: () => setReplyPop(false) })),
1403
+ h('div', { className: 'b' }, lastAi ? h(RichText, { text: lastAi.text }) : h('div', { className: 'cx-empty' }, '—'))) : null),
1404
+ showRight ? rightPanel : null),
1405
+ tracePanel,
1406
+ ctxMenu ? h('div', { className: 'cx-ctxmenu', style: { left: Math.min(ctxMenu.x, Math.max(0, (typeof window !== 'undefined' ? window.innerWidth : 1200) - 200)), top: Math.min(ctxMenu.y, Math.max(0, (typeof window !== 'undefined' ? window.innerHeight : 800) - 150)) }, onClick: (e) => e.stopPropagation() },
1407
+ h('div', { className: 'cx-ctxmenu-t' }, ctxMenu.kind === 'avatar' ? (avatar?.name || '数字人') : (bg?.name || '背景')),
1408
+ ctxMenu.kind === 'avatar' ? [
1409
+ h('button', { key: 'up', onClick: () => { setCtxMenu(null); avatarImgRef.current && avatarImgRef.current.click(); } }, T('上传头像照片')),
1410
+ h('button', { key: 'edit', onClick: () => { setCtxMenu(null); setAvatarEditOpen(true); } }, T('修改分身…')),
1411
+ h('button', { key: 'sw', onClick: () => { setCtxMenu(null); setPick('avatar'); } }, T('切换分身')),
1412
+ h('button', { key: 'new', onClick: () => { setCtxMenu(null); onCreateAvatar(); } }, T('新建分身…')),
1413
+ ] : [
1414
+ h('button', { key: 'up', onClick: () => { setCtxMenu(null); bgImgRef.current && bgImgRef.current.click(); } }, T('上传背景照片')),
1415
+ h('button', { key: 'cfg', onClick: () => { setCtxMenu(null); setSceneOpen(true); } }, T('赛博世界设置…')),
1416
+ h('button', { key: 'sw', onClick: () => { setCtxMenu(null); setPick('bg'); } }, T('切换赛博世界')),
1417
+ h('button', { key: 'new', onClick: () => { setCtxMenu(null); onCreateBg(); } }, T('新建赛博世界')),
1418
+ ]) : null,
1419
+ avatarEditOpen ? h(AvatarEditModal, { avatar, onClose: () => setAvatarEditOpen(false), onSave: (p) => { onUpdateAvatar(p); setAvatarEditOpen(false); } }) : null,
1420
+ sceneOpen ? h(SceneModal, { scene: bg, onClose: () => setSceneOpen(false), onSave: (p) => { onUpdateScene(p); setSceneOpen(false); } }) : null,
1421
+ pmForm ? h(PMModal, { kind: pmForm.kind, mode: pmForm.mode, item: pmForm.item, parent: pmForm.parent, node, avatar, onClose: () => setPmForm(null), onDone: async () => { setPmForm(null); await loadPm(); } }) : null);
1422
+ }