dsh-device-fleet 0.1.0-dev.3 → 0.1.0-dev.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,1682 @@
1
+ // Fleet approval panel (Client half).
2
+ // The trigger lives in the native sidebar footer action row (no overlap with
3
+ // content panels); the inbox opens as an intentional centered dialog.
4
+ // Data comes from the Host routes registered by src/host/approval-web.js.
5
+ window.__ModuleLoader__.load({
6
+ id: 'dsh-device-fleet',
7
+ factory(require) {
8
+ const React = require('react');
9
+ const h = React.createElement;
10
+ const { useEffect, useState } = React;
11
+
12
+ const POLL_MS = 2500;
13
+ const store = {
14
+ items: [],
15
+ open: false,
16
+ listeners: new Set(),
17
+ };
18
+ let pollTimer = null;
19
+ let polling = false;
20
+
21
+ const notify = () => {
22
+ for (const listener of store.listeners) listener();
23
+ };
24
+
25
+ const setItems = (items) => {
26
+ const next = Array.isArray(items) ? items : [];
27
+ if (next.length === store.items.length && next.every((item, index) => item.id === store.items[index].id)) return;
28
+ store.items = next;
29
+ notify();
30
+ };
31
+
32
+ const startPolling = () => {
33
+ if (polling) return () => {};
34
+ polling = true;
35
+ const tick = async () => {
36
+ try {
37
+ const response = await fetch('/device-fleet/approvals', { headers: { accept: 'application/json' } });
38
+ if (!response.ok) throw new Error('HTTP ' + response.status);
39
+ const data = await response.json();
40
+ setItems(data.approvals);
41
+ } catch {
42
+ setItems([]);
43
+ }
44
+ if (polling) pollTimer = setTimeout(tick, POLL_MS);
45
+ };
46
+ tick();
47
+ return () => {
48
+ polling = false;
49
+ if (pollTimer) clearTimeout(pollTimer);
50
+ pollTimer = null;
51
+ };
52
+ };
53
+
54
+ const useStore = () => {
55
+ const [, setVersion] = useState(0);
56
+ useEffect(() => {
57
+ const listener = () => setVersion((value) => value + 1);
58
+ store.listeners.add(listener);
59
+ return () => store.listeners.delete(listener);
60
+ }, []);
61
+ return store;
62
+ };
63
+
64
+ const decide = async (id, action, scope) => {
65
+ try {
66
+ await fetch('/device-fleet/approvals/decide', {
67
+ method: 'POST',
68
+ headers: { 'content-type': 'application/json' },
69
+ body: JSON.stringify({ id, action, ...(scope ? { scope } : {}) }),
70
+ });
71
+ } finally {
72
+ store.items = store.items.filter((item) => item.id !== id);
73
+ notify();
74
+ }
75
+ };
76
+
77
+ const dialogStyle = {
78
+ position: 'fixed',
79
+ left: '50%',
80
+ top: '12vh',
81
+ transform: 'translateX(-50%)',
82
+ width: 'min(440px, calc(100vw - 32px))',
83
+ maxHeight: '70vh',
84
+ overflow: 'auto',
85
+ zIndex: 950,
86
+ background: 'var(--dsw-alias-surface-raised, #1f1f1f)',
87
+ color: 'var(--dsw-alias-text, #e8e8e8)',
88
+ border: '1px solid var(--dsw-alias-border, #3a3a3a)',
89
+ borderRadius: 12,
90
+ boxShadow: '0 18px 48px rgba(0,0,0,.45)',
91
+ padding: 14,
92
+ font: '13px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif',
93
+ };
94
+
95
+ const smallButton = (label, onClick, tone, disabled) =>
96
+ h(
97
+ 'button',
98
+ {
99
+ type: 'button',
100
+ disabled: disabled === true,
101
+ onClick,
102
+ style: {
103
+ padding: '3px 9px',
104
+ borderRadius: 6,
105
+ border: '1px solid ' + (tone === 'allow' ? 'var(--dsw-alias-accent, #2f6fed)' : 'var(--dsw-alias-border, #3a3a3a)'),
106
+ background: tone === 'allow' ? 'var(--dsw-alias-accent, #2f6fed)' : 'transparent',
107
+ color: 'var(--dsw-alias-text, #e8e8e8)',
108
+ cursor: disabled === true ? 'default' : 'pointer',
109
+ opacity: disabled === true ? 0.55 : 1,
110
+ fontSize: 12,
111
+ },
112
+ },
113
+ label,
114
+ );
115
+
116
+ function Trigger({ wide }) {
117
+ const current = useStore();
118
+ const count = current.items.length;
119
+ const expanded = wide !== false;
120
+ return h(
121
+ 'button',
122
+ {
123
+ type: 'button',
124
+ title: count > 0 ? 'Fleet 审批:' + count + ' 条待处理' : 'Fleet 审批',
125
+ 'aria-label': 'Fleet 审批' + (count > 0 ? '(' + count + ' 条待处理)' : ''),
126
+ onClick: () => {
127
+ store.open = !store.open;
128
+ notify();
129
+ },
130
+ style: {
131
+ display: 'flex',
132
+ alignItems: 'center',
133
+ gap: 6,
134
+ padding: expanded ? '5px 10px' : '5px 7px',
135
+ borderRadius: 8,
136
+ border: '1px solid ' + (count > 0 ? 'var(--dsw-alias-accent, #2f6fed)' : 'var(--dsw-alias-border, #3a3a3a)'),
137
+ background: count > 0 ? 'var(--dsw-alias-accent, #2f6fed)' : 'transparent',
138
+ color: 'var(--dsw-alias-text, #e8e8e8)',
139
+ cursor: 'pointer',
140
+ fontSize: 12,
141
+ whiteSpace: 'nowrap',
142
+ },
143
+ },
144
+ h('span', { 'aria-hidden': true }, '🛰️'),
145
+ expanded ? h('span', null, count > 0 ? '审批 ' + count : '审批') : null,
146
+ );
147
+ }
148
+
149
+ function Panel() {
150
+ const current = useStore();
151
+ useEffect(() => {
152
+ if (!current.open) return undefined;
153
+ const onKey = (event) => {
154
+ if (event.key === 'Escape') {
155
+ store.open = false;
156
+ notify();
157
+ }
158
+ };
159
+ window.addEventListener('keydown', onKey);
160
+ return () => window.removeEventListener('keydown', onKey);
161
+ }, [current.open]);
162
+ if (!current.open) return null;
163
+ return h(
164
+ 'div',
165
+ {
166
+ style: { position: 'fixed', inset: 0, zIndex: 940, background: 'rgba(0,0,0,.35)' },
167
+ onClick: () => {
168
+ store.open = false;
169
+ notify();
170
+ },
171
+ role: 'presentation',
172
+ },
173
+ h(
174
+ 'div',
175
+ { style: dialogStyle, role: 'dialog', 'aria-label': 'Fleet 待审批请求', onClick: (event) => event.stopPropagation() },
176
+ h(
177
+ 'div',
178
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 } },
179
+ h('strong', null, '待审批请求'),
180
+ h(
181
+ 'button',
182
+ {
183
+ type: 'button',
184
+ onClick: () => {
185
+ store.open = false;
186
+ notify();
187
+ },
188
+ style: { background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: 12, opacity: 0.8 },
189
+ },
190
+ '收起',
191
+ ),
192
+ ),
193
+ current.items.length === 0
194
+ ? h('div', { style: { opacity: 0.7 } }, '暂无待审批请求')
195
+ : current.items.map((item) =>
196
+ h(
197
+ 'div',
198
+ { key: item.id, style: { borderTop: '1px solid var(--dsw-alias-border, #333)', padding: '9px 0' } },
199
+ h('div', { style: { fontWeight: 600 } }, (item.capability || 'tool') + (item.tool ? ' / ' + item.tool : '')),
200
+ h('div', { style: { opacity: 0.75, fontSize: 12 } }, '来自 ' + item.fromDeviceId + ' · ' + Math.max(0, Math.round(item.expiresInMs / 1000)) + 's 后超时'),
201
+ item.reason ? h('div', { style: { opacity: 0.75, fontSize: 12, marginTop: 2 } }, item.reason) : null,
202
+ h('div', { style: { fontFamily: 'ui-monospace, monospace', fontSize: 11, margin: '5px 0', opacity: 0.8, wordBreak: 'break-all' } }, item.argsSummary),
203
+ h(
204
+ 'div',
205
+ { style: { display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' } },
206
+ smallButton('允许一次', () => decide(item.id, 'approve'), 'allow'),
207
+ smallButton('允许 15 分钟', () => decide(item.id, 'approve', 'ttl'), 'allow'),
208
+ smallButton('拒绝', () => decide(item.id, 'deny'), 'deny'),
209
+ ),
210
+ ),
211
+ ),
212
+ ),
213
+ );
214
+ }
215
+
216
+ const cardStyle = {
217
+ border: '1px solid var(--dsw-alias-border, #3a3a3a)',
218
+ borderRadius: 10,
219
+ padding: 12,
220
+ background: 'var(--dsw-alias-surface-raised, #1f1f1f)',
221
+ marginBottom: 10,
222
+ };
223
+ const sectionTitle = { fontSize: 12, opacity: 0.65, textTransform: 'uppercase', letterSpacing: '.04em', marginBottom: 6 };
224
+ const pill = (text, tone) =>
225
+ h(
226
+ 'span',
227
+ {
228
+ style: {
229
+ display: 'inline-block',
230
+ padding: '1px 7px',
231
+ borderRadius: 999,
232
+ fontSize: 11,
233
+ border: '1px solid ' + (tone === 'ok' ? 'var(--dsw-alias-accent, #2f6fed)' : 'var(--dsw-alias-border, #3a3a3a)'),
234
+ color: tone === 'ok' ? 'var(--dsw-alias-accent, #2f6fed)' : 'inherit',
235
+ opacity: tone === 'muted' ? 0.7 : 1,
236
+ marginRight: 6,
237
+ },
238
+ },
239
+ text,
240
+ );
241
+
242
+ const fmtTime = (value) => {
243
+ try {
244
+ return new Date(value).toLocaleTimeString();
245
+ } catch {
246
+ return '';
247
+ }
248
+ };
249
+
250
+ const postJson = async (url, body) => {
251
+ const response = await fetch(url, {
252
+ method: 'POST',
253
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
254
+ body: JSON.stringify(body ?? {}),
255
+ });
256
+ const data = await response.json().catch(() => ({}));
257
+ if (!response.ok) throw new Error(data.error ?? 'HTTP ' + response.status);
258
+ return data;
259
+ };
260
+
261
+ function FleetIcon({ size = 20, active = false }) {
262
+ return h(
263
+ 'svg',
264
+ {
265
+ width: size,
266
+ height: size,
267
+ viewBox: '0 0 24 24',
268
+ fill: 'none',
269
+ stroke: 'currentColor',
270
+ strokeWidth: 1.8,
271
+ strokeLinecap: 'round',
272
+ 'aria-hidden': true,
273
+ style: { opacity: active ? 1 : 0.75 },
274
+ },
275
+ h('circle', { cx: 12, cy: 12, r: 8.5 }),
276
+ h('ellipse', { cx: 12, cy: 12, rx: 3.6, ry: 8.5 }),
277
+ h('path', { d: 'M3.8 12h16.4' }),
278
+ );
279
+ }
280
+
281
+ function FleetConsole() {
282
+ const [tab, setTab] = useState('devices');
283
+ const [overview, setOverview] = useState(null);
284
+ const [rooms, setRooms] = useState([]);
285
+ const [selectedRoom, setSelectedRoom] = useState(null);
286
+ const [roomDetail, setRoomDetail] = useState(null);
287
+ const [roomError, setRoomError] = useState('');
288
+ const [error, setError] = useState('');
289
+ const [actionStatus, setActionStatus] = useState('');
290
+ const [inviteCode, setInviteCode] = useState('');
291
+ const [connectHost, setConnectHost] = useState('192.168.137.1');
292
+ const [connectPort, setConnectPort] = useState(0);
293
+ const [sendText, setSendText] = useState('');
294
+ const [sendTo, setSendTo] = useState([]);
295
+ const [taskTitle, setTaskTitle] = useState('');
296
+ const [taskOwner, setTaskOwner] = useState('');
297
+
298
+ useEffect(() => {
299
+ let disposed = false;
300
+ const tick = async () => {
301
+ try {
302
+ const response = await fetch('/device-fleet/overview', { headers: { accept: 'application/json' } });
303
+ if (!response.ok) throw new Error('HTTP ' + response.status);
304
+ const data = await response.json();
305
+ if (!disposed) {
306
+ setOverview(data);
307
+ setRooms(Array.isArray(data.rooms) ? data.rooms : []);
308
+ setError('');
309
+ }
310
+ } catch (problem) {
311
+ if (!disposed) setError(String(problem.message ?? problem));
312
+ }
313
+ };
314
+ tick();
315
+ const timer = setInterval(tick, 3000);
316
+ return () => {
317
+ disposed = true;
318
+ clearInterval(timer);
319
+ };
320
+ }, []);
321
+
322
+ useEffect(() => {
323
+ if (!selectedRoom) {
324
+ setRoomDetail(null);
325
+ return undefined;
326
+ }
327
+ let disposed = false;
328
+ const load = async () => {
329
+ try {
330
+ const response = await fetch('/device-fleet/room?id=' + encodeURIComponent(selectedRoom), { headers: { accept: 'application/json' } });
331
+ if (!response.ok) throw new Error('HTTP ' + response.status);
332
+ const data = await response.json();
333
+ if (!disposed) {
334
+ setRoomDetail(data.room ?? null);
335
+ setRoomError('');
336
+ }
337
+ } catch (problem) {
338
+ if (!disposed) {
339
+ setRoomDetail(null);
340
+ setRoomError(String(problem.message ?? problem));
341
+ }
342
+ }
343
+ };
344
+ load();
345
+ const timer = setInterval(load, 3000);
346
+ return () => {
347
+ disposed = true;
348
+ clearInterval(timer);
349
+ };
350
+ }, [selectedRoom]);
351
+
352
+ const decide = async (id, action, scope) => {
353
+ await fetch('/device-fleet/approvals/decide', {
354
+ method: 'POST',
355
+ headers: { 'content-type': 'application/json' },
356
+ body: JSON.stringify({ id, action, ...(scope ? { scope } : {}) }),
357
+ });
358
+ setOverview((current) => (current ? { ...current, approvals: (current.approvals ?? []).filter((item) => item.id !== id) } : current));
359
+ };
360
+
361
+ const runAction = async (label, action) => {
362
+ setActionStatus(label + '…');
363
+ try {
364
+ await action();
365
+ setActionStatus(label + '成功');
366
+ } catch (problem) {
367
+ setActionStatus(label + '失败:' + String(problem.message ?? problem));
368
+ }
369
+ };
370
+ const sendRoomMessage = () =>
371
+ runAction('发送消息', async () => {
372
+ if (!selectedRoom) throw new Error('请先选择房间');
373
+ if (sendText.trim() === '') throw new Error('消息不能为空');
374
+ await postJson('/device-fleet/room/send', { roomId: selectedRoom, to: sendTo, text: sendText });
375
+ setSendText('');
376
+ });
377
+ const addRoomTask = () =>
378
+ runAction('添加任务', async () => {
379
+ if (!selectedRoom) throw new Error('请先选择房间');
380
+ if (taskTitle.trim() === '') throw new Error('任务标题不能为空');
381
+ await postJson('/device-fleet/room/task', { roomId: selectedRoom, action: 'add', title: taskTitle, owner: taskOwner });
382
+ setTaskTitle('');
383
+ });
384
+ const updateRoomTask = (id, status) =>
385
+ runAction('更新任务', async () => {
386
+ await postJson('/device-fleet/room/task', { roomId: selectedRoom, action: 'update', id, status });
387
+ });
388
+ const closeCurrentRoom = () =>
389
+ runAction('关闭房间', async () => {
390
+ await postJson('/device-fleet/room/close', { roomId: selectedRoom });
391
+ setSelectedRoom(null);
392
+ });
393
+ const removeRoomMember = (memberId) =>
394
+ runAction('移除成员', async () => {
395
+ await postJson('/device-fleet/room/member/remove', { roomId: selectedRoom, memberId });
396
+ });
397
+ const createInviteCode = () =>
398
+ runAction('生成邀请码', async () => {
399
+ const data = await postJson('/device-fleet/pair/invite');
400
+ setInviteCode(data.code ?? '');
401
+ });
402
+ const connectPeer = () =>
403
+ runAction('连接设备', async () => {
404
+ const port = Number(connectPort) || overview?.direct?.port || 0;
405
+ const data = await postJson('/device-fleet/pair/connect', { host: connectHost, port });
406
+ setActionStatus('已连接 ' + String(data.result?.deviceId ?? ''));
407
+ });
408
+
409
+ const tabs = [
410
+ ['devices', '设备'],
411
+ ['rooms', '房间'],
412
+ ['approvals', '审批'],
413
+ ];
414
+ const header = h(
415
+ 'div',
416
+ { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 } },
417
+ h('strong', { style: { fontSize: 15 } }, '设备联邦'),
418
+ overview ? pill('本机 ' + (overview.name ?? '') + ' · ' + overview.pluginVersion, 'muted') : null,
419
+ actionStatus ? h('span', { style: { fontSize: 12, opacity: 0.7 } }, actionStatus) : null,
420
+ h('span', { style: { flex: 1 } }),
421
+ ...tabs.map(([id, label]) =>
422
+ h(
423
+ 'button',
424
+ {
425
+ key: id,
426
+ type: 'button',
427
+ onClick: () => setTab(id),
428
+ style: {
429
+ padding: '4px 10px',
430
+ borderRadius: 999,
431
+ border: '1px solid ' + (tab === id ? 'var(--dsw-alias-accent, #2f6fed)' : 'var(--dsw-alias-border, #3a3a3a)'),
432
+ background: tab === id ? 'var(--dsw-alias-accent, #2f6fed)' : 'transparent',
433
+ color: 'inherit',
434
+ cursor: 'pointer',
435
+ fontSize: 12,
436
+ },
437
+ },
438
+ label + (id === 'approvals' && (overview?.approvals?.length ?? 0) > 0 ? ' ' + overview.approvals.length : ''),
439
+ ),
440
+ ),
441
+ );
442
+
443
+ if (!overview) {
444
+ return h('div', { style: { padding: 16 } }, header, h('div', { style: { opacity: 0.7 } }, error ? '加载失败:' + error : '加载中…'));
445
+ }
446
+
447
+ let body = null;
448
+ if (tab === 'devices') {
449
+ const connected = new Set(Array.isArray(overview.connected) ? overview.connected : []);
450
+ body = h(
451
+ 'div',
452
+ null,
453
+ h('div', { style: cardStyle },
454
+ h('div', { style: sectionTitle }, '本机'),
455
+ h('div', null, overview.name + ' · ' + overview.deviceId),
456
+ h('div', { style: { opacity: 0.75, fontSize: 12, marginTop: 4 } },
457
+ '直连 ' + (overview.direct ? overview.direct.host + ':' + overview.direct.port : '未监听') +
458
+ ' · relay ' + (overview.relay?.online ? '在线' : '离线') +
459
+ ' · 发现 ' + ((overview.discovery?.peers ?? []).length) + ' 个'),
460
+ ),
461
+ h('div', { style: cardStyle },
462
+ h('div', { style: sectionTitle }, 'Fleet 成员(' + (overview.roster?.length ?? 0) + ' 台已配对)'),
463
+ (overview.roster ?? []).length === 0
464
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '还没有配对设备;在任一台设备上生成邀请码完成配对。')
465
+ : (overview.roster ?? []).map((member) =>
466
+ h('div', { key: member.deviceId, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
467
+ h('span', { style: { flex: 1 } }, member.name + ' · ' + member.deviceId.slice(0, 8) + '…'),
468
+ connected.has(member.deviceId) ? pill('在线', 'ok') : pill('离线', 'muted'),
469
+ ),
470
+ ),
471
+ ),
472
+ (overview.peerHints ?? []).length > 0
473
+ ? h('div', { style: cardStyle },
474
+ h('div', { style: sectionTitle }, '回连地址'),
475
+ ...overview.peerHints.map((hint, index) =>
476
+ h('div', { key: index, style: { fontSize: 12, fontFamily: 'ui-monospace, monospace', opacity: 0.8 } }, hint.host + ':' + hint.port),
477
+ ),
478
+ )
479
+ : null,
480
+ h('div', { style: cardStyle },
481
+ h('div', { style: sectionTitle }, '配对与连接'),
482
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
483
+ smallButton('生成邀请码', () => void createInviteCode(), 'allow'),
484
+ inviteCode
485
+ ? h('button', {
486
+ type: 'button',
487
+ onClick: () => {
488
+ try {
489
+ void navigator.clipboard?.writeText(inviteCode)
490
+ } catch {
491
+ /* ignore */
492
+ }
493
+ },
494
+ style: { background: 'transparent', border: 'none', color: 'var(--dsw-alias-accent, #2f6fed)', cursor: 'pointer', fontSize: 12 },
495
+ }, '复制')
496
+ : null,
497
+ ),
498
+ inviteCode
499
+ ? h('pre', { style: { fontSize: 11, fontFamily: 'ui-monospace, monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all', background: 'var(--dsw-alias-bg-layer-2, #262626)', padding: 8, borderRadius: 6, maxHeight: 140, overflow: 'auto' } }, inviteCode)
500
+ : null,
501
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 10, flexWrap: 'wrap' } },
502
+ h('input', {
503
+ type: 'text',
504
+ value: connectHost,
505
+ onChange: (event) => setConnectHost(event.target.value),
506
+ placeholder: 'host',
507
+ style: { ...controlStyleWidth(180) },
508
+ }),
509
+ h('input', {
510
+ type: 'number',
511
+ value: connectPort || overview?.direct?.port || 0,
512
+ onChange: (event) => setConnectPort(Number(event.target.value || 0)),
513
+ placeholder: 'port',
514
+ style: { ...controlStyleWidth(110) },
515
+ }),
516
+ smallButton('连接', () => void connectPeer(), 'normal'),
517
+ ),
518
+ ),
519
+ );
520
+ } else if (tab === 'rooms') {
521
+ body = h(
522
+ 'div',
523
+ { style: { display: 'flex', gap: 14, alignItems: 'flex-start', flexWrap: 'wrap' } },
524
+ h('div', { style: { flex: '1 1 260px', minWidth: 240 } },
525
+ h('div', { style: sectionTitle }, '房间(' + rooms.length + ')'),
526
+ rooms.length === 0
527
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '还没有房间。让 agent 在当前会话执行 room_create 升级为 Room Host。')
528
+ : rooms.map((room) =>
529
+ h(
530
+ 'button',
531
+ {
532
+ key: room.roomId,
533
+ type: 'button',
534
+ onClick: () => setSelectedRoom(room.roomId === selectedRoom ? null : room.roomId),
535
+ style: {
536
+ ...cardStyle,
537
+ display: 'block',
538
+ width: '100%',
539
+ textAlign: 'left',
540
+ cursor: 'pointer',
541
+ borderColor: selectedRoom === room.roomId ? 'var(--dsw-alias-accent, #2f6fed)' : undefined,
542
+ marginBottom: 8,
543
+ },
544
+ },
545
+ h('div', { style: { fontWeight: 600 } }, 'Room ' + room.roomId.slice(-8)),
546
+ h('div', { style: { fontSize: 12, opacity: 0.7, marginTop: 3 } },
547
+ room.members.length + ' 名成员 · ' + room.messageCount + ' 条消息 · ' + room.tasks + ' 个任务'),
548
+ ),
549
+ ),
550
+ ),
551
+ h('div', { style: { flex: '2 1 420px', minWidth: 320 } }, selectedRoom ? (roomError && !roomDetail ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '房间加载失败:' + roomError) : renderRoomDetail(roomDetail, {
552
+ sendText,
553
+ setSendText,
554
+ sendTo,
555
+ setSendTo,
556
+ sendRoomMessage,
557
+ taskTitle,
558
+ setTaskTitle,
559
+ taskOwner,
560
+ setTaskOwner,
561
+ addRoomTask,
562
+ updateRoomTask,
563
+ closeCurrentRoom,
564
+ removeRoomMember,
565
+ })) : h('div', { style: { opacity: 0.65, fontSize: 12 } }, '选择一个房间查看成员、时间线与看板。')),
566
+ );
567
+ } else {
568
+ const approvals = overview.approvals ?? [];
569
+ const history = overview.approvalHistory ?? [];
570
+ body = h(
571
+ 'div',
572
+ null,
573
+ h('div', { style: cardStyle },
574
+ h('div', { style: sectionTitle }, '待审批请求(' + approvals.length + ')'),
575
+ approvals.length === 0
576
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无待审批请求')
577
+ : approvals.map((item) =>
578
+ h('div', { key: item.id, style: { borderTop: '1px solid var(--dsw-alias-border, #333)', padding: '9px 0' } },
579
+ h('div', { style: { fontWeight: 600 } }, (item.capability || 'tool') + (item.tool ? ' / ' + item.tool : '')),
580
+ h('div', { style: { opacity: 0.75, fontSize: 12 } }, '来自 ' + item.fromDeviceId + ' · ' + Math.max(0, Math.round(item.expiresInMs / 1000)) + 's 后超时'),
581
+ item.reason ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, item.reason) : null,
582
+ h('div', { style: { display: 'flex', gap: 6, marginTop: 5, flexWrap: 'wrap' } },
583
+ smallButton('允许一次', () => decide(item.id, 'approve'), 'allow'),
584
+ smallButton('允许 15 分钟', () => decide(item.id, 'approve', 'ttl'), 'allow'),
585
+ smallButton('拒绝', () => decide(item.id, 'deny'), 'deny'),
586
+ ),
587
+ ),
588
+ ),
589
+ ),
590
+ h('div', { style: cardStyle },
591
+ h('div', { style: sectionTitle }, '审批历史(最近 ' + history.length + ' 条)'),
592
+ history.length === 0
593
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无历史')
594
+ : history.slice(0, 20).map((item) =>
595
+ h('div', { key: item.id, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0', fontSize: 12, borderTop: '1px solid var(--dsw-alias-border, #333)' } },
596
+ pill(item.outcome, item.outcome === 'approved' ? 'ok' : 'muted'),
597
+ h('span', { style: { flex: 1 } }, (item.capability || 'tool') + (item.tool ? ' / ' + item.tool : '') + ' · ' + item.fromDeviceId),
598
+ h('span', { style: { opacity: 0.55 } }, fmtTime(item.resolvedAt)),
599
+ ),
600
+ ),
601
+ ),
602
+ );
603
+ }
604
+
605
+ return h('div', { style: { padding: '14px 16px', height: '100%', overflow: 'auto', boxSizing: 'border-box' } }, header, body);
606
+ }
607
+
608
+ function renderRoomDetail(detail, ctl) {
609
+ if (!detail) return h('div', { style: { opacity: 0.65, fontSize: 12 } }, '加载房间…');
610
+ const memberIds = detail.members.map((member) => member.memberId);
611
+ const participantIds = memberIds.filter((id) => !id.startsWith('host:'));
612
+ const defaultRecipients = participantIds.length > 0 ? participantIds : memberIds;
613
+ const recipientIds = Array.isArray(ctl.sendTo) && ctl.sendTo.length > 0 ? ctl.sendTo : defaultRecipients;
614
+ return h(
615
+ 'div',
616
+ null,
617
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 } },
618
+ h('div', { style: { ...sectionTitle, margin: 0, flex: 1 } }, '房间 ' + detail.roomId.slice(-8)),
619
+ smallButton('关闭房间', () => void ctl.closeCurrentRoom(), 'deny'),
620
+ ),
621
+ h('div', { style: cardStyle },
622
+ h('div', { style: sectionTitle }, '成员'),
623
+ ...detail.members.map((member) =>
624
+ h('div', { key: member.memberId, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '3px 0', fontSize: 12 } },
625
+ pill(member.role, member.role === 'host' ? 'ok' : 'muted'),
626
+ h('span', { style: { flex: 1 } }, member.name + ' · ' + member.memberId),
627
+ member.role === 'host' ? null : smallButton('移除', () => void ctl.removeRoomMember(member.memberId), 'deny'),
628
+ ),
629
+ ),
630
+ ),
631
+ h('div', { style: cardStyle },
632
+ h('div', { style: sectionTitle }, '发消息(以用户身份)'),
633
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px 14px', marginBottom: 6 } },
634
+ ...detail.members.map((member) =>
635
+ h('label', { key: member.memberId, style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 12 } },
636
+ h('input', {
637
+ type: 'checkbox',
638
+ checked: recipientIds.includes(member.memberId),
639
+ onChange: (event) => {
640
+ ctl.setSendTo((current) => {
641
+ const base = current.length > 0 ? current : defaultRecipients
642
+ return event.target.checked ? [...new Set([...base, member.memberId])] : base.filter((id) => id !== member.memberId)
643
+ })
644
+ },
645
+ }),
646
+ member.name,
647
+ ),
648
+ ),
649
+ ),
650
+ h('textarea', {
651
+ value: ctl.sendText,
652
+ onChange: (event) => ctl.setSendText(event.target.value),
653
+ rows: 2,
654
+ placeholder: '发送给房间成员(默认为 host)',
655
+ style: { width: '100%', boxSizing: 'border-box', padding: '6px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l1, #333)', background: 'var(--dsw-alias-bg-layer-2, #262626)', color: 'inherit', fontSize: 12, resize: 'vertical', fontFamily: 'inherit' },
656
+ }),
657
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 } },
658
+ h('span', { style: { flex: 1 } }),
659
+ smallButton('发送', () => void ctl.sendRoomMessage(), 'allow'),
660
+ ),
661
+ ),
662
+ h('div', { style: cardStyle },
663
+ h('div', { style: sectionTitle }, '看板(' + detail.tasks.length + ')'),
664
+ detail.tasks.length === 0
665
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无任务')
666
+ : detail.tasks.map((task) =>
667
+ h('div', { key: task.id, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', fontSize: 12 } },
668
+ pill(task.status, task.status === 'done' ? 'ok' : 'muted'),
669
+ h('span', { style: { flex: 1 } }, task.title + ' · owner ' + task.owner + (task.dependsOn.length ? ' · deps ' + task.dependsOn.length : '')),
670
+ h('select', {
671
+ value: task.status,
672
+ onChange: (event) => void ctl.updateRoomTask(task.id, event.target.value),
673
+ style: { height: 24, borderRadius: 5, border: '1px solid var(--dsw-alias-border-l1, #333)', background: 'var(--dsw-alias-bg-layer-2, #262626)', color: 'inherit', fontSize: 11 },
674
+ },
675
+ ...['pending', 'running', 'done', 'blocked'].map((status) => h('option', { key: status, value: status }, status)),
676
+ ),
677
+ ),
678
+ ),
679
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' } },
680
+ h('input', {
681
+ type: 'text',
682
+ value: ctl.taskTitle,
683
+ onChange: (event) => ctl.setTaskTitle(event.target.value),
684
+ placeholder: '新任务标题',
685
+ style: { ...controlStyleWidth(180), height: 26, fontSize: 12 },
686
+ }),
687
+ h('select', {
688
+ value: ctl.taskOwner,
689
+ onChange: (event) => ctl.setTaskOwner(event.target.value),
690
+ style: { ...controlStyleWidth(180), height: 26, fontSize: 12 },
691
+ },
692
+ h('option', { value: '' }, '选择 owner'),
693
+ ...detail.members.map((member) => h('option', { key: member.memberId, value: member.memberId }, member.name)),
694
+ ),
695
+ smallButton('添加任务', () => void ctl.addRoomTask(), 'allow'),
696
+ ),
697
+ ),
698
+ h('div', { style: cardStyle },
699
+ h('div', { style: sectionTitle }, '时间线'),
700
+ detail.messages.length === 0
701
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无消息')
702
+ : detail.messages.map((message) =>
703
+ h('div', { key: message.id, style: { padding: '5px 0', borderTop: '1px solid var(--dsw-alias-border, #333)' } },
704
+ h('div', { style: { fontSize: 11, opacity: 0.6 } }, '[' + fmtTime(message.createdAt) + '] ' + message.fromLabel + ' → ' + message.to.join(', ')),
705
+ h('div', { style: { whiteSpace: 'pre-wrap', marginTop: 2 } }, message.text),
706
+ ),
707
+ ),
708
+ ),
709
+ );
710
+ }
711
+
712
+ // ---- DSH-styled configuration page -------------------------------------
713
+ const CONTROL_WIDTH = 260
714
+ const rowStyle = {
715
+ display: 'flex',
716
+ alignItems: 'center',
717
+ gap: 16,
718
+ minHeight: 42,
719
+ padding: '5px 0',
720
+ borderBottom: '1px solid var(--dsw-alias-border-l1, #333)',
721
+ }
722
+ const rowLabelStyle = { flex: 1, fontSize: 13, color: 'var(--dsw-alias-label-primary, #e8e8e8)' }
723
+ const controlStyle = {
724
+ width: CONTROL_WIDTH,
725
+ boxSizing: 'border-box',
726
+ height: 30,
727
+ padding: '3px 8px',
728
+ borderRadius: 6,
729
+ border: '1px solid var(--dsw-alias-border-l1, #333)',
730
+ background: 'var(--dsw-alias-bg-layer-2, #262626)',
731
+ color: 'var(--dsw-alias-label-primary, #e8e8e8)',
732
+ fontSize: 13,
733
+ outline: 'none',
734
+ }
735
+ const controlStyleWidth = (width) => ({ ...controlStyle, width })
736
+ const selectStyle = {
737
+ ...controlStyle,
738
+ appearance: 'none',
739
+ WebkitAppearance: 'none',
740
+ paddingRight: 24,
741
+ cursor: 'pointer',
742
+ }
743
+ const textareaStyle = {
744
+ ...controlStyle,
745
+ width: '100%',
746
+ height: 'auto',
747
+ minHeight: 64,
748
+ resize: 'vertical',
749
+ padding: '6px 8px',
750
+ lineHeight: 1.5,
751
+ fontFamily: 'ui-monospace, monospace',
752
+ }
753
+ const sectionStyle = {
754
+ margin: '22px 0 4px',
755
+ fontSize: 13,
756
+ fontWeight: 600,
757
+ color: 'var(--dsw-alias-label-primary, #e8e8e8)',
758
+ }
759
+
760
+ function Row({ label, children, wide = false }) {
761
+ if (wide) {
762
+ return h('div', { style: { ...rowStyle, flexDirection: 'column', alignItems: 'stretch', gap: 6, padding: '8px 0' } },
763
+ h('div', { style: rowLabelStyle }, label),
764
+ h('div', { style: { width: '100%' } }, children),
765
+ )
766
+ }
767
+ return h('div', { style: rowStyle },
768
+ h('div', { style: rowLabelStyle }, label),
769
+ h('div', { style: { width: CONTROL_WIDTH } }, children),
770
+ )
771
+ }
772
+
773
+ function SelectControl({ value, options, onChange }) {
774
+ return h('select', {
775
+ value: value ?? '',
776
+ onChange: (event) => onChange(event.target.value),
777
+ style: selectStyle,
778
+ },
779
+ ...(options ?? []).map((option) => {
780
+ const entry = typeof option === 'string' ? { value: option, label: option } : option
781
+ return h('option', { key: entry.value, value: entry.value }, entry.label)
782
+ }),
783
+ )
784
+ }
785
+
786
+ function TextControl({ value, onChange, placeholder }) {
787
+ return h('input', {
788
+ type: 'text',
789
+ value: value ?? '',
790
+ placeholder: placeholder ?? '',
791
+ onChange: (event) => onChange(event.target.value),
792
+ style: controlStyle,
793
+ })
794
+ }
795
+
796
+ function NumberControl({ value, onChange }) {
797
+ return h('input', {
798
+ type: 'number',
799
+ value: value ?? 0,
800
+ onChange: (event) => onChange(Number(event.target.value || 0)),
801
+ style: controlStyle,
802
+ })
803
+ }
804
+
805
+ function ToggleControl({ value, onChange }) {
806
+ return h(SelectControl, {
807
+ value: value ? 'on' : 'off',
808
+ options: [{ value: 'on', label: '开启' }, { value: 'off', label: '关闭' }],
809
+ onChange: (next) => onChange(next === 'on'),
810
+ })
811
+ }
812
+
813
+ const CAPABILITY_ROWS = [
814
+ ['capRead', '读取文件'],
815
+ ['capList', '列目录'],
816
+ ['capGrep', '搜索内容'],
817
+ ['capFetch', '获取文件'],
818
+ ['capRun', '执行命令'],
819
+ ['capTool', '原始工具(遵循 DSH 审批)'],
820
+ ['capDelegate', '委派任务'],
821
+ ]
822
+
823
+ function configToDraft(config = {}, options = {}) {
824
+ const execution = config.execution ?? {}
825
+ const policy = config.policy ?? {}
826
+ const approvals = config.approvals ?? {}
827
+ const direct = config.direct ?? {}
828
+ const relay = config.relay ?? null
829
+ const cwd = execution.cwd ?? ''
830
+ const cwdInfo = options.cwd ?? {}
831
+ let cwdMode = 'custom'
832
+ if (cwd === '') cwdMode = 'default'
833
+ else if (cwdInfo.workspace && cwd === cwdInfo.workspace) cwdMode = 'workspace'
834
+ else if (cwdInfo.dshHome && cwd === cwdInfo.dshHome) cwdMode = 'dshHome'
835
+ else if (cwdInfo.defaultDirectory && cwd === cwdInfo.defaultDirectory) cwdMode = 'default'
836
+ return {
837
+ deviceName: config.deviceName ?? '',
838
+ directHost: direct.host ?? '0.0.0.0',
839
+ directPort: direct.port ?? 0,
840
+ discovery: config.discovery !== false,
841
+ relayMode: relay && (relay.host || relay.port) ? 'custom' : 'off',
842
+ relayHost: relay?.host ?? '',
843
+ relayPort: relay?.port ?? 0,
844
+ relayDataPort: relay?.dataPort ?? 0,
845
+ cwdMode,
846
+ customCwd: cwdMode === 'custom' ? cwd : '',
847
+ execPreset: execution.agentPreset ?? '',
848
+ execShellTool: execution.shellTool ?? '',
849
+ execApprovalTimeoutMs: execution.approvalTimeoutMs ?? 60000,
850
+ execDelegateTimeoutMs: execution.delegateTimeoutMs ?? 180000,
851
+ execSubagentProvider: execution.subagentProvider ?? 'spawn',
852
+ rootsText: (policy.allowedRoots ?? []).join('\n'),
853
+ capRead: policy.capabilities?.read ?? 'auto',
854
+ capList: policy.capabilities?.list ?? 'auto',
855
+ capGrep: policy.capabilities?.grep ?? 'auto',
856
+ capFetch: policy.capabilities?.fetch ?? 'auto',
857
+ capRun: policy.capabilities?.run ?? 'ask',
858
+ capTool: policy.capabilities?.tool ?? 'deny',
859
+ capDelegate: policy.capabilities?.delegate ?? 'ask',
860
+ policyApprovalTtlMs: policy.approvalTtlMs ?? 900000,
861
+ approvalsTtlMs: approvals.ttlMs ?? 60000,
862
+ approvalsMaxPending: approvals.maxPending ?? 50,
863
+ autoApproveAsk: config.autoApproveAsk === true,
864
+ promptSection: config.promptSection !== false,
865
+ simulation: approvals.simulation === true,
866
+ }
867
+ }
868
+
869
+ function draftToPatch(draft, options = {}) {
870
+ const cwdInfo = options.cwd ?? {}
871
+ let cwd = ''
872
+ if (draft.cwdMode === 'workspace') cwd = cwdInfo.workspace ?? ''
873
+ else if (draft.cwdMode === 'dshHome') cwd = cwdInfo.dshHome ?? ''
874
+ else if (draft.cwdMode === 'default') cwd = cwdInfo.defaultDirectory || cwdInfo.workspace || ''
875
+ else if (draft.cwdMode === 'custom') cwd = draft.customCwd ?? ''
876
+ return {
877
+ deviceName: draft.deviceName,
878
+ direct: { host: draft.directHost, port: draft.directPort },
879
+ discovery: draft.discovery,
880
+ relay: draft.relayMode === 'custom'
881
+ ? { host: draft.relayHost, port: draft.relayPort, dataPort: draft.relayDataPort }
882
+ : null,
883
+ execution: {
884
+ cwd,
885
+ agentPreset: draft.execPreset,
886
+ shellTool: draft.execShellTool,
887
+ approvalTimeoutMs: draft.execApprovalTimeoutMs,
888
+ delegateTimeoutMs: draft.execDelegateTimeoutMs,
889
+ subagentProvider: draft.execSubagentProvider,
890
+ },
891
+ policy: {
892
+ allowedRoots: draft.rootsText.split('\n').map((item) => item.trim()).filter(Boolean),
893
+ capabilities: {
894
+ read: draft.capRead,
895
+ list: draft.capList,
896
+ grep: draft.capGrep,
897
+ fetch: draft.capFetch,
898
+ run: draft.capRun,
899
+ tool: draft.capTool,
900
+ delegate: draft.capDelegate,
901
+ },
902
+ approvalTtlMs: draft.policyApprovalTtlMs,
903
+ },
904
+ approvals: {
905
+ ttlMs: draft.approvalsTtlMs,
906
+ maxPending: draft.approvalsMaxPending,
907
+ simulation: draft.simulation,
908
+ },
909
+ autoApproveAsk: draft.autoApproveAsk,
910
+ promptSection: draft.promptSection,
911
+ }
912
+ }
913
+
914
+ function FleetConfig() {
915
+ const [draft, setDraft] = useState(null)
916
+ const [options, setOptions] = useState({})
917
+ const [status, setStatus] = useState('')
918
+ const [busy, setBusy] = useState(false)
919
+ const [library, setLibrary] = useState([])
920
+ const [avatarStatus, setAvatarStatus] = useState('')
921
+
922
+ const loadLibrary = async () => {
923
+ try {
924
+ const response = await fetch('/device-fleet/avatar-library', { headers: { accept: 'application/json' } })
925
+ if (!response.ok) return
926
+ const data = await response.json()
927
+ setLibrary(Array.isArray(data.items) ? data.items : [])
928
+ } catch {
929
+ /* ignore */
930
+ }
931
+ }
932
+
933
+ const uploadAvatars = async (event) => {
934
+ const files = Array.from(event.target.files ?? [])
935
+ if (files.length === 0) return
936
+ setAvatarStatus('上传中…')
937
+ try {
938
+ for (const file of files) {
939
+ const dataUrl = await new Promise((resolve, reject) => {
940
+ const reader = new FileReader()
941
+ reader.onload = () => resolve(reader.result)
942
+ reader.onerror = () => reject(new Error('读取文件失败'))
943
+ reader.readAsDataURL(file)
944
+ })
945
+ await postJson('/device-fleet/avatar-library', { name: file.name, dataUrl })
946
+ }
947
+ await loadLibrary()
948
+ setAvatarStatus('已加入头像库')
949
+ } catch (problem) {
950
+ setAvatarStatus('上传失败:' + String(problem.message ?? problem))
951
+ } finally {
952
+ event.target.value = ''
953
+ }
954
+ }
955
+
956
+ const removeAvatar = async (name) => {
957
+ try {
958
+ await postJson('/device-fleet/avatar-library/remove', { name })
959
+ await loadLibrary()
960
+ } catch (problem) {
961
+ setAvatarStatus('删除失败:' + String(problem.message ?? problem))
962
+ }
963
+ }
964
+
965
+ useEffect(() => {
966
+ let disposed = false
967
+ const load = async () => {
968
+ try {
969
+ const [configResponse, optionsResponse] = await Promise.all([
970
+ fetch('/device-fleet/config', { headers: { accept: 'application/json' } }),
971
+ fetch('/device-fleet/options', { headers: { accept: 'application/json' } }),
972
+ ])
973
+ const configData = await configResponse.json()
974
+ const optionsData = optionsResponse.ok ? await optionsResponse.json() : {}
975
+ if (disposed) return
976
+ setOptions(optionsData ?? {})
977
+ setDraft(configToDraft(configData.config ?? {}, optionsData ?? {}))
978
+ } catch (problem) {
979
+ if (!disposed) setStatus('加载失败')
980
+ }
981
+ }
982
+ void load()
983
+ void loadLibrary()
984
+ return () => { disposed = true }
985
+ }, [])
986
+
987
+ if (!draft) return h('div', { style: { padding: 8, color: 'var(--dsw-alias-label-secondary, #999)' } }, status || '加载中…')
988
+
989
+ const set = (key, value) => setDraft((current) => ({ ...current, [key]: value }))
990
+ const save = async () => {
991
+ setBusy(true)
992
+ setStatus('')
993
+ try {
994
+ const response = await fetch('/device-fleet/config', {
995
+ method: 'POST',
996
+ headers: { 'content-type': 'application/json' },
997
+ body: JSON.stringify(draftToPatch(draft, options)),
998
+ })
999
+ const data = await response.json()
1000
+ if (!response.ok) throw new Error(data.error ?? 'HTTP ' + response.status)
1001
+ setDraft(configToDraft(data.config ?? {}, options))
1002
+ setStatus('已保存')
1003
+ } catch (problem) {
1004
+ setStatus('保存失败:' + String(problem.message ?? problem))
1005
+ } finally {
1006
+ setBusy(false)
1007
+ }
1008
+ }
1009
+
1010
+ const cwdOptions = [
1011
+ { value: 'workspace', label: 'Device Fleet 工作区' },
1012
+ { value: 'default', label: 'DSH 默认目录' },
1013
+ { value: 'dshHome', label: 'DSH 主目录' },
1014
+ { value: 'custom', label: '自定义路径' },
1015
+ ]
1016
+ const presetOptions = [{ value: '', label: '默认' }, ...((options.presets ?? []).map((preset) => ({ value: preset.id, label: preset.label })))]
1017
+ const shellOptions = (options.shells ?? ['pwsh']).map((shell) => ({ value: shell, label: shell }))
1018
+ const providerOptions = (options.subagentProviders ?? ['spawn']).map((provider) => ({ value: provider, label: provider }))
1019
+ const hostOptions = (options.addresses ?? ['0.0.0.0']).map((address) => ({ value: address, label: address }))
1020
+ const modeOptions = [{ value: 'auto', label: '自动' }, { value: 'ask', label: '询问' }, { value: 'deny', label: '拒绝' }]
1021
+
1022
+ return h('div', { style: { maxWidth: 760, padding: '4px 2px 28px', color: 'var(--dsw-alias-label-primary, #e8e8e8)' } },
1023
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 } },
1024
+ h('div', { style: { fontSize: 15, fontWeight: 600 } }, 'Device Fleet'),
1025
+ h('span', { style: { flex: 1 } }),
1026
+ status ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #999)' } }, status) : null,
1027
+ h('button', {
1028
+ type: 'button',
1029
+ disabled: busy,
1030
+ onClick: () => void save(),
1031
+ style: {
1032
+ height: 30,
1033
+ padding: '0 14px',
1034
+ borderRadius: 6,
1035
+ border: '1px solid var(--dsw-alias-brand-primary, #2f6fed)',
1036
+ background: '#2f6fed',
1037
+ color: '#fff',
1038
+ fontSize: 13,
1039
+ cursor: busy ? 'default' : 'pointer',
1040
+ opacity: busy ? 0.6 : 1,
1041
+ },
1042
+ }, busy ? '保存中' : '保存'),
1043
+ ),
1044
+ h('div', { style: sectionStyle }, '设备'),
1045
+ h(Row, { label: '名称' },
1046
+ h(TextControl, { value: draft.deviceName, onChange: (value) => set('deviceName', value) }),
1047
+ ),
1048
+ h('div', { style: sectionStyle }, '网络'),
1049
+ h(Row, { label: '直连地址' },
1050
+ h(SelectControl, { value: draft.directHost, options: hostOptions, onChange: (value) => set('directHost', value) }),
1051
+ ),
1052
+ h(Row, { label: '直连端口' },
1053
+ h(NumberControl, { value: draft.directPort, onChange: (value) => set('directPort', value) }),
1054
+ ),
1055
+ h(Row, { label: '局域网发现' },
1056
+ h(ToggleControl, { value: draft.discovery, onChange: (value) => set('discovery', value) }),
1057
+ ),
1058
+ h(Row, { label: 'Relay' },
1059
+ h(SelectControl, {
1060
+ value: draft.relayMode,
1061
+ options: [{ value: 'off', label: '不使用' }, { value: 'custom', label: '自定义' }],
1062
+ onChange: (value) => set('relayMode', value),
1063
+ }),
1064
+ ),
1065
+ draft.relayMode === 'custom' ? h(Row, { label: 'Relay 地址' },
1066
+ h(TextControl, { value: draft.relayHost, onChange: (value) => set('relayHost', value) }),
1067
+ ) : null,
1068
+ draft.relayMode === 'custom' ? h(Row, { label: 'Relay 端口' },
1069
+ h(NumberControl, { value: draft.relayPort, onChange: (value) => set('relayPort', value) }),
1070
+ ) : null,
1071
+ draft.relayMode === 'custom' ? h(Row, { label: 'Relay 数据端口' },
1072
+ h(NumberControl, { value: draft.relayDataPort, onChange: (value) => set('relayDataPort', value) }),
1073
+ ) : null,
1074
+ h('div', { style: sectionStyle }, '执行上下文'),
1075
+ h(Row, { label: '工作目录' },
1076
+ h(SelectControl, { value: draft.cwdMode, options: cwdOptions, onChange: (value) => set('cwdMode', value) }),
1077
+ ),
1078
+ draft.cwdMode === 'custom' ? h(Row, { label: '路径' },
1079
+ h(TextControl, { value: draft.customCwd, onChange: (value) => set('customCwd', value) }),
1080
+ ) : null,
1081
+ h(Row, { label: 'Agent Preset' },
1082
+ h(SelectControl, { value: draft.execPreset, options: presetOptions, onChange: (value) => set('execPreset', value) }),
1083
+ ),
1084
+ h(Row, { label: 'Shell' },
1085
+ h(SelectControl, { value: draft.execShellTool, options: shellOptions, onChange: (value) => set('execShellTool', value) }),
1086
+ ),
1087
+ h(Row, { label: '委派 Provider' },
1088
+ h(SelectControl, { value: draft.execSubagentProvider, options: providerOptions, onChange: (value) => set('execSubagentProvider', value) }),
1089
+ ),
1090
+ h(Row, { label: '审批超时 (ms)' },
1091
+ h(NumberControl, { value: draft.execApprovalTimeoutMs, onChange: (value) => set('execApprovalTimeoutMs', value) }),
1092
+ ),
1093
+ h(Row, { label: '委派超时 (ms)' },
1094
+ h(NumberControl, { value: draft.execDelegateTimeoutMs, onChange: (value) => set('execDelegateTimeoutMs', value) }),
1095
+ ),
1096
+ h('div', { style: sectionStyle }, '头像库'),
1097
+ h('div', { style: { ...cardStyle, padding: 14 } },
1098
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
1099
+ h('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp', multiple: true, onChange: (event) => void uploadAvatars(event) }),
1100
+ avatarStatus ? h('span', { style: { fontSize: 12, opacity: 0.7 } }, avatarStatus) : null,
1101
+ ),
1102
+ library.length === 0
1103
+ ? h('div', { style: { fontSize: 12, opacity: 0.6, marginTop: 8 } }, '未上传头像时使用内置头像;上传后 Room 成员按顺序循环分配')
1104
+ : h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 } },
1105
+ ...library.map((item) =>
1106
+ h('div', { key: item.name, style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 } },
1107
+ h('img', { src: item.url, alt: item.name, style: { width: 46, height: 46, borderRadius: '50%', objectFit: 'cover', border: '1px solid var(--dsw-alias-border-l1, #333)' } }),
1108
+ smallButton('删除', () => void removeAvatar(item.name), 'deny'),
1109
+ ),
1110
+ ),
1111
+ ),
1112
+ h('div', { style: { fontSize: 11, opacity: 0.55, marginTop: 8 } }, '图片存放在设备数据目录的 avatars/ 下;Room 视图按成员顺序循环取用,可点「换头像」切换到下一组。'),
1113
+ ),
1114
+ h('div', { style: sectionStyle }, '远端权限'),
1115
+ h(Row, { label: '允许的根目录', wide: true },
1116
+ h('textarea', {
1117
+ value: draft.rootsText,
1118
+ onChange: (event) => set('rootsText', event.target.value),
1119
+ rows: 3,
1120
+ style: textareaStyle,
1121
+ }),
1122
+ ),
1123
+ ...CAPABILITY_ROWS.map(([key, label]) =>
1124
+ h(Row, { key, label },
1125
+ h(SelectControl, { value: draft[key], options: modeOptions, onChange: (value) => set(key, value) }),
1126
+ ),
1127
+ ),
1128
+ h(Row, { label: '授权有效期 (ms)' },
1129
+ h(NumberControl, { value: draft.policyApprovalTtlMs, onChange: (value) => set('policyApprovalTtlMs', value) }),
1130
+ ),
1131
+ h('div', { style: sectionStyle }, '审批与提示'),
1132
+ h(Row, { label: '审批超时 (ms)' },
1133
+ h(NumberControl, { value: draft.approvalsTtlMs, onChange: (value) => set('approvalsTtlMs', value) }),
1134
+ ),
1135
+ h(Row, { label: '最大排队数' },
1136
+ h(NumberControl, { value: draft.approvalsMaxPending, onChange: (value) => set('approvalsMaxPending', value) }),
1137
+ ),
1138
+ h(Row, { label: '自动批准(测试)' },
1139
+ h(ToggleControl, { value: draft.autoApproveAsk, onChange: (value) => set('autoApproveAsk', value) }),
1140
+ ),
1141
+ h(Row, { label: 'Fleet 系统提示' },
1142
+ h(ToggleControl, { value: draft.promptSection, onChange: (value) => set('promptSection', value) }),
1143
+ ),
1144
+ h(Row, { label: '审批模拟(测试)' },
1145
+ h(ToggleControl, { value: draft.simulation, onChange: (value) => set('simulation', value) }),
1146
+ ),
1147
+ )
1148
+ }
1149
+
1150
+ // Room is a normal Session with participants. This dock keeps the member
1151
+ // strip inside the conversation instead of a separate dashboard.
1152
+ function extractDraftText(input, depth = 0) {
1153
+ if (input === null || input === undefined || depth > 3) return '';
1154
+ if (typeof input === 'string') return input;
1155
+ if (typeof input !== 'object') return '';
1156
+ for (const key of ['draft', 'text', 'value', 'plainText', 'input', 'message', 'content', 'current']) {
1157
+ const value = input[key];
1158
+ if (typeof value === 'string' && value !== '') return value;
1159
+ if (value && typeof value === 'object') {
1160
+ const nested = extractDraftText(value, depth + 1);
1161
+ if (nested !== '') return nested;
1162
+ }
1163
+ }
1164
+ return '';
1165
+ }
1166
+
1167
+ function insertMentionText(props, name) {
1168
+ const actions = props?.inputActions;
1169
+ if (actions && typeof actions === 'object') {
1170
+ for (const method of ['insertMention', 'insertText', 'appendText', 'replaceText', 'setText', 'setDraft']) {
1171
+ const fn = actions[method];
1172
+ if (typeof fn === 'function') {
1173
+ try {
1174
+ fn.call(actions, '@' + name + ' ');
1175
+ return true;
1176
+ } catch {
1177
+ /* try the next action */
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+ try {
1183
+ const element = document.activeElement;
1184
+ if (element && (element.isContentEditable || element.tagName === 'TEXTAREA' || element.tagName === 'INPUT')) {
1185
+ element.focus();
1186
+ document.execCommand('insertText', false, '@' + name + ' ');
1187
+ return true;
1188
+ }
1189
+ } catch {
1190
+ /* fall through */
1191
+ }
1192
+ return false;
1193
+ }
1194
+
1195
+ function RoomDock(props) {
1196
+ const sessionId = props?.sessionId;
1197
+ const [room, setRoom] = useState(null);
1198
+ const [missing, setMissing] = useState(false);
1199
+ const [status, setStatus] = useState('');
1200
+ const [autoOpened, setAutoOpened] = useState(false);
1201
+ // Standard slot props expose conversation hooks; use them when available
1202
+ // to switch a Room session from Chat to the Room view automatically.
1203
+ const conversation = (() => {
1204
+ try {
1205
+ return typeof props?.useConversation === 'function' ? props.useConversation() : null;
1206
+ } catch {
1207
+ return null;
1208
+ }
1209
+ })();
1210
+
1211
+ const load = async () => {
1212
+ if (!sessionId) return;
1213
+ try {
1214
+ const response = await fetch('/device-fleet/room?id=' + encodeURIComponent(sessionId), { headers: { accept: 'application/json' } });
1215
+ if (response.status === 404) {
1216
+ setRoom(null);
1217
+ setMissing(true);
1218
+ return;
1219
+ }
1220
+ if (!response.ok) throw new Error('HTTP ' + response.status);
1221
+ const data = await response.json();
1222
+ setRoom(data.room ?? null);
1223
+ setMissing(false);
1224
+ } catch {
1225
+ setRoom(null);
1226
+ }
1227
+ };
1228
+
1229
+ useEffect(() => {
1230
+ void load();
1231
+ const timer = setInterval(load, 4000);
1232
+ return () => clearInterval(timer);
1233
+ }, [sessionId]);
1234
+
1235
+ useEffect(() => {
1236
+ if (!room || autoOpened) return;
1237
+ const openView =
1238
+ conversation?.openView ?? conversation?.selectView ?? conversation?.setView ?? conversation?.goView;
1239
+ if (typeof openView !== 'function') return;
1240
+ try {
1241
+ openView('room', '');
1242
+ setAutoOpened(true);
1243
+ } catch {
1244
+ /* the shell did not expose a compatible view switch */
1245
+ }
1246
+ }, [room, autoOpened, conversation]);
1247
+
1248
+ if (!sessionId) return null;
1249
+ const upgrade = async () => {
1250
+ setStatus('升级中…');
1251
+ try {
1252
+ await postJson('/device-fleet/room/upgrade', { sessionId });
1253
+ setStatus('');
1254
+ await load();
1255
+ } catch (problem) {
1256
+ setStatus('升级失败:' + String(problem.message ?? problem));
1257
+ }
1258
+ };
1259
+
1260
+ if (missing) {
1261
+ return h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, opacity: 0.75, padding: '2px 2px 0' } },
1262
+ h('span', null, '普通会话'),
1263
+ status ? h('span', null, status) : null,
1264
+ smallButton('升级为 Room', () => void upgrade(), 'normal'),
1265
+ );
1266
+ }
1267
+ if (!room) return null;
1268
+ const names = room.members.filter((member) => member.role !== 'host').map((member) => member.name);
1269
+ const inputState = (() => {
1270
+ try {
1271
+ return typeof props?.useInput === 'function' ? props.useInput() : null;
1272
+ } catch {
1273
+ return null;
1274
+ }
1275
+ })();
1276
+ const draft = extractDraftText(inputState);
1277
+ const mention = /(?:^|\s)@([^\s@]*)$/.exec(draft);
1278
+ const candidates = mention
1279
+ ? room.members.filter((member) => String(member.name ?? '').toLowerCase().includes(mention[1].toLowerCase()))
1280
+ : [];
1281
+ const completion =
1282
+ mention && candidates.length > 0
1283
+ ? h('div', {
1284
+ style: {
1285
+ position: 'fixed',
1286
+ bottom: 96,
1287
+ left: '50%',
1288
+ transform: 'translateX(-50%)',
1289
+ width: 'min(520px, 80vw)',
1290
+ zIndex: 1000,
1291
+ background: 'var(--dsw-alias-bg-overlay, #1f1f1f)',
1292
+ color: 'var(--dsw-alias-label-primary, #e8e8e8)',
1293
+ border: '1px solid var(--dsw-alias-border-l2, #3a3a3a)',
1294
+ borderRadius: 10,
1295
+ boxShadow: '0 12px 32px rgba(0,0,0,.4)',
1296
+ padding: 6,
1297
+ font: '12px system-ui, sans-serif',
1298
+ },
1299
+ },
1300
+ h('div', { style: { opacity: 0.55, fontSize: 10, padding: '3px 7px 5px' } }, '可发送给:所有人 / 指定 agent'),
1301
+ ...candidates.map((member) =>
1302
+ h('button', {
1303
+ key: member.memberId,
1304
+ type: 'button',
1305
+ onClick: () => {
1306
+ if (!insertMentionText(props, member.name)) {
1307
+ setStatus('请在输入框手动补全 @' + member.name)
1308
+ }
1309
+ },
1310
+ style: { display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 8px', background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: 12, textAlign: 'left', borderRadius: 6 },
1311
+ }, avatarImg('/device-fleet/avatar/' + (member.role === 'host' ? 'host' : 'worker'), member.name), member.name + (member.role === 'host' ? '(host)' : '')),
1312
+ ),
1313
+ )
1314
+ : null;
1315
+ return h('div', null,
1316
+ completion,
1317
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, opacity: 0.8, padding: '2px 2px 0', flexWrap: 'wrap' } },
1318
+ pill('Room', 'ok'),
1319
+ h('span', null, room.members.length + ' 名成员'),
1320
+ h('span', { style: { opacity: 0.6 } }, '可发送给'),
1321
+ ...room.members.map((member) =>
1322
+ h('button', {
1323
+ key: member.memberId,
1324
+ type: 'button',
1325
+ onClick: () => {
1326
+ if (!insertMentionText(props, member.name)) {
1327
+ setStatus('请在输入框手动输入 @' + member.name)
1328
+ }
1329
+ },
1330
+ style: { padding: '1px 7px', borderRadius: 999, border: '1px solid var(--dsw-alias-border-l1, #333)', background: 'transparent', color: 'inherit', fontSize: 11, cursor: 'pointer' },
1331
+ }, '@' + member.name),
1332
+ ),
1333
+ h('span', { style: { flex: 1 } }),
1334
+ h('span', { style: { opacity: 0.5, fontSize: 10 } }, '点名字插入 @;不点名发给所有人'),
1335
+ ),
1336
+ );
1337
+ }
1338
+
1339
+ // Session-native Room view: the Room's transcript, members and board live
1340
+ // in their own Conversation View instead of the normal Chat stream.
1341
+ function RoomPane({ roomId }) {
1342
+ const [detail, setDetail] = useState(null);
1343
+ const [missing, setMissing] = useState(false);
1344
+ const [error, setError] = useState('');
1345
+ const [sendText, setSendText] = useState('');
1346
+ const [sendTo, setSendTo] = useState([]);
1347
+ const [taskTitle, setTaskTitle] = useState('');
1348
+ const [taskOwner, setTaskOwner] = useState('');
1349
+ const [status, setStatus] = useState('');
1350
+
1351
+ const load = async () => {
1352
+ if (!roomId) return;
1353
+ try {
1354
+ const response = await fetch('/device-fleet/room?id=' + encodeURIComponent(roomId), { headers: { accept: 'application/json' } });
1355
+ if (response.status === 404) {
1356
+ setMissing(true);
1357
+ setDetail(null);
1358
+ return;
1359
+ }
1360
+ if (!response.ok) throw new Error('HTTP ' + response.status);
1361
+ const data = await response.json();
1362
+ setDetail(data.room ?? null);
1363
+ setMissing(false);
1364
+ setError('');
1365
+ } catch (problem) {
1366
+ setError(String(problem.message ?? problem));
1367
+ }
1368
+ };
1369
+
1370
+ useEffect(() => {
1371
+ void load();
1372
+ const timer = setInterval(load, 2500);
1373
+ return () => clearInterval(timer);
1374
+ }, [roomId]);
1375
+
1376
+ // Only route Chat input into the Room while this view is active. Post a
1377
+ // heartbeat so a hidden view cannot leave a stale route, and stop on unmount.
1378
+ useEffect(() => {
1379
+ if (!roomId) return undefined;
1380
+ let stopped = false;
1381
+ const report = (focused) => {
1382
+ if (stopped) return;
1383
+ void fetch('/device-fleet/room/focus', {
1384
+ method: 'POST',
1385
+ headers: { 'content-type': 'application/json' },
1386
+ body: JSON.stringify({ roomId, focused }),
1387
+ keepalive: true,
1388
+ }).catch(() => {})
1389
+ }
1390
+ report(true)
1391
+ const timer = setInterval(() => report(true), 5000)
1392
+ return () => {
1393
+ stopped = true
1394
+ clearInterval(timer)
1395
+ void fetch('/device-fleet/room/focus', {
1396
+ method: 'POST',
1397
+ headers: { 'content-type': 'application/json' },
1398
+ body: JSON.stringify({ roomId, focused: false }),
1399
+ keepalive: true,
1400
+ }).catch(() => {})
1401
+ }
1402
+ }, [roomId]);
1403
+
1404
+ const act = async (label, action) => {
1405
+ setStatus(label + '…');
1406
+ try {
1407
+ await action();
1408
+ setStatus(label + '成功');
1409
+ await load();
1410
+ } catch (problem) {
1411
+ setStatus(label + '失败:' + String(problem.message ?? problem));
1412
+ }
1413
+ };
1414
+
1415
+ if (!roomId) return null;
1416
+ if (missing) {
1417
+ return h('div', { style: { opacity: 0.75, fontSize: 12 } }, '这个会话还不是 Room。在下方输入框旁点击「升级为 Room」,或让 agent 执行 room_create。');
1418
+ }
1419
+ if (!detail) return h('div', { style: { opacity: 0.7, fontSize: 12 } }, error ? '房间加载失败:' + error : '加载房间…');
1420
+
1421
+ const ctl = {
1422
+ sendText,
1423
+ setSendText,
1424
+ sendTo,
1425
+ setSendTo,
1426
+ sendRoomMessage: () =>
1427
+ act('发送', async () => {
1428
+ if (sendText.trim() === '') throw new Error('消息不能为空');
1429
+ await postJson('/device-fleet/room/send', { roomId, to: sendTo, text: sendText });
1430
+ setSendText('');
1431
+ }),
1432
+ taskTitle,
1433
+ setTaskTitle,
1434
+ taskOwner,
1435
+ setTaskOwner,
1436
+ addRoomTask: () =>
1437
+ act('添加任务', async () => {
1438
+ if (taskTitle.trim() === '') throw new Error('任务标题不能为空');
1439
+ await postJson('/device-fleet/room/task', { roomId, action: 'add', title: taskTitle, owner: taskOwner });
1440
+ setTaskTitle('');
1441
+ }),
1442
+ updateRoomTask: (id, nextStatus) => act('更新任务', () => postJson('/device-fleet/room/task', { roomId, action: 'update', id, status: nextStatus })),
1443
+ closeCurrentRoom: () => act('关闭房间', () => postJson('/device-fleet/room/close', { roomId })),
1444
+ removeRoomMember: (memberId) => act('移除成员', () => postJson('/device-fleet/room/member/remove', { roomId, memberId })),
1445
+ };
1446
+
1447
+ return h('div', null,
1448
+ status ? h('div', { style: { fontSize: 11, opacity: 0.6, marginBottom: 6 } }, status) : null,
1449
+ renderRoomDetail(detail, ctl),
1450
+ );
1451
+ }
1452
+
1453
+ function avatarImg(src, alt) {
1454
+ return h('img', {
1455
+ src,
1456
+ alt,
1457
+ draggable: false,
1458
+ style: {
1459
+ width: 26,
1460
+ height: 26,
1461
+ borderRadius: '50%',
1462
+ flex: '0 0 26px',
1463
+ objectFit: 'cover',
1464
+ border: '1px solid var(--dsw-alias-border-l1, #333)',
1465
+ background: 'var(--dsw-alias-bg-layer-2, #262626)',
1466
+ },
1467
+ })
1468
+ }
1469
+
1470
+ function roomAvatar(name, tone) {
1471
+ const kind = tone === 'user' ? 'user' : String(name ?? '') === 'host' ? 'host' : 'worker'
1472
+ return avatarImg('/device-fleet/avatar/' + kind, String(name ?? kind))
1473
+ }
1474
+
1475
+ // Room conversation rendered as a chat: agents on the left with names and
1476
+ // avatars, the user on the right. This is the Room's presentation instead of
1477
+ // injecting participant speech into the host's ordinary Chat.
1478
+ function RoomChatPane({ roomId }) {
1479
+ const [detail, setDetail] = useState(null);
1480
+ const [missing, setMissing] = useState(false);
1481
+ const [error, setError] = useState('');
1482
+ const [sendText, setSendText] = useState('');
1483
+ const [sendTo, setSendTo] = useState([]);
1484
+ const [status, setStatus] = useState('');
1485
+ const [showBoard, setShowBoard] = useState(false);
1486
+ const [taskTitle, setTaskTitle] = useState('');
1487
+ const [taskOwner, setTaskOwner] = useState('');
1488
+ const [library, setLibrary] = useState([]);
1489
+ const [avatarOffset, setAvatarOffset] = useState(0);
1490
+
1491
+ useEffect(() => {
1492
+ let disposed = false;
1493
+ const loadLibrary = async () => {
1494
+ try {
1495
+ const response = await fetch('/device-fleet/avatar-library', { headers: { accept: 'application/json' } });
1496
+ if (!response.ok) return;
1497
+ const data = await response.json();
1498
+ if (!disposed) setLibrary(Array.isArray(data.items) ? data.items : []);
1499
+ } catch {
1500
+ /* keep built-in avatars */
1501
+ }
1502
+ };
1503
+ void loadLibrary();
1504
+ return () => { disposed = true };
1505
+ }, []);
1506
+
1507
+ const load = async () => {
1508
+ if (!roomId) return;
1509
+ try {
1510
+ const response = await fetch('/device-fleet/room?id=' + encodeURIComponent(roomId), { headers: { accept: 'application/json' } });
1511
+ if (response.status === 404) {
1512
+ setMissing(true);
1513
+ setDetail(null);
1514
+ return;
1515
+ }
1516
+ if (!response.ok) throw new Error('HTTP ' + response.status);
1517
+ const data = await response.json();
1518
+ setDetail(data.room ?? null);
1519
+ setMissing(false);
1520
+ setError('');
1521
+ } catch (problem) {
1522
+ setError(String(problem.message ?? problem));
1523
+ }
1524
+ };
1525
+
1526
+ useEffect(() => {
1527
+ void load();
1528
+ const timer = setInterval(load, 2000);
1529
+ return () => clearInterval(timer);
1530
+ }, [roomId]);
1531
+
1532
+ const act = async (label, action) => {
1533
+ setStatus(label + '…');
1534
+ try {
1535
+ await action();
1536
+ setStatus(label + '成功');
1537
+ await load();
1538
+ } catch (problem) {
1539
+ setStatus(label + '失败:' + String(problem.message ?? problem));
1540
+ }
1541
+ };
1542
+
1543
+ if (!roomId) return null;
1544
+ if (missing) {
1545
+ return h('div', { style: { padding: 16, opacity: 0.75, fontSize: 12 } }, '这个会话还不是 Room。点击输入框下方的「升级为 Room」即可创建。');
1546
+ }
1547
+ if (!detail) return h('div', { style: { padding: 16, opacity: 0.7, fontSize: 12 } }, error ? '房间加载失败:' + error : '加载房间…');
1548
+
1549
+ const nameOf = (memberId) => detail.members.find((member) => member.memberId === memberId)?.name ?? memberId
1550
+ const memberAvatarSrc = (member) => {
1551
+ if (!member) return '/device-fleet/avatar/user'
1552
+ const index = detail.members.findIndex((item) => item.memberId === member.memberId)
1553
+ if (library.length > 0) {
1554
+ return library[((index >= 0 ? index : 0) + avatarOffset) % library.length].url
1555
+ }
1556
+ return member.role === 'host' ? '/device-fleet/avatar/host' : '/device-fleet/avatar/worker'
1557
+ }
1558
+ const participants = detail.members.filter((member) => member.role !== 'host')
1559
+ const defaultRecipients = participants.length > 0 ? participants.map((member) => member.memberId) : detail.members.map((member) => member.memberId)
1560
+ const recipients = sendTo.length > 0 ? sendTo : defaultRecipients
1561
+ const mentionMatch = /(?:^|\s)@([^\s@]*)$/.exec(sendText)
1562
+ const mentionInfo = mentionMatch ? { query: mentionMatch[1], start: sendText.length - mentionMatch[1].length - 1 } : null
1563
+ const mentionCandidates = mentionInfo
1564
+ ? detail.members.filter((member) => String(member.name ?? '').toLowerCase().includes(mentionInfo.query.toLowerCase()))
1565
+ : []
1566
+ const send = () =>
1567
+ act('发送', async () => {
1568
+ if (sendText.trim() === '') throw new Error('消息不能为空')
1569
+ await postJson('/device-fleet/room/send', { roomId, to: recipients, text: sendText })
1570
+ setSendText('')
1571
+ })
1572
+
1573
+ const messages = detail.messages.map((message) => {
1574
+ const fromUser = message.from?.kind === 'user'
1575
+ const author = fromUser ? '你' : message.from?.name ?? 'agent'
1576
+ const senderMember = fromUser ? null : detail.members.find((member) => member.memberId === message.from?.memberId)
1577
+ const targets = (message.to ?? []).map(nameOf).join(', ')
1578
+ return h('div', { key: message.id, style: { display: 'flex', justifyContent: fromUser ? 'flex-end' : 'flex-start', gap: 8, margin: '10px 0' } },
1579
+ fromUser ? null : avatarImg(memberAvatarSrc(senderMember), author),
1580
+ h('div', { style: { maxWidth: '74%' } },
1581
+ fromUser ? null : h('div', { style: { fontSize: 11, opacity: 0.6, marginBottom: 2 } }, author + (targets ? ' · → ' + targets : '')),
1582
+ h('div', {
1583
+ style: {
1584
+ background: fromUser ? '#2f6fed' : 'var(--dsw-alias-bg-layer-2, #262626)',
1585
+ color: fromUser ? '#fff' : 'var(--dsw-alias-label-primary, #e8e8e8)',
1586
+ padding: '8px 12px',
1587
+ borderRadius: 12,
1588
+ whiteSpace: 'pre-wrap',
1589
+ fontSize: 13,
1590
+ lineHeight: 1.5,
1591
+ },
1592
+ }, message.text),
1593
+ h('div', { style: { fontSize: 10, opacity: 0.45, marginTop: 2, textAlign: fromUser ? 'right' : 'left' } }, fmtTime(message.createdAt)),
1594
+ ),
1595
+ fromUser ? avatarImg('/device-fleet/avatar/user', '你') : null,
1596
+ )
1597
+ })
1598
+
1599
+ return h('div', { style: { height: '100%', display: 'flex', flexDirection: 'column', padding: '12px 16px', boxSizing: 'border-box' } },
1600
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 8 } },
1601
+ h('strong', { style: { fontSize: 15 } }, 'Room'),
1602
+ ...detail.members.map((member) =>
1603
+ h('span', { key: member.memberId, style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, opacity: 0.8 } },
1604
+ avatarImg(memberAvatarSrc(member), member.name ?? member.memberId),
1605
+ member.name + (member.role === 'host' ? '(host)' : ''),
1606
+ ),
1607
+ ),
1608
+ h('span', { style: { flex: 1 } }),
1609
+ status ? h('span', { style: { fontSize: 11, opacity: 0.6 } }, status) : null,
1610
+ library.length > 0 ? smallButton('换头像', () => setAvatarOffset((value) => value + 1), 'normal') : null,
1611
+ smallButton(showBoard ? '收起看板' : '看板', () => setShowBoard((value) => !value), 'normal'),
1612
+ smallButton('关闭房间', () => { void act('关闭房间', () => postJson('/device-fleet/room/close', { roomId })) }, 'deny'),
1613
+ ),
1614
+ h('div', { style: { flex: 1, overflow: 'auto', minHeight: 120 } }, messages.length === 0 ? h('div', { style: { opacity: 0.6, fontSize: 12 } }, '还没有消息') : messages),
1615
+ showBoard
1616
+ ? h('div', { style: { ...cardStyle, marginTop: 8 } },
1617
+ h('div', { style: sectionTitle }, '看板'),
1618
+ detail.tasks.length === 0 ? h('div', { style: { opacity: 0.65, fontSize: 12 } }, '暂无任务') : detail.tasks.map((task) =>
1619
+ h('div', { key: task.id, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', fontSize: 12 } },
1620
+ h('span', { style: { flex: 1 } }, task.title + ' · ' + nameOf(task.owner)),
1621
+ h('select', {
1622
+ value: task.status,
1623
+ onChange: (event) => void act('更新任务', () => postJson('/device-fleet/room/task', { roomId, action: 'update', id: task.id, status: event.target.value })),
1624
+ style: { height: 24, borderRadius: 5, border: '1px solid var(--dsw-alias-border-l1, #333)', background: 'var(--dsw-alias-bg-layer-2, #262626)', color: 'inherit', fontSize: 11 },
1625
+ }, ...['pending', 'running', 'done', 'blocked'].map((value) => h('option', { key: value, value }, value))),
1626
+ ),
1627
+ ),
1628
+ h('div', { style: { display: 'flex', gap: 6, marginTop: 8, flexWrap: 'wrap' } },
1629
+ h('input', { type: 'text', value: taskTitle, onChange: (event) => setTaskTitle(event.target.value), placeholder: '新任务', style: { ...controlStyleWidth(160), height: 26, fontSize: 12 } }),
1630
+ h('select', { value: taskOwner, onChange: (event) => setTaskOwner(event.target.value), style: { ...controlStyleWidth(150), height: 26, fontSize: 12 } },
1631
+ h('option', { value: '' }, 'owner'),
1632
+ ...detail.members.map((member) => h('option', { key: member.memberId, value: member.memberId }, member.name)),
1633
+ ),
1634
+ smallButton('添加', () => void act('添加任务', () => postJson('/device-fleet/room/task', { roomId, action: 'add', title: taskTitle, owner: taskOwner })), 'allow'),
1635
+ ),
1636
+ )
1637
+ : null,
1638
+ h('div', { style: { ...cardStyle, marginTop: 8, fontSize: 12, opacity: 0.75 } },
1639
+ '在下方 DSH 输入框发送:用 @成员名 点名(例如 @worker-c),不点名则发给所有人。',
1640
+ ),
1641
+ )
1642
+ }
1643
+
1644
+ function RoomView({ sessionId }) {
1645
+ return h('div', { style: { height: '100%' } }, h(RoomChatPane, { roomId: sessionId }));
1646
+ }
1647
+
1648
+ return {
1649
+ inject: ['slots'],
1650
+ apply(ctx) {
1651
+ ctx.effect(() => startPolling());
1652
+ ctx.slots.inject('sidebar.footer.action', () =>
1653
+ ctx.slots.register({ name: 'sidebar.footer.action', id: 'fleet-approvals', order: 5, label: 'Fleet 审批' }, Trigger),
1654
+ );
1655
+ // shell.overlay is a root slot that exists from boot (the dev.10 build
1656
+ // rendered from a direct registration); register directly so the dialog
1657
+ // mounts even if the lazy inject callback never fires.
1658
+ ctx.slots.register({ name: 'shell.overlay', id: 'fleet-approvals-panel', order: 10 }, Panel);
1659
+ // Global Fleet console: sidebar icon + matching main panel.
1660
+ ctx.slots.inject('sidebar.panellist', () =>
1661
+ ctx.slots.register({ name: 'sidebar.panellist', id: 'fleet', order: 20, label: '设备联邦' }, FleetIcon),
1662
+ );
1663
+ ctx.slots.register({ name: 'main', key: 'fleet' }, FleetConsole);
1664
+ // Session-native Room: member strip below the composer, plus a "房间"
1665
+ // Conversation View for the Room transcript/board.
1666
+ ctx.slots.inject('conversation.composer.dock', () =>
1667
+ ctx.slots.register({ name: 'conversation.composer.dock', id: 'fleet-room', order: 10 }, RoomDock),
1668
+ );
1669
+ ctx.slots.inject('conversation.view', () =>
1670
+ ctx.slots.register({ name: 'conversation.view', id: 'room', order: 20, label: '房间' }, RoomView),
1671
+ );
1672
+ // Configuration page: Settings → Plugins → Device Fleet (row + bundle).
1673
+ ctx.slots.inject('plugins.row.config', () =>
1674
+ ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-device-fleet#device-fleet' }, FleetConfig),
1675
+ );
1676
+ ctx.slots.inject('plugins.bundle.config', () =>
1677
+ ctx.slots.register({ name: 'plugins.bundle.config', key: 'dsh-device-fleet' }, FleetConfig),
1678
+ );
1679
+ },
1680
+ };
1681
+ },
1682
+ });