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 +3 -2
- package/client.js +568 -0
- package/docs/notes/approval-panel-ui.md +21 -21
- package/docs/notes/dev-release.md +5 -5
- package/docs/notes/fleet-console-ui.md +44 -0
- package/docs/notes/ui-surface-audit.md +93 -0
- package/package.json +1 -1
- package/src/host/config-web.js +61 -0
- package/src/host/execution-slot.js +8 -0
- package/src/host/index.js +3 -0
- package/src/host/room-service.js +103 -3
- package/src/host/runtime-config.js +192 -0
- package/src/host/ui-web.js +78 -0
- package/src/net/fleet-node.js +22 -0
- package/src/plugin/index.js +100 -28
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.
|
|
13
|
-
-
|
|
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
|
|
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: [...] }
|
|
7
|
+
- `GET /device-fleet/approvals` → `{ approvals: [...] }`(ApprovalInbox.list);
|
|
8
8
|
- `POST /device-fleet/approvals/decide`,body `{ id, action: "approve"|"deny", scope?, reason? }` → 决策走 ApprovalInbox;
|
|
9
|
-
-
|
|
10
|
-
- 纯逻辑 `createApprovalApi` 与路由注册分离,便于单测;路由在插件卸载时 dispose。
|
|
9
|
+
- 路由注册在插件 apply 中、卸载时 dispose;纯逻辑 `createApprovalApi` 与路由分离便于单测。
|
|
11
10
|
|
|
12
11
|
## Client 半
|
|
13
12
|
|
|
14
|
-
`client.js
|
|
13
|
+
`client.js`(闭包工厂,id = `dsh-device-fleet`):
|
|
15
14
|
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
37
|
-
- 路由未额外鉴权(依赖 web
|
|
38
|
-
-
|
|
36
|
+
- 轮询而非 SSE 推送;
|
|
37
|
+
- 路由未额外鉴权(依赖 web 载体本地/会话边界);
|
|
38
|
+
- 面板暂无房间视图(时间线/看板页下一步)。
|