dsh-device-fleet 0.1.0-dev.2 → 0.1.0-dev.21

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,1176 @@
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 recipientIds = Array.isArray(ctl.sendTo) && ctl.sendTo.length > 0 ? ctl.sendTo : memberIds.filter((id) => id.startsWith('host:'));
612
+ return h(
613
+ 'div',
614
+ null,
615
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 } },
616
+ h('div', { style: { ...sectionTitle, margin: 0, flex: 1 } }, '房间 ' + detail.roomId.slice(-8)),
617
+ smallButton('关闭房间', () => void ctl.closeCurrentRoom(), 'deny'),
618
+ ),
619
+ h('div', { style: cardStyle },
620
+ h('div', { style: sectionTitle }, '成员'),
621
+ ...detail.members.map((member) =>
622
+ h('div', { key: member.memberId, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '3px 0', fontSize: 12 } },
623
+ pill(member.role, member.role === 'host' ? 'ok' : 'muted'),
624
+ h('span', { style: { flex: 1 } }, member.name + ' · ' + member.memberId),
625
+ member.role === 'host' ? null : smallButton('移除', () => void ctl.removeRoomMember(member.memberId), 'deny'),
626
+ ),
627
+ ),
628
+ ),
629
+ h('div', { style: cardStyle },
630
+ h('div', { style: sectionTitle }, '发消息(以用户身份)'),
631
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px 14px', marginBottom: 6 } },
632
+ ...detail.members.map((member) =>
633
+ h('label', { key: member.memberId, style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 12 } },
634
+ h('input', {
635
+ type: 'checkbox',
636
+ checked: recipientIds.includes(member.memberId),
637
+ onChange: (event) => {
638
+ ctl.setSendTo((current) => {
639
+ const base = current.length > 0 ? current : recipientIds
640
+ return event.target.checked ? [...new Set([...base, member.memberId])] : base.filter((id) => id !== member.memberId)
641
+ })
642
+ },
643
+ }),
644
+ member.name,
645
+ ),
646
+ ),
647
+ ),
648
+ h('textarea', {
649
+ value: ctl.sendText,
650
+ onChange: (event) => ctl.setSendText(event.target.value),
651
+ rows: 2,
652
+ placeholder: '发送给房间成员(默认为 host)',
653
+ 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' },
654
+ }),
655
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 } },
656
+ h('span', { style: { flex: 1 } }),
657
+ smallButton('发送', () => void ctl.sendRoomMessage(), 'allow'),
658
+ ),
659
+ ),
660
+ h('div', { style: cardStyle },
661
+ h('div', { style: sectionTitle }, '看板(' + detail.tasks.length + ')'),
662
+ detail.tasks.length === 0
663
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无任务')
664
+ : detail.tasks.map((task) =>
665
+ h('div', { key: task.id, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', fontSize: 12 } },
666
+ pill(task.status, task.status === 'done' ? 'ok' : 'muted'),
667
+ h('span', { style: { flex: 1 } }, task.title + ' · owner ' + task.owner + (task.dependsOn.length ? ' · deps ' + task.dependsOn.length : '')),
668
+ h('select', {
669
+ value: task.status,
670
+ onChange: (event) => void ctl.updateRoomTask(task.id, event.target.value),
671
+ 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 },
672
+ },
673
+ ...['pending', 'running', 'done', 'blocked'].map((status) => h('option', { key: status, value: status }, status)),
674
+ ),
675
+ ),
676
+ ),
677
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' } },
678
+ h('input', {
679
+ type: 'text',
680
+ value: ctl.taskTitle,
681
+ onChange: (event) => ctl.setTaskTitle(event.target.value),
682
+ placeholder: '新任务标题',
683
+ style: { ...controlStyleWidth(180), height: 26, fontSize: 12 },
684
+ }),
685
+ h('select', {
686
+ value: ctl.taskOwner,
687
+ onChange: (event) => ctl.setTaskOwner(event.target.value),
688
+ style: { ...controlStyleWidth(180), height: 26, fontSize: 12 },
689
+ },
690
+ h('option', { value: '' }, '选择 owner'),
691
+ ...detail.members.map((member) => h('option', { key: member.memberId, value: member.memberId }, member.name)),
692
+ ),
693
+ smallButton('添加任务', () => void ctl.addRoomTask(), 'allow'),
694
+ ),
695
+ ),
696
+ h('div', { style: cardStyle },
697
+ h('div', { style: sectionTitle }, '时间线'),
698
+ detail.messages.length === 0
699
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无消息')
700
+ : detail.messages.map((message) =>
701
+ h('div', { key: message.id, style: { padding: '5px 0', borderTop: '1px solid var(--dsw-alias-border, #333)' } },
702
+ h('div', { style: { fontSize: 11, opacity: 0.6 } }, '[' + fmtTime(message.createdAt) + '] ' + message.fromLabel + ' → ' + message.to.join(', ')),
703
+ h('div', { style: { whiteSpace: 'pre-wrap', marginTop: 2 } }, message.text),
704
+ ),
705
+ ),
706
+ ),
707
+ );
708
+ }
709
+
710
+ // ---- DSH-styled configuration page -------------------------------------
711
+ const CONTROL_WIDTH = 260
712
+ const rowStyle = {
713
+ display: 'flex',
714
+ alignItems: 'center',
715
+ gap: 16,
716
+ minHeight: 42,
717
+ padding: '5px 0',
718
+ borderBottom: '1px solid var(--dsw-alias-border-l1, #333)',
719
+ }
720
+ const rowLabelStyle = { flex: 1, fontSize: 13, color: 'var(--dsw-alias-label-primary, #e8e8e8)' }
721
+ const controlStyle = {
722
+ width: CONTROL_WIDTH,
723
+ boxSizing: 'border-box',
724
+ height: 30,
725
+ padding: '3px 8px',
726
+ borderRadius: 6,
727
+ border: '1px solid var(--dsw-alias-border-l1, #333)',
728
+ background: 'var(--dsw-alias-bg-layer-2, #262626)',
729
+ color: 'var(--dsw-alias-label-primary, #e8e8e8)',
730
+ fontSize: 13,
731
+ outline: 'none',
732
+ }
733
+ const controlStyleWidth = (width) => ({ ...controlStyle, width })
734
+ const selectStyle = {
735
+ ...controlStyle,
736
+ appearance: 'none',
737
+ WebkitAppearance: 'none',
738
+ paddingRight: 24,
739
+ cursor: 'pointer',
740
+ }
741
+ const textareaStyle = {
742
+ ...controlStyle,
743
+ width: '100%',
744
+ height: 'auto',
745
+ minHeight: 64,
746
+ resize: 'vertical',
747
+ padding: '6px 8px',
748
+ lineHeight: 1.5,
749
+ fontFamily: 'ui-monospace, monospace',
750
+ }
751
+ const sectionStyle = {
752
+ margin: '22px 0 4px',
753
+ fontSize: 13,
754
+ fontWeight: 600,
755
+ color: 'var(--dsw-alias-label-primary, #e8e8e8)',
756
+ }
757
+
758
+ function Row({ label, children, wide = false }) {
759
+ if (wide) {
760
+ return h('div', { style: { ...rowStyle, flexDirection: 'column', alignItems: 'stretch', gap: 6, padding: '8px 0' } },
761
+ h('div', { style: rowLabelStyle }, label),
762
+ h('div', { style: { width: '100%' } }, children),
763
+ )
764
+ }
765
+ return h('div', { style: rowStyle },
766
+ h('div', { style: rowLabelStyle }, label),
767
+ h('div', { style: { width: CONTROL_WIDTH } }, children),
768
+ )
769
+ }
770
+
771
+ function SelectControl({ value, options, onChange }) {
772
+ return h('select', {
773
+ value: value ?? '',
774
+ onChange: (event) => onChange(event.target.value),
775
+ style: selectStyle,
776
+ },
777
+ ...(options ?? []).map((option) => {
778
+ const entry = typeof option === 'string' ? { value: option, label: option } : option
779
+ return h('option', { key: entry.value, value: entry.value }, entry.label)
780
+ }),
781
+ )
782
+ }
783
+
784
+ function TextControl({ value, onChange, placeholder }) {
785
+ return h('input', {
786
+ type: 'text',
787
+ value: value ?? '',
788
+ placeholder: placeholder ?? '',
789
+ onChange: (event) => onChange(event.target.value),
790
+ style: controlStyle,
791
+ })
792
+ }
793
+
794
+ function NumberControl({ value, onChange }) {
795
+ return h('input', {
796
+ type: 'number',
797
+ value: value ?? 0,
798
+ onChange: (event) => onChange(Number(event.target.value || 0)),
799
+ style: controlStyle,
800
+ })
801
+ }
802
+
803
+ function ToggleControl({ value, onChange }) {
804
+ return h(SelectControl, {
805
+ value: value ? 'on' : 'off',
806
+ options: [{ value: 'on', label: '开启' }, { value: 'off', label: '关闭' }],
807
+ onChange: (next) => onChange(next === 'on'),
808
+ })
809
+ }
810
+
811
+ const CAPABILITY_ROWS = [
812
+ ['capRead', '读取文件'],
813
+ ['capList', '列目录'],
814
+ ['capGrep', '搜索内容'],
815
+ ['capFetch', '获取文件'],
816
+ ['capRun', '执行命令'],
817
+ ['capTool', '原始工具(遵循 DSH 审批)'],
818
+ ['capDelegate', '委派任务'],
819
+ ]
820
+
821
+ function configToDraft(config = {}, options = {}) {
822
+ const execution = config.execution ?? {}
823
+ const policy = config.policy ?? {}
824
+ const approvals = config.approvals ?? {}
825
+ const direct = config.direct ?? {}
826
+ const relay = config.relay ?? null
827
+ const cwd = execution.cwd ?? ''
828
+ const cwdInfo = options.cwd ?? {}
829
+ let cwdMode = 'custom'
830
+ if (cwd === '') cwdMode = 'default'
831
+ else if (cwdInfo.workspace && cwd === cwdInfo.workspace) cwdMode = 'workspace'
832
+ else if (cwdInfo.dshHome && cwd === cwdInfo.dshHome) cwdMode = 'dshHome'
833
+ else if (cwdInfo.defaultDirectory && cwd === cwdInfo.defaultDirectory) cwdMode = 'default'
834
+ return {
835
+ deviceName: config.deviceName ?? '',
836
+ directHost: direct.host ?? '0.0.0.0',
837
+ directPort: direct.port ?? 0,
838
+ discovery: config.discovery !== false,
839
+ relayMode: relay && (relay.host || relay.port) ? 'custom' : 'off',
840
+ relayHost: relay?.host ?? '',
841
+ relayPort: relay?.port ?? 0,
842
+ relayDataPort: relay?.dataPort ?? 0,
843
+ cwdMode,
844
+ customCwd: cwdMode === 'custom' ? cwd : '',
845
+ execPreset: execution.agentPreset ?? '',
846
+ execShellTool: execution.shellTool ?? '',
847
+ execApprovalTimeoutMs: execution.approvalTimeoutMs ?? 60000,
848
+ execDelegateTimeoutMs: execution.delegateTimeoutMs ?? 180000,
849
+ execSubagentProvider: execution.subagentProvider ?? 'spawn',
850
+ rootsText: (policy.allowedRoots ?? []).join('\n'),
851
+ capRead: policy.capabilities?.read ?? 'auto',
852
+ capList: policy.capabilities?.list ?? 'auto',
853
+ capGrep: policy.capabilities?.grep ?? 'auto',
854
+ capFetch: policy.capabilities?.fetch ?? 'auto',
855
+ capRun: policy.capabilities?.run ?? 'ask',
856
+ capTool: policy.capabilities?.tool ?? 'deny',
857
+ capDelegate: policy.capabilities?.delegate ?? 'ask',
858
+ policyApprovalTtlMs: policy.approvalTtlMs ?? 900000,
859
+ approvalsTtlMs: approvals.ttlMs ?? 60000,
860
+ approvalsMaxPending: approvals.maxPending ?? 50,
861
+ autoApproveAsk: config.autoApproveAsk === true,
862
+ promptSection: config.promptSection !== false,
863
+ simulation: approvals.simulation === true,
864
+ }
865
+ }
866
+
867
+ function draftToPatch(draft, options = {}) {
868
+ const cwdInfo = options.cwd ?? {}
869
+ let cwd = ''
870
+ if (draft.cwdMode === 'workspace') cwd = cwdInfo.workspace ?? ''
871
+ else if (draft.cwdMode === 'dshHome') cwd = cwdInfo.dshHome ?? ''
872
+ else if (draft.cwdMode === 'default') cwd = cwdInfo.defaultDirectory || cwdInfo.workspace || ''
873
+ else if (draft.cwdMode === 'custom') cwd = draft.customCwd ?? ''
874
+ return {
875
+ deviceName: draft.deviceName,
876
+ direct: { host: draft.directHost, port: draft.directPort },
877
+ discovery: draft.discovery,
878
+ relay: draft.relayMode === 'custom'
879
+ ? { host: draft.relayHost, port: draft.relayPort, dataPort: draft.relayDataPort }
880
+ : null,
881
+ execution: {
882
+ cwd,
883
+ agentPreset: draft.execPreset,
884
+ shellTool: draft.execShellTool,
885
+ approvalTimeoutMs: draft.execApprovalTimeoutMs,
886
+ delegateTimeoutMs: draft.execDelegateTimeoutMs,
887
+ subagentProvider: draft.execSubagentProvider,
888
+ },
889
+ policy: {
890
+ allowedRoots: draft.rootsText.split('\n').map((item) => item.trim()).filter(Boolean),
891
+ capabilities: {
892
+ read: draft.capRead,
893
+ list: draft.capList,
894
+ grep: draft.capGrep,
895
+ fetch: draft.capFetch,
896
+ run: draft.capRun,
897
+ tool: draft.capTool,
898
+ delegate: draft.capDelegate,
899
+ },
900
+ approvalTtlMs: draft.policyApprovalTtlMs,
901
+ },
902
+ approvals: {
903
+ ttlMs: draft.approvalsTtlMs,
904
+ maxPending: draft.approvalsMaxPending,
905
+ simulation: draft.simulation,
906
+ },
907
+ autoApproveAsk: draft.autoApproveAsk,
908
+ promptSection: draft.promptSection,
909
+ }
910
+ }
911
+
912
+ function FleetConfig() {
913
+ const [draft, setDraft] = useState(null)
914
+ const [options, setOptions] = useState({})
915
+ const [status, setStatus] = useState('')
916
+ const [busy, setBusy] = useState(false)
917
+
918
+ useEffect(() => {
919
+ let disposed = false
920
+ const load = async () => {
921
+ try {
922
+ const [configResponse, optionsResponse] = await Promise.all([
923
+ fetch('/device-fleet/config', { headers: { accept: 'application/json' } }),
924
+ fetch('/device-fleet/options', { headers: { accept: 'application/json' } }),
925
+ ])
926
+ const configData = await configResponse.json()
927
+ const optionsData = optionsResponse.ok ? await optionsResponse.json() : {}
928
+ if (disposed) return
929
+ setOptions(optionsData ?? {})
930
+ setDraft(configToDraft(configData.config ?? {}, optionsData ?? {}))
931
+ } catch (problem) {
932
+ if (!disposed) setStatus('加载失败')
933
+ }
934
+ }
935
+ void load()
936
+ return () => { disposed = true }
937
+ }, [])
938
+
939
+ if (!draft) return h('div', { style: { padding: 8, color: 'var(--dsw-alias-label-secondary, #999)' } }, status || '加载中…')
940
+
941
+ const set = (key, value) => setDraft((current) => ({ ...current, [key]: value }))
942
+ const save = async () => {
943
+ setBusy(true)
944
+ setStatus('')
945
+ try {
946
+ const response = await fetch('/device-fleet/config', {
947
+ method: 'POST',
948
+ headers: { 'content-type': 'application/json' },
949
+ body: JSON.stringify(draftToPatch(draft, options)),
950
+ })
951
+ const data = await response.json()
952
+ if (!response.ok) throw new Error(data.error ?? 'HTTP ' + response.status)
953
+ setDraft(configToDraft(data.config ?? {}, options))
954
+ setStatus('已保存')
955
+ } catch (problem) {
956
+ setStatus('保存失败:' + String(problem.message ?? problem))
957
+ } finally {
958
+ setBusy(false)
959
+ }
960
+ }
961
+
962
+ const cwdOptions = [
963
+ { value: 'workspace', label: 'Device Fleet 工作区' },
964
+ { value: 'default', label: 'DSH 默认目录' },
965
+ { value: 'dshHome', label: 'DSH 主目录' },
966
+ { value: 'custom', label: '自定义路径' },
967
+ ]
968
+ const presetOptions = [{ value: '', label: '默认' }, ...((options.presets ?? []).map((preset) => ({ value: preset.id, label: preset.label })))]
969
+ const shellOptions = (options.shells ?? ['pwsh']).map((shell) => ({ value: shell, label: shell }))
970
+ const providerOptions = (options.subagentProviders ?? ['spawn']).map((provider) => ({ value: provider, label: provider }))
971
+ const hostOptions = (options.addresses ?? ['0.0.0.0']).map((address) => ({ value: address, label: address }))
972
+ const modeOptions = [{ value: 'auto', label: '自动' }, { value: 'ask', label: '询问' }, { value: 'deny', label: '拒绝' }]
973
+
974
+ return h('div', { style: { maxWidth: 760, padding: '4px 2px 28px', color: 'var(--dsw-alias-label-primary, #e8e8e8)' } },
975
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 } },
976
+ h('div', { style: { fontSize: 15, fontWeight: 600 } }, 'Device Fleet'),
977
+ h('span', { style: { flex: 1 } }),
978
+ status ? h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #999)' } }, status) : null,
979
+ h('button', {
980
+ type: 'button',
981
+ disabled: busy,
982
+ onClick: () => void save(),
983
+ style: {
984
+ height: 30,
985
+ padding: '0 14px',
986
+ borderRadius: 6,
987
+ border: '1px solid var(--dsw-alias-brand-primary, #2f6fed)',
988
+ background: '#2f6fed',
989
+ color: '#fff',
990
+ fontSize: 13,
991
+ cursor: busy ? 'default' : 'pointer',
992
+ opacity: busy ? 0.6 : 1,
993
+ },
994
+ }, busy ? '保存中' : '保存'),
995
+ ),
996
+ h('div', { style: sectionStyle }, '设备'),
997
+ h(Row, { label: '名称' },
998
+ h(TextControl, { value: draft.deviceName, onChange: (value) => set('deviceName', value) }),
999
+ ),
1000
+ h('div', { style: sectionStyle }, '网络'),
1001
+ h(Row, { label: '直连地址' },
1002
+ h(SelectControl, { value: draft.directHost, options: hostOptions, onChange: (value) => set('directHost', value) }),
1003
+ ),
1004
+ h(Row, { label: '直连端口' },
1005
+ h(NumberControl, { value: draft.directPort, onChange: (value) => set('directPort', value) }),
1006
+ ),
1007
+ h(Row, { label: '局域网发现' },
1008
+ h(ToggleControl, { value: draft.discovery, onChange: (value) => set('discovery', value) }),
1009
+ ),
1010
+ h(Row, { label: 'Relay' },
1011
+ h(SelectControl, {
1012
+ value: draft.relayMode,
1013
+ options: [{ value: 'off', label: '不使用' }, { value: 'custom', label: '自定义' }],
1014
+ onChange: (value) => set('relayMode', value),
1015
+ }),
1016
+ ),
1017
+ draft.relayMode === 'custom' ? h(Row, { label: 'Relay 地址' },
1018
+ h(TextControl, { value: draft.relayHost, onChange: (value) => set('relayHost', value) }),
1019
+ ) : null,
1020
+ draft.relayMode === 'custom' ? h(Row, { label: 'Relay 端口' },
1021
+ h(NumberControl, { value: draft.relayPort, onChange: (value) => set('relayPort', value) }),
1022
+ ) : null,
1023
+ draft.relayMode === 'custom' ? h(Row, { label: 'Relay 数据端口' },
1024
+ h(NumberControl, { value: draft.relayDataPort, onChange: (value) => set('relayDataPort', value) }),
1025
+ ) : null,
1026
+ h('div', { style: sectionStyle }, '执行上下文'),
1027
+ h(Row, { label: '工作目录' },
1028
+ h(SelectControl, { value: draft.cwdMode, options: cwdOptions, onChange: (value) => set('cwdMode', value) }),
1029
+ ),
1030
+ draft.cwdMode === 'custom' ? h(Row, { label: '路径' },
1031
+ h(TextControl, { value: draft.customCwd, onChange: (value) => set('customCwd', value) }),
1032
+ ) : null,
1033
+ h(Row, { label: 'Agent Preset' },
1034
+ h(SelectControl, { value: draft.execPreset, options: presetOptions, onChange: (value) => set('execPreset', value) }),
1035
+ ),
1036
+ h(Row, { label: 'Shell' },
1037
+ h(SelectControl, { value: draft.execShellTool, options: shellOptions, onChange: (value) => set('execShellTool', value) }),
1038
+ ),
1039
+ h(Row, { label: '委派 Provider' },
1040
+ h(SelectControl, { value: draft.execSubagentProvider, options: providerOptions, onChange: (value) => set('execSubagentProvider', value) }),
1041
+ ),
1042
+ h(Row, { label: '审批超时 (ms)' },
1043
+ h(NumberControl, { value: draft.execApprovalTimeoutMs, onChange: (value) => set('execApprovalTimeoutMs', value) }),
1044
+ ),
1045
+ h(Row, { label: '委派超时 (ms)' },
1046
+ h(NumberControl, { value: draft.execDelegateTimeoutMs, onChange: (value) => set('execDelegateTimeoutMs', value) }),
1047
+ ),
1048
+ h('div', { style: sectionStyle }, '远端权限'),
1049
+ h(Row, { label: '允许的根目录', wide: true },
1050
+ h('textarea', {
1051
+ value: draft.rootsText,
1052
+ onChange: (event) => set('rootsText', event.target.value),
1053
+ rows: 3,
1054
+ style: textareaStyle,
1055
+ }),
1056
+ ),
1057
+ ...CAPABILITY_ROWS.map(([key, label]) =>
1058
+ h(Row, { key, label },
1059
+ h(SelectControl, { value: draft[key], options: modeOptions, onChange: (value) => set(key, value) }),
1060
+ ),
1061
+ ),
1062
+ h(Row, { label: '授权有效期 (ms)' },
1063
+ h(NumberControl, { value: draft.policyApprovalTtlMs, onChange: (value) => set('policyApprovalTtlMs', value) }),
1064
+ ),
1065
+ h('div', { style: sectionStyle }, '审批与提示'),
1066
+ h(Row, { label: '审批超时 (ms)' },
1067
+ h(NumberControl, { value: draft.approvalsTtlMs, onChange: (value) => set('approvalsTtlMs', value) }),
1068
+ ),
1069
+ h(Row, { label: '最大排队数' },
1070
+ h(NumberControl, { value: draft.approvalsMaxPending, onChange: (value) => set('approvalsMaxPending', value) }),
1071
+ ),
1072
+ h(Row, { label: '自动批准(测试)' },
1073
+ h(ToggleControl, { value: draft.autoApproveAsk, onChange: (value) => set('autoApproveAsk', value) }),
1074
+ ),
1075
+ h(Row, { label: 'Fleet 系统提示' },
1076
+ h(ToggleControl, { value: draft.promptSection, onChange: (value) => set('promptSection', value) }),
1077
+ ),
1078
+ h(Row, { label: '审批模拟(测试)' },
1079
+ h(ToggleControl, { value: draft.simulation, onChange: (value) => set('simulation', value) }),
1080
+ ),
1081
+ )
1082
+ }
1083
+
1084
+ // Room is a normal Session with participants. This dock keeps the member
1085
+ // strip inside the conversation instead of a separate dashboard.
1086
+ function RoomDock({ sessionId }) {
1087
+ const [room, setRoom] = useState(null);
1088
+ const [missing, setMissing] = useState(false);
1089
+ const [status, setStatus] = useState('');
1090
+
1091
+ const load = async () => {
1092
+ if (!sessionId) return;
1093
+ try {
1094
+ const response = await fetch('/device-fleet/room?id=' + encodeURIComponent(sessionId), { headers: { accept: 'application/json' } });
1095
+ if (response.status === 404) {
1096
+ setRoom(null);
1097
+ setMissing(true);
1098
+ return;
1099
+ }
1100
+ if (!response.ok) throw new Error('HTTP ' + response.status);
1101
+ const data = await response.json();
1102
+ setRoom(data.room ?? null);
1103
+ setMissing(false);
1104
+ } catch {
1105
+ setRoom(null);
1106
+ }
1107
+ };
1108
+
1109
+ useEffect(() => {
1110
+ void load();
1111
+ const timer = setInterval(load, 4000);
1112
+ return () => clearInterval(timer);
1113
+ }, [sessionId]);
1114
+
1115
+ if (!sessionId) return null;
1116
+ const upgrade = async () => {
1117
+ setStatus('升级中…');
1118
+ try {
1119
+ await postJson('/device-fleet/room/upgrade', { sessionId });
1120
+ setStatus('');
1121
+ await load();
1122
+ } catch (problem) {
1123
+ setStatus('升级失败:' + String(problem.message ?? problem));
1124
+ }
1125
+ };
1126
+
1127
+ if (missing) {
1128
+ return h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, opacity: 0.75, padding: '2px 2px 0' } },
1129
+ h('span', null, '普通会话'),
1130
+ status ? h('span', null, status) : null,
1131
+ smallButton('升级为 Room', () => void upgrade(), 'normal'),
1132
+ );
1133
+ }
1134
+ if (!room) return null;
1135
+ const names = room.members.filter((member) => member.role !== 'host').map((member) => member.name);
1136
+ return h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, opacity: 0.8, padding: '2px 2px 0', flexWrap: 'wrap' } },
1137
+ pill('Room', 'ok'),
1138
+ h('span', null, room.members.length + ' 名成员'),
1139
+ names.length > 0 ? h('span', null, '· ' + names.join(', ')) : h('span', null, '· 仅 host'),
1140
+ h('span', { style: { flex: 1 } }),
1141
+ h('span', { style: { opacity: 0.55 } }, '房间消息会同步到参与者'),
1142
+ );
1143
+ }
1144
+
1145
+ return {
1146
+ inject: ['slots'],
1147
+ apply(ctx) {
1148
+ ctx.effect(() => startPolling());
1149
+ ctx.slots.inject('sidebar.footer.action', () =>
1150
+ ctx.slots.register({ name: 'sidebar.footer.action', id: 'fleet-approvals', order: 5, label: 'Fleet 审批' }, Trigger),
1151
+ );
1152
+ // shell.overlay is a root slot that exists from boot (the dev.10 build
1153
+ // rendered from a direct registration); register directly so the dialog
1154
+ // mounts even if the lazy inject callback never fires.
1155
+ ctx.slots.register({ name: 'shell.overlay', id: 'fleet-approvals-panel', order: 10 }, Panel);
1156
+ // Global Fleet console: sidebar icon + matching main panel.
1157
+ ctx.slots.inject('sidebar.panellist', () =>
1158
+ ctx.slots.register({ name: 'sidebar.panellist', id: 'fleet', order: 20, label: '设备联邦' }, FleetIcon),
1159
+ );
1160
+ ctx.slots.register({ name: 'main', key: 'fleet' }, FleetConsole);
1161
+ // Session-native Room: member strip below the composer; the normal Chat
1162
+ // stream carries the conversation.
1163
+ ctx.slots.inject('conversation.composer.dock', () =>
1164
+ ctx.slots.register({ name: 'conversation.composer.dock', id: 'fleet-room', order: 10 }, RoomDock),
1165
+ );
1166
+ // Configuration page: Settings → Plugins → Device Fleet (row + bundle).
1167
+ ctx.slots.inject('plugins.row.config', () =>
1168
+ ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-device-fleet#device-fleet' }, FleetConfig),
1169
+ );
1170
+ ctx.slots.inject('plugins.bundle.config', () =>
1171
+ ctx.slots.register({ name: 'plugins.bundle.config', key: 'dsh-device-fleet' }, FleetConfig),
1172
+ );
1173
+ },
1174
+ };
1175
+ },
1176
+ });