dsh-device-fleet 0.1.0-dev.12 → 0.1.0-dev.14

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/README.md CHANGED
@@ -9,8 +9,9 @@ DeepSeek Harness 的设备联邦插件:把登录同一账号、且都安装本
9
9
  - ✅ **Host 插件 + 直调 MVP(代码完成、单测通过,待真实 DSH 安装联调)**:`fleet_list` / `fleet_tools` / `fleet_call` + `fleet_read` / `fleet_fetch` / `fleet_run`、Tool Policy、Execution Slot、审批 fail-closed
10
10
  - ✅ **审批收件箱逻辑层(dev.7)**:`fleet_approvals` / `fleet_approve` / `fleet_deny`,ask 档排队、超时 fail-closed、once/ttl 授权、`fleet_simulate_request` 本机演练;Client 审批面板待做
11
11
  - ✅ **Room 逻辑层(dev.8,单机模拟)**:`room_create` / `room_add_local` / `room_send` / `room_messages` / `room_status` / `room_task_*` / `room_close`;本地真实 agent 参与者、寻址唤醒、Room Board 串行交接;单机仿真验证通过
12
- - ✅ **审批面板 UI(dev.10)**:Host `/device-fleet/approvals` HTTP 数据面 + Client `shell.overlay` 浮动审批按钮/面板(允许一次/15 分钟/拒绝);单测 14/14;刷新页面后可见
13
- - ⏳ 远程参与者传输、房间视图、插件重载 rehydrate、SSE 推送(下一步)
12
+ - ✅ **审批面板 UI(dev.12,已验证)**:Host `/device-fleet/approvals` HTTP 数据面 + Client 入口在 Settings 上方、居中审批对话框(允许一次/15 分钟/拒绝);用户点击「允许」后 Host 列表清空,端到端闭环
13
+ - ✅ **Fleet 控制台(dev.13,Phase A)**:Host `/device-fleet/overview`、`/rooms`、`/rooms/:id`;Room 持久化到 `$DSH_HOME/device-fleet/rooms.json` 并 rehydrate(插件重载不丢房间);Client `sidebar.panellist` + `main` 面板,设备/房间/审批三 Tab
14
+ - ⏳ Phase B 配置页(即时生效,无需重启)、Phase C 房间/配对交互、Phase D SSE/通知/i18n/a11y
14
15
 
15
16
  ## 安装(开发版)
16
17
 
package/client.js CHANGED
@@ -213,6 +213,562 @@ window.__ModuleLoader__.load({
213
213
  );
214
214
  }
215
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
+ function FleetIcon({ size = 20, active = false }) {
251
+ return h(
252
+ 'svg',
253
+ {
254
+ width: size,
255
+ height: size,
256
+ viewBox: '0 0 24 24',
257
+ fill: 'none',
258
+ stroke: 'currentColor',
259
+ strokeWidth: 1.8,
260
+ strokeLinecap: 'round',
261
+ 'aria-hidden': true,
262
+ style: { opacity: active ? 1 : 0.75 },
263
+ },
264
+ h('circle', { cx: 12, cy: 12, r: 8.5 }),
265
+ h('ellipse', { cx: 12, cy: 12, rx: 3.6, ry: 8.5 }),
266
+ h('path', { d: 'M3.8 12h16.4' }),
267
+ );
268
+ }
269
+
270
+ function FleetConsole() {
271
+ const [tab, setTab] = useState('devices');
272
+ const [overview, setOverview] = useState(null);
273
+ const [rooms, setRooms] = useState([]);
274
+ const [selectedRoom, setSelectedRoom] = useState(null);
275
+ const [roomDetail, setRoomDetail] = useState(null);
276
+ const [error, setError] = useState('');
277
+
278
+ useEffect(() => {
279
+ let disposed = false;
280
+ const tick = async () => {
281
+ try {
282
+ const response = await fetch('/device-fleet/overview', { headers: { accept: 'application/json' } });
283
+ if (!response.ok) throw new Error('HTTP ' + response.status);
284
+ const data = await response.json();
285
+ if (!disposed) {
286
+ setOverview(data);
287
+ setRooms(Array.isArray(data.rooms) ? data.rooms : []);
288
+ setError('');
289
+ }
290
+ } catch (problem) {
291
+ if (!disposed) setError(String(problem.message ?? problem));
292
+ }
293
+ };
294
+ tick();
295
+ const timer = setInterval(tick, 3000);
296
+ return () => {
297
+ disposed = true;
298
+ clearInterval(timer);
299
+ };
300
+ }, []);
301
+
302
+ useEffect(() => {
303
+ if (!selectedRoom) {
304
+ setRoomDetail(null);
305
+ return undefined;
306
+ }
307
+ let disposed = false;
308
+ const load = async () => {
309
+ try {
310
+ const response = await fetch('/device-fleet/rooms/' + encodeURIComponent(selectedRoom), { headers: { accept: 'application/json' } });
311
+ if (!response.ok) throw new Error('HTTP ' + response.status);
312
+ const data = await response.json();
313
+ if (!disposed) setRoomDetail(data.room ?? null);
314
+ } catch {
315
+ if (!disposed) setRoomDetail(null);
316
+ }
317
+ };
318
+ load();
319
+ const timer = setInterval(load, 3000);
320
+ return () => {
321
+ disposed = true;
322
+ clearInterval(timer);
323
+ };
324
+ }, [selectedRoom]);
325
+
326
+ const decide = async (id, action, scope) => {
327
+ await fetch('/device-fleet/approvals/decide', {
328
+ method: 'POST',
329
+ headers: { 'content-type': 'application/json' },
330
+ body: JSON.stringify({ id, action, ...(scope ? { scope } : {}) }),
331
+ });
332
+ setOverview((current) => (current ? { ...current, approvals: (current.approvals ?? []).filter((item) => item.id !== id) } : current));
333
+ };
334
+
335
+ const tabs = [
336
+ ['devices', '设备'],
337
+ ['rooms', '房间'],
338
+ ['approvals', '审批'],
339
+ ];
340
+ const header = h(
341
+ 'div',
342
+ { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 } },
343
+ h('strong', { style: { fontSize: 15 } }, '设备联邦'),
344
+ overview ? pill('本机 ' + (overview.name ?? '') + ' · ' + overview.pluginVersion, 'muted') : null,
345
+ h('span', { style: { flex: 1 } }),
346
+ ...tabs.map(([id, label]) =>
347
+ h(
348
+ 'button',
349
+ {
350
+ key: id,
351
+ type: 'button',
352
+ onClick: () => setTab(id),
353
+ style: {
354
+ padding: '4px 10px',
355
+ borderRadius: 999,
356
+ border: '1px solid ' + (tab === id ? 'var(--dsw-alias-accent, #2f6fed)' : 'var(--dsw-alias-border, #3a3a3a)'),
357
+ background: tab === id ? 'var(--dsw-alias-accent, #2f6fed)' : 'transparent',
358
+ color: 'inherit',
359
+ cursor: 'pointer',
360
+ fontSize: 12,
361
+ },
362
+ },
363
+ label + (id === 'approvals' && (overview?.approvals?.length ?? 0) > 0 ? ' ' + overview.approvals.length : ''),
364
+ ),
365
+ ),
366
+ );
367
+
368
+ if (!overview) {
369
+ return h('div', { style: { padding: 16 } }, header, h('div', { style: { opacity: 0.7 } }, error ? '加载失败:' + error : '加载中…'));
370
+ }
371
+
372
+ let body = null;
373
+ if (tab === 'devices') {
374
+ const connected = new Set(Array.isArray(overview.connected) ? overview.connected : []);
375
+ body = h(
376
+ 'div',
377
+ null,
378
+ h('div', { style: cardStyle },
379
+ h('div', { style: sectionTitle }, '本机'),
380
+ h('div', null, overview.name + ' · ' + overview.deviceId),
381
+ h('div', { style: { opacity: 0.75, fontSize: 12, marginTop: 4 } },
382
+ '直连 ' + (overview.direct ? overview.direct.host + ':' + overview.direct.port : '未监听') +
383
+ ' · relay ' + (overview.relay?.online ? '在线' : '离线') +
384
+ ' · 发现 ' + ((overview.discovery?.peers ?? []).length) + ' 个'),
385
+ ),
386
+ h('div', { style: cardStyle },
387
+ h('div', { style: sectionTitle }, 'Fleet 成员(' + (overview.roster?.length ?? 0) + ' 台已配对)'),
388
+ (overview.roster ?? []).length === 0
389
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '还没有配对设备;在任一台设备上生成邀请码完成配对。')
390
+ : (overview.roster ?? []).map((member) =>
391
+ h('div', { key: member.deviceId, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0' } },
392
+ h('span', { style: { flex: 1 } }, member.name + ' · ' + member.deviceId.slice(0, 8) + '…'),
393
+ connected.has(member.deviceId) ? pill('在线', 'ok') : pill('离线', 'muted'),
394
+ ),
395
+ ),
396
+ ),
397
+ (overview.peerHints ?? []).length > 0
398
+ ? h('div', { style: cardStyle },
399
+ h('div', { style: sectionTitle }, '回连地址'),
400
+ ...overview.peerHints.map((hint, index) =>
401
+ h('div', { key: index, style: { fontSize: 12, fontFamily: 'ui-monospace, monospace', opacity: 0.8 } }, hint.host + ':' + hint.port),
402
+ ),
403
+ )
404
+ : null,
405
+ );
406
+ } else if (tab === 'rooms') {
407
+ body = h(
408
+ 'div',
409
+ { style: { display: 'flex', gap: 14, alignItems: 'flex-start', flexWrap: 'wrap' } },
410
+ h('div', { style: { flex: '1 1 260px', minWidth: 240 } },
411
+ h('div', { style: sectionTitle }, '房间(' + rooms.length + ')'),
412
+ rooms.length === 0
413
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '还没有房间。让 agent 在当前会话执行 room_create 升级为 Room Host。')
414
+ : rooms.map((room) =>
415
+ h(
416
+ 'button',
417
+ {
418
+ key: room.roomId,
419
+ type: 'button',
420
+ onClick: () => setSelectedRoom(room.roomId === selectedRoom ? null : room.roomId),
421
+ style: {
422
+ ...cardStyle,
423
+ display: 'block',
424
+ width: '100%',
425
+ textAlign: 'left',
426
+ cursor: 'pointer',
427
+ borderColor: selectedRoom === room.roomId ? 'var(--dsw-alias-accent, #2f6fed)' : undefined,
428
+ marginBottom: 8,
429
+ },
430
+ },
431
+ h('div', { style: { fontWeight: 600 } }, 'Room ' + room.roomId.slice(-8)),
432
+ h('div', { style: { fontSize: 12, opacity: 0.7, marginTop: 3 } },
433
+ room.members.length + ' 名成员 · ' + room.messageCount + ' 条消息 · ' + room.tasks + ' 个任务'),
434
+ ),
435
+ ),
436
+ ),
437
+ h('div', { style: { flex: '2 1 420px', minWidth: 320 } }, selectedRoom ? renderRoomDetail(roomDetail) : h('div', { style: { opacity: 0.65, fontSize: 12 } }, '选择一个房间查看成员、时间线与看板。')),
438
+ );
439
+ } else {
440
+ const approvals = overview.approvals ?? [];
441
+ body = h(
442
+ 'div',
443
+ { style: cardStyle },
444
+ h('div', { style: sectionTitle }, '待审批请求(' + approvals.length + ')'),
445
+ approvals.length === 0
446
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无待审批请求')
447
+ : approvals.map((item) =>
448
+ h('div', { key: item.id, style: { borderTop: '1px solid var(--dsw-alias-border, #333)', padding: '9px 0' } },
449
+ h('div', { style: { fontWeight: 600 } }, (item.capability || 'tool') + (item.tool ? ' / ' + item.tool : '')),
450
+ h('div', { style: { opacity: 0.75, fontSize: 12 } }, '来自 ' + item.fromDeviceId + ' · ' + Math.max(0, Math.round(item.expiresInMs / 1000)) + 's 后超时'),
451
+ item.reason ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, item.reason) : null,
452
+ h('div', { style: { display: 'flex', gap: 6, marginTop: 5, flexWrap: 'wrap' } },
453
+ smallButton('允许一次', () => decide(item.id, 'approve'), 'allow'),
454
+ smallButton('允许 15 分钟', () => decide(item.id, 'approve', 'ttl'), 'allow'),
455
+ smallButton('拒绝', () => decide(item.id, 'deny'), 'deny'),
456
+ ),
457
+ ),
458
+ ),
459
+ );
460
+ }
461
+
462
+ return h('div', { style: { padding: '14px 16px', height: '100%', overflow: 'auto', boxSizing: 'border-box' } }, header, body);
463
+ }
464
+
465
+ function renderRoomDetail(detail) {
466
+ if (!detail) return h('div', { style: { opacity: 0.65, fontSize: 12 } }, '加载房间…');
467
+ return h(
468
+ 'div',
469
+ null,
470
+ h('div', { style: cardStyle },
471
+ h('div', { style: sectionTitle }, '成员'),
472
+ ...detail.members.map((member) =>
473
+ h('div', { key: member.memberId, style: { padding: '3px 0', fontSize: 12 } },
474
+ pill(member.role, member.role === 'host' ? 'ok' : 'muted'),
475
+ member.name + ' · ' + member.memberId,
476
+ ),
477
+ ),
478
+ ),
479
+ h('div', { style: cardStyle },
480
+ h('div', { style: sectionTitle }, '看板(' + detail.tasks.length + ')'),
481
+ detail.tasks.length === 0
482
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无任务')
483
+ : detail.tasks.map((task) =>
484
+ h('div', { key: task.id, style: { padding: '4px 0', fontSize: 12 } },
485
+ pill(task.status, task.status === 'done' ? 'ok' : 'muted'),
486
+ task.title + ' · owner ' + task.owner + (task.dependsOn.length ? ' · deps ' + task.dependsOn.length : ''),
487
+ ),
488
+ ),
489
+ ),
490
+ h('div', { style: cardStyle },
491
+ h('div', { style: sectionTitle }, '时间线'),
492
+ detail.messages.length === 0
493
+ ? h('div', { style: { opacity: 0.7, fontSize: 12 } }, '暂无消息')
494
+ : detail.messages.map((message) =>
495
+ h('div', { key: message.id, style: { padding: '5px 0', borderTop: '1px solid var(--dsw-alias-border, #333)' } },
496
+ h('div', { style: { fontSize: 11, opacity: 0.6 } }, '[' + fmtTime(message.createdAt) + '] ' + message.fromLabel + ' → ' + message.to.join(', ')),
497
+ h('div', { style: { whiteSpace: 'pre-wrap', marginTop: 2 } }, message.text),
498
+ ),
499
+ ),
500
+ ),
501
+ );
502
+ }
503
+
504
+ const inputStyle = {
505
+ width: '100%',
506
+ boxSizing: 'border-box',
507
+ padding: '5px 8px',
508
+ borderRadius: 6,
509
+ border: '1px solid var(--dsw-alias-border, #3a3a3a)',
510
+ background: 'transparent',
511
+ color: 'inherit',
512
+ fontSize: 13,
513
+ };
514
+ const labelStyle = { display: 'block', margin: '4px 0 10px' };
515
+ const labelTextStyle = { fontSize: 12, opacity: 0.72, marginBottom: 3 };
516
+
517
+ function Field({ label, hint, children }) {
518
+ return h('label', { style: labelStyle }, h('div', { style: labelTextStyle }, label), children, hint ? h('div', { style: { fontSize: 11, opacity: 0.55, marginTop: 2 } }, hint) : null);
519
+ }
520
+
521
+ function TextField({ label, value, onChange, hint, type = 'text' }) {
522
+ return h(Field, { label, hint }, h('input', { type, value: value ?? '', onChange: (event) => onChange(event.target.value), style: inputStyle }));
523
+ }
524
+
525
+ function NumberField({ label, value, onChange, hint }) {
526
+ return h(Field, { label, hint }, h('input', { type: 'number', value: value ?? 0, onChange: (event) => onChange(Number(event.target.value || 0)), style: inputStyle }));
527
+ }
528
+
529
+ function ToggleField({ label, checked, onChange }) {
530
+ return h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 10px', fontSize: 13 } },
531
+ h('input', { type: 'checkbox', checked: checked === true, onChange: (event) => onChange(event.target.checked) }),
532
+ h('span', null, label),
533
+ );
534
+ }
535
+
536
+ function SelectField({ label, value, options, onChange }) {
537
+ return h(Field, { label }, h('select', { value, onChange: (event) => onChange(event.target.value), style: inputStyle },
538
+ ...options.map((option) => h('option', { key: option, value: option }, option)),
539
+ ));
540
+ }
541
+
542
+ function configToDraft(config = {}) {
543
+ const execution = config.execution ?? {};
544
+ const policy = config.policy ?? {};
545
+ const approvals = config.approvals ?? {};
546
+ const direct = config.direct ?? {};
547
+ const relay = config.relay ?? {};
548
+ return {
549
+ deviceName: config.deviceName ?? '',
550
+ directHost: direct.host ?? '0.0.0.0',
551
+ directPort: direct.port ?? 0,
552
+ discovery: config.discovery !== false,
553
+ relayHost: relay.host ?? '',
554
+ relayPort: relay.port ?? 0,
555
+ relayDataPort: relay.dataPort ?? 0,
556
+ execCwd: execution.cwd ?? '',
557
+ execPreset: execution.agentPreset ?? '',
558
+ execShellTool: execution.shellTool ?? '',
559
+ execApprovalTimeoutMs: execution.approvalTimeoutMs ?? 60000,
560
+ execDelegateTimeoutMs: execution.delegateTimeoutMs ?? 180000,
561
+ execSubagentProvider: execution.subagentProvider ?? 'spawn',
562
+ rootsText: (policy.allowedRoots ?? []).join('\n'),
563
+ toolsText: (policy.allowedTools ?? []).join('\n'),
564
+ capRead: policy.capabilities?.read ?? 'auto',
565
+ capList: policy.capabilities?.list ?? 'auto',
566
+ capGrep: policy.capabilities?.grep ?? 'auto',
567
+ capFetch: policy.capabilities?.fetch ?? 'auto',
568
+ capRun: policy.capabilities?.run ?? 'ask',
569
+ capTool: policy.capabilities?.tool ?? 'deny',
570
+ capDelegate: policy.capabilities?.delegate ?? 'ask',
571
+ policyApprovalTtlMs: policy.approvalTtlMs ?? 900000,
572
+ approvalsTtlMs: approvals.ttlMs ?? 60000,
573
+ approvalsMaxPending: approvals.maxPending ?? 50,
574
+ autoApproveAsk: config.autoApproveAsk === true,
575
+ promptSection: config.promptSection !== false,
576
+ simulation: approvals.simulation === true,
577
+ dataDir: config.dataDir ?? '',
578
+ };
579
+ }
580
+
581
+ function draftToPatch(draft) {
582
+ return {
583
+ deviceName: draft.deviceName,
584
+ direct: { host: draft.directHost, port: draft.directPort },
585
+ discovery: draft.discovery,
586
+ relay: draft.relayHost && draft.relayPort > 0
587
+ ? { host: draft.relayHost, port: draft.relayPort, dataPort: draft.relayDataPort }
588
+ : null,
589
+ execution: {
590
+ cwd: draft.execCwd,
591
+ agentPreset: draft.execPreset,
592
+ shellTool: draft.execShellTool,
593
+ approvalTimeoutMs: draft.execApprovalTimeoutMs,
594
+ delegateTimeoutMs: draft.execDelegateTimeoutMs,
595
+ subagentProvider: draft.execSubagentProvider,
596
+ },
597
+ policy: {
598
+ allowedRoots: draft.rootsText.split('\n').map((item) => item.trim()).filter(Boolean),
599
+ allowedTools: draft.toolsText.split('\n').map((item) => item.trim()).filter(Boolean),
600
+ capabilities: {
601
+ read: draft.capRead,
602
+ list: draft.capList,
603
+ grep: draft.capGrep,
604
+ fetch: draft.capFetch,
605
+ run: draft.capRun,
606
+ tool: draft.capTool,
607
+ delegate: draft.capDelegate,
608
+ },
609
+ approvalTtlMs: draft.policyApprovalTtlMs,
610
+ },
611
+ approvals: {
612
+ ttlMs: draft.approvalsTtlMs,
613
+ maxPending: draft.approvalsMaxPending,
614
+ simulation: draft.simulation,
615
+ },
616
+ autoApproveAsk: draft.autoApproveAsk,
617
+ promptSection: draft.promptSection,
618
+ };
619
+ }
620
+
621
+ function FleetConfig() {
622
+ const [draft, setDraft] = useState(null);
623
+ const [status, setStatus] = useState('');
624
+ const [busy, setBusy] = useState(false);
625
+
626
+ const load = async () => {
627
+ try {
628
+ const response = await fetch('/device-fleet/config', { headers: { accept: 'application/json' } });
629
+ if (!response.ok) throw new Error('HTTP ' + response.status);
630
+ const data = await response.json();
631
+ setDraft(configToDraft(data.config));
632
+ } catch (problem) {
633
+ setStatus('加载配置失败:' + String(problem.message ?? problem));
634
+ }
635
+ };
636
+
637
+ useEffect(() => {
638
+ void load();
639
+ }, []);
640
+
641
+ if (!draft) return h('div', { style: { padding: 8, opacity: 0.7 } }, status || '加载中…');
642
+
643
+ const set = (key, value) => setDraft((current) => ({ ...current, [key]: value }));
644
+ const save = async () => {
645
+ setBusy(true);
646
+ setStatus('');
647
+ try {
648
+ const response = await fetch('/device-fleet/config', {
649
+ method: 'POST',
650
+ headers: { 'content-type': 'application/json' },
651
+ body: JSON.stringify(draftToPatch(draft)),
652
+ });
653
+ const data = await response.json();
654
+ if (!response.ok) throw new Error(data.error ?? 'HTTP ' + response.status);
655
+ setDraft(configToDraft(data.config));
656
+ setStatus('已应用(无需重启插件或 DSH)');
657
+ } catch (problem) {
658
+ setStatus('保存失败:' + String(problem.message ?? problem));
659
+ } finally {
660
+ setBusy(false);
661
+ }
662
+ };
663
+
664
+ const sectionCard = (title, children, hint) =>
665
+ h('div', { style: { ...cardStyle, padding: 14 } },
666
+ h('div', { style: sectionTitle }, title),
667
+ hint ? h('div', { style: { fontSize: 11, opacity: 0.55, marginBottom: 8 } }, hint) : null,
668
+ children,
669
+ );
670
+
671
+ const capabilityFields = [
672
+ ['capRead', 'read 读取'],
673
+ ['capList', 'list 列目录'],
674
+ ['capGrep', 'grep 搜索'],
675
+ ['capFetch', 'fetch 取文件'],
676
+ ['capRun', 'run 执行命令'],
677
+ ['capTool', 'tool 原始工具'],
678
+ ['capDelegate', 'delegate 委派'],
679
+ ];
680
+ const twoCol = (left, right) => h('div', { style: { display: 'flex', gap: 12 } }, h('div', { style: { flex: 1 } }, left), h('div', { style: { flex: 1 } }, right));
681
+
682
+ return h('div', { style: { padding: '2px 2px 24px', maxWidth: 760 } },
683
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10, flexWrap: 'wrap' } },
684
+ h('strong', null, 'Device Fleet 配置'),
685
+ h('span', { style: { flex: 1 } }),
686
+ status ? h('span', { style: { fontSize: 12, opacity: 0.8 } }, status) : null,
687
+ h('button', {
688
+ type: 'button',
689
+ disabled: busy,
690
+ onClick: () => void save(),
691
+ style: {
692
+ padding: '6px 14px',
693
+ borderRadius: 7,
694
+ border: '1px solid var(--dsw-alias-accent, #2f6fed)',
695
+ background: 'var(--dsw-alias-accent, #2f6fed)',
696
+ color: '#fff',
697
+ cursor: busy ? 'default' : 'pointer',
698
+ opacity: busy ? 0.6 : 1,
699
+ },
700
+ }, busy ? '保存中…' : '保存并立即生效'),
701
+ ),
702
+ h('div', { style: { fontSize: 12, opacity: 0.6, marginBottom: 12 } }, '保存后立即对运行中的插件生效,不需要重启插件或 DSH。dataDir 为只读(启动路径)。'),
703
+ sectionCard('设备与网络',
704
+ h('div', null,
705
+ h(TextField, { label: '设备名称', value: draft.deviceName, onChange: (value) => set('deviceName', value) }),
706
+ twoCol(
707
+ h(TextField, { label: '直连绑定地址', value: draft.directHost, onChange: (value) => set('directHost', value) }),
708
+ h(NumberField, { label: '直连端口(0=随机)', value: draft.directPort, onChange: (value) => set('directPort', value) }),
709
+ ),
710
+ h(ToggleField, { label: '局域网发现(UDP 组播)', checked: draft.discovery, onChange: (value) => set('discovery', value) }),
711
+ h('div', { style: { ...labelTextStyle, marginTop: 10 } }, '自建 Relay(v2,可选)'),
712
+ twoCol(
713
+ h(TextField, { label: 'Relay host', value: draft.relayHost, onChange: (value) => set('relayHost', value) }),
714
+ h(NumberField, { label: 'Relay control 端口', value: draft.relayPort, onChange: (value) => set('relayPort', value) }),
715
+ ),
716
+ h(NumberField, { label: 'Relay data 端口', value: draft.relayDataPort, onChange: (value) => set('relayDataPort', value) }),
717
+ )),
718
+ sectionCard('执行上下文(远端调用/委派在本机执行时使用)',
719
+ h('div', null,
720
+ twoCol(
721
+ h(TextField, { label: '工作目录 cwd', value: draft.execCwd, onChange: (value) => set('execCwd', value) }),
722
+ h(TextField, { label: 'Agent Preset', value: draft.execPreset, onChange: (value) => set('execPreset', value) }),
723
+ ),
724
+ twoCol(
725
+ h(TextField, { label: 'shell 工具名', value: draft.execShellTool, onChange: (value) => set('execShellTool', value) }),
726
+ h(TextField, { label: '委派 subagent provider', value: draft.execSubagentProvider, onChange: (value) => set('execSubagentProvider', value) }),
727
+ ),
728
+ twoCol(
729
+ h(NumberField, { label: '审批超时 (ms)', value: draft.execApprovalTimeoutMs, onChange: (value) => set('execApprovalTimeoutMs', value) }),
730
+ h(NumberField, { label: '委派超时 (ms)', value: draft.execDelegateTimeoutMs, onChange: (value) => set('execDelegateTimeoutMs', value) }),
731
+ ),
732
+ )),
733
+ sectionCard('远端权限(Tool Policy:别人能对你做什么)',
734
+ h('div', null,
735
+ h(Field, { label: '允许访问的根目录(每行一个)' },
736
+ h('textarea', {
737
+ value: draft.rootsText,
738
+ onChange: (event) => set('rootsText', event.target.value),
739
+ rows: 3,
740
+ style: { ...inputStyle, fontFamily: 'ui-monospace, monospace', resize: 'vertical' },
741
+ }),
742
+ ),
743
+ h(Field, { label: '允许的原始工具(每行一个,配合 tool 能力)' },
744
+ h('textarea', {
745
+ value: draft.toolsText,
746
+ onChange: (event) => set('toolsText', event.target.value),
747
+ rows: 3,
748
+ style: { ...inputStyle, fontFamily: 'ui-monospace, monospace', resize: 'vertical' },
749
+ }),
750
+ ),
751
+ h('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 12px' } },
752
+ ...capabilityFields.map(([key, label]) =>
753
+ h(SelectField, { key, label, value: draft[key], options: ['auto', 'ask', 'deny'], onChange: (value) => set(key, value) }),
754
+ ),
755
+ ),
756
+ h(NumberField, { label: '一次授权的 TTL (ms)', value: draft.policyApprovalTtlMs, onChange: (value) => set('policyApprovalTtlMs', value) }),
757
+ )),
758
+ sectionCard('本地审批与提示',
759
+ h('div', null,
760
+ twoCol(
761
+ h(NumberField, { label: '审批请求超时 (ms)', value: draft.approvalsTtlMs, onChange: (value) => set('approvalsTtlMs', value) }),
762
+ h(NumberField, { label: '最多排队请求数', value: draft.approvalsMaxPending, onChange: (value) => set('approvalsMaxPending', value) }),
763
+ ),
764
+ h(ToggleField, { label: '自动批准所有审批请求(仅测试)', checked: draft.autoApproveAsk, onChange: (value) => set('autoApproveAsk', value) }),
765
+ h(ToggleField, { label: '系统提示词中的 Fleet 提示', checked: draft.promptSection, onChange: (value) => set('promptSection', value) }),
766
+ h(ToggleField, { label: '允许 fleet_simulate_request(测试用)', checked: draft.simulation, onChange: (value) => set('simulation', value) }),
767
+ h(TextField, { label: '状态目录 dataDir(只读,需重启)', value: draft.dataDir, onChange: () => {} }),
768
+ )),
769
+ );
770
+ }
771
+
216
772
  return {
217
773
  inject: ['slots'],
218
774
  apply(ctx) {
@@ -224,6 +780,18 @@ window.__ModuleLoader__.load({
224
780
  // rendered from a direct registration); register directly so the dialog
225
781
  // mounts even if the lazy inject callback never fires.
226
782
  ctx.slots.register({ name: 'shell.overlay', id: 'fleet-approvals-panel', order: 10 }, Panel);
783
+ // Global Fleet console: sidebar icon + matching main panel.
784
+ ctx.slots.inject('sidebar.panellist', () =>
785
+ ctx.slots.register({ name: 'sidebar.panellist', id: 'fleet', order: 20, label: '设备联邦' }, FleetIcon),
786
+ );
787
+ ctx.slots.register({ name: 'main', key: 'fleet' }, FleetConsole);
788
+ // Configuration page: Settings → Plugins → Device Fleet (row + bundle).
789
+ ctx.slots.inject('plugins.row.config', () =>
790
+ ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-device-fleet#device-fleet' }, FleetConfig),
791
+ );
792
+ ctx.slots.inject('plugins.bundle.config', () =>
793
+ ctx.slots.register({ name: 'plugins.bundle.config', key: 'dsh-device-fleet' }, FleetConfig),
794
+ );
227
795
  },
228
796
  };
229
797
  },
@@ -1,38 +1,38 @@
1
- # 审批面板 UI(dev.9 / dev.10)
1
+ # 审批面板 UI(dev.9 → dev.12)
2
2
 
3
3
  ## Host 数据面
4
4
 
5
5
  `src/host/approval-web.js`:
6
6
 
7
- - `GET /device-fleet/approvals` → `{ approvals: [...] }`(来自 ApprovalInbox.list);
7
+ - `GET /device-fleet/approvals` → `{ approvals: [...] }`(ApprovalInbox.list);
8
8
  - `POST /device-fleet/approvals/decide`,body `{ id, action: "approve"|"deny", scope?, reason? }` → 决策走 ApprovalInbox;
9
- - 仅注册在 DSH web 载体的本地监听上;决策仍受 ApprovalInbox 的超时/关闭/未知 id 约束;
10
- - 纯逻辑 `createApprovalApi` 与路由注册分离,便于单测;路由在插件卸载时 dispose。
9
+ - 路由注册在插件 apply 中、卸载时 dispose;纯逻辑 `createApprovalApi` 与路由分离便于单测。
11
10
 
12
11
  ## Client 半
13
12
 
14
- `client.js`(闭包工厂,`window.__ModuleLoader__.load`,id = `dsh-device-fleet`):
13
+ `client.js`(闭包工厂,id = `dsh-device-fleet`):
15
14
 
16
- - 注册 `shell.overlay`(root)列表项 `fleet-approvals`,右下角浮动按钮「Fleet 审批 (n)」;
17
- - 2.5s 轮询 `/device-fleet/approvals`,有请求时按钮高亮;点开面板显示来源设备/能力/工具/参数摘要/剩余超时;
18
- - 按钮:允许一次 / 允许 15 分钟(ttl)/ 拒绝,POST 到 decide 后从列表移除;
19
- - 使用 `--dsw-alias-*` token(带 fallback),不覆盖宿主根、不读其他插件 DOM。
15
+ - **入口**:`sidebar.footer.action`(Settings 上方),显示 `🛰️ 审批`,有待审批时带数字并高亮;不再占用 `shell.overlay` 右下角,避免与任务列表 UI 重叠;
16
+ - **面板**:`shell.overlay` 直接注册的居中模态对话框(遮罩、Esc/点遮罩/收起关闭);
17
+ - 2.5s 轮询 `/device-fleet/approvals`;按钮「允许一次 / 允许 15 分钟(ttl) / 拒绝」POST 到 decide;
18
+ - 共享模块级 store + `ctx.effect` 启动/清理轮询;使用 `--dsw-alias-*` token(带 fallback)。
20
19
 
21
- `package.json` 增加 `dsh.client = { platform: web, immediately: true, inject: ["@deepseek-ai/dsh-client-runtime"] }` 与 `./client` 导出、`files` 包含 client.js。
20
+ ## 修复记录
22
21
 
23
- ## 验证
22
+ - dev.10:右下角浮动按钮可用,但会遮挡任务列表(用户截图确认);
23
+ - dev.11:入口迁到 sidebar footer;发现点击无反应;
24
+ - dev.12:修复 `Panel` 的条件 Hook 顺序(关闭时提前 return,打开后才调 `useEffect`,导致 React 渲染报错);同时把 `shell.overlay` 改回直接注册(dev.10 已验证可用路径)。
24
25
 
25
- - 单测:`approval HTTP API lists and decides requests, and registers two routes`(列表/决策/路由注册/GET handler);
26
- - Host 单测总计 **14/14** 通过,网络层 9/9;
27
- - Host 路由注册:插件 dev.9/10 激活成功(无重复路由错误);
28
- - Client 视觉:**未验证**(无浏览器控制;需要刷新页面加载 client bundle),`node --check client.js` 通过。
26
+ ## 端到端验证(已完成)
29
27
 
30
- ## 使用
31
-
32
- 刷新当前 Web 页面(F5)后,右下角应出现「Fleet 审批」浮动按钮。用 `fleet_simulate_request` 造一条请求即可看到面板内容;点击允许/拒绝后 Host 端 `fleet_approvals` 同步清空。
28
+ 1. 刷新页面后 Settings 上方出现「🛰️ 审批」;
29
+ 2. 点击后居中对话框正常打开并显示待审批条目(用户确认);
30
+ 3. `fleet_simulate_request` 造请求 `dae59ce4-…`(run/pwsh,simulated-device-B);
31
+ 4. 用户在面板点击「允许一次」;
32
+ 5. Host `fleet_approvals` 返回 `(no pending approvals)` → **UI → HTTP → ApprovalInbox 写回链路闭环**。
33
33
 
34
34
  ## 限制
35
35
 
36
- - 面板轮询而非推送,规模大时应换 SSE/事件;
37
- - 路由未额外鉴权(依赖 web 载体自身的本地/会话边界);
38
- - 面板不含房间视图(下一版可加 Room 时间线/看板页)。
36
+ - 轮询而非 SSE 推送;
37
+ - 路由未额外鉴权(依赖 web 载体本地/会话边界);
38
+ - 面板暂无房间视图(时间线/看板页下一步)。