dsh-device-fleet 0.1.0-dev.27 → 0.1.0-dev.29

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 CHANGED
@@ -1149,6 +1149,49 @@ window.__ModuleLoader__.load({
1149
1149
 
1150
1150
  // Room is a normal Session with participants. This dock keeps the member
1151
1151
  // strip inside the conversation instead of a separate dashboard.
1152
+ function extractDraftText(input, depth = 0) {
1153
+ if (input === null || input === undefined || depth > 3) return '';
1154
+ if (typeof input === 'string') return input;
1155
+ if (typeof input !== 'object') return '';
1156
+ for (const key of ['draft', 'text', 'value', 'plainText', 'input', 'message', 'content', 'current']) {
1157
+ const value = input[key];
1158
+ if (typeof value === 'string' && value !== '') return value;
1159
+ if (value && typeof value === 'object') {
1160
+ const nested = extractDraftText(value, depth + 1);
1161
+ if (nested !== '') return nested;
1162
+ }
1163
+ }
1164
+ return '';
1165
+ }
1166
+
1167
+ function insertMentionText(props, name) {
1168
+ const actions = props?.inputActions;
1169
+ if (actions && typeof actions === 'object') {
1170
+ for (const method of ['insertMention', 'insertText', 'appendText', 'replaceText', 'setText', 'setDraft']) {
1171
+ const fn = actions[method];
1172
+ if (typeof fn === 'function') {
1173
+ try {
1174
+ fn.call(actions, '@' + name + ' ');
1175
+ return true;
1176
+ } catch {
1177
+ /* try the next action */
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+ try {
1183
+ const element = document.activeElement;
1184
+ if (element && (element.isContentEditable || element.tagName === 'TEXTAREA' || element.tagName === 'INPUT')) {
1185
+ element.focus();
1186
+ document.execCommand('insertText', false, '@' + name + ' ');
1187
+ return true;
1188
+ }
1189
+ } catch {
1190
+ /* fall through */
1191
+ }
1192
+ return false;
1193
+ }
1194
+
1152
1195
  function RoomDock(props) {
1153
1196
  const sessionId = props?.sessionId;
1154
1197
  const [room, setRoom] = useState(null);
@@ -1223,12 +1266,61 @@ window.__ModuleLoader__.load({
1223
1266
  }
1224
1267
  if (!room) return null;
1225
1268
  const names = room.members.filter((member) => member.role !== 'host').map((member) => member.name);
1226
- return h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, opacity: 0.8, padding: '2px 2px 0', flexWrap: 'wrap' } },
1227
- pill('Room', 'ok'),
1228
- h('span', null, room.members.length + ' 名成员'),
1229
- names.length > 0 ? h('span', null, '· ' + names.join(', ')) : h('span', null, '· 仅 host'),
1230
- h('span', { style: { flex: 1 } }),
1231
- h('span', { style: { opacity: 0.55 } }, '房间消息在「房间」页交流'),
1269
+ const inputState = (() => {
1270
+ try {
1271
+ return typeof props?.useInput === 'function' ? props.useInput() : null;
1272
+ } catch {
1273
+ return null;
1274
+ }
1275
+ })();
1276
+ const draft = extractDraftText(inputState);
1277
+ const mention = /(?:^|\s)@([^\s@]*)$/.exec(draft);
1278
+ const candidates = mention
1279
+ ? room.members.filter((member) => String(member.name ?? '').toLowerCase().includes(mention[1].toLowerCase()))
1280
+ : [];
1281
+ const completion =
1282
+ mention && candidates.length > 0
1283
+ ? h('div', {
1284
+ style: {
1285
+ position: 'fixed',
1286
+ bottom: 96,
1287
+ left: '50%',
1288
+ transform: 'translateX(-50%)',
1289
+ width: 'min(520px, 80vw)',
1290
+ zIndex: 1000,
1291
+ background: 'var(--dsw-alias-bg-overlay, #1f1f1f)',
1292
+ color: 'var(--dsw-alias-label-primary, #e8e8e8)',
1293
+ border: '1px solid var(--dsw-alias-border-l2, #3a3a3a)',
1294
+ borderRadius: 10,
1295
+ boxShadow: '0 12px 32px rgba(0,0,0,.4)',
1296
+ padding: 6,
1297
+ font: '12px system-ui, sans-serif',
1298
+ },
1299
+ },
1300
+ h('div', { style: { opacity: 0.55, fontSize: 10, padding: '3px 7px 5px' } }, '可发送给:所有人 / 指定 agent'),
1301
+ ...candidates.map((member) =>
1302
+ h('button', {
1303
+ key: member.memberId,
1304
+ type: 'button',
1305
+ onClick: () => {
1306
+ if (!insertMentionText(props, member.name)) {
1307
+ setStatus('请在输入框手动补全 @' + member.name)
1308
+ }
1309
+ },
1310
+ style: { display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 8px', background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: 12, textAlign: 'left', borderRadius: 6 },
1311
+ }, avatarImg('/device-fleet/avatar/' + (member.role === 'host' ? 'host' : 'worker'), member.name), member.name + (member.role === 'host' ? '(host)' : '')),
1312
+ ),
1313
+ )
1314
+ : null;
1315
+ return h('div', null,
1316
+ completion,
1317
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, opacity: 0.8, padding: '2px 2px 0', flexWrap: 'wrap' } },
1318
+ pill('Room', 'ok'),
1319
+ h('span', null, room.members.length + ' 名成员'),
1320
+ names.length > 0 ? h('span', null, '· ' + names.join(', ')) : h('span', null, '· 仅 host'),
1321
+ h('span', { style: { flex: 1 } }),
1322
+ h('span', { style: { opacity: 0.55 } }, '输入 @ 点名,不点名发给所有人'),
1323
+ ),
1232
1324
  );
1233
1325
  }
1234
1326
 
@@ -1503,55 +1595,8 @@ window.__ModuleLoader__.load({
1503
1595
  ),
1504
1596
  )
1505
1597
  : null,
1506
- h('div', { style: { ...cardStyle, marginTop: 8 } },
1507
- h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', marginBottom: 6 } },
1508
- h('span', { style: { fontSize: 11, opacity: 0.6 } }, '发给'),
1509
- sendTo.length === 0
1510
- ? h('span', { style: { fontSize: 11, opacity: 0.8 } }, '所有人')
1511
- : sendTo.map((memberId) =>
1512
- h('span', {
1513
- key: memberId,
1514
- style: { fontSize: 11, padding: '1px 7px', borderRadius: 999, border: '1px solid var(--dsw-alias-accent, #2f6fed)', color: 'var(--dsw-alias-accent, #2f6fed)', cursor: 'pointer' },
1515
- onClick: () => setSendTo((current) => current.filter((id) => id !== memberId)),
1516
- }, '@' + nameOf(memberId) + ' ×'),
1517
- ),
1518
- h('span', { style: { flex: 1 } }),
1519
- h('span', { style: { fontSize: 10, opacity: 0.45 } }, '输入 @ 点名'),
1520
- ),
1521
- h('textarea', {
1522
- value: sendText,
1523
- onChange: (event) => setSendText(event.target.value),
1524
- onKeyDown: (event) => {
1525
- if (event.key === 'Enter' && !event.shiftKey) {
1526
- event.preventDefault()
1527
- void send()
1528
- }
1529
- },
1530
- rows: 2,
1531
- placeholder: '发消息给房间成员;输入 @ 可点名(Enter 发送,Shift+Enter 换行)',
1532
- style: { width: '100%', boxSizing: 'border-box', padding: '7px 9px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l1, #333)', background: 'var(--dsw-alias-bg-layer-2, #262626)', color: 'inherit', fontSize: 13, resize: 'vertical', fontFamily: 'inherit' },
1533
- }),
1534
- mentionInfo && mentionCandidates.length > 0
1535
- ? h('div', { style: { marginTop: 6, border: '1px solid var(--dsw-alias-border-l1, #333)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2, #262626)', overflow: 'hidden' } },
1536
- ...mentionCandidates.map((member) =>
1537
- h('button', {
1538
- key: member.memberId,
1539
- type: 'button',
1540
- onClick: () => {
1541
- setSendTo((current) => [...new Set([...current, member.memberId])])
1542
- setSendText((text) => {
1543
- const match = /(?:^|\s)@([^\s@]*)$/.exec(text)
1544
- if (!match) return text
1545
- const start = text.length - match[1].length - 1
1546
- return text.slice(0, start).replace(/\s+$/, '')
1547
- })
1548
- },
1549
- style: { display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '6px 9px', background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: 12, textAlign: 'left' },
1550
- }, avatarImg(memberAvatarSrc(member), member.name ?? member.memberId), member.name + (member.role === 'host' ? '(host)' : '')),
1551
- ),
1552
- )
1553
- : null,
1554
- h('div', { style: { display: 'flex', justifyContent: 'flex-end', marginTop: 6 } }, smallButton('发送', () => void send(), 'allow')),
1598
+ h('div', { style: { ...cardStyle, marginTop: 8, fontSize: 12, opacity: 0.75 } },
1599
+ '在下方 DSH 输入框发送:用 @成员名 点名(例如 @worker-c),不点名则发给所有人。',
1555
1600
  ),
1556
1601
  )
1557
1602
  }
@@ -143,4 +143,14 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
143
143
 
144
144
  - RoomDock 在检测到会话是 Room 时尝试通过标准 slot 的 `useConversation` 钩子自动切到「房间」Conversation View(不同版本的 shell 若未暴露该 API 则保持手动切换);
145
145
  - 头像库:Settings → Plugins → Device Fleet 配置页新增「头像库」,可多选上传 png/jpeg/webp;Host 存到 `$DSH_HOME/.../avatars/`,提供列表/预览/删除接口;
146
- - Room 成员头像按成员顺序循环分配库中图片,可点「换头像」切换到下一组;未上传时使用内置三张图。
146
+ - Room 成员头像按成员顺序循环分配库中图片,可点「换头像」切换到下一组;未上传时使用内置三张图。
147
+ ## 0.1.0-dev.27(修复插件配置页空白)
148
+
149
+ - 根因:dev.26 头像库区块误用了不存在的 `sectionCard`,FleetConfig 渲染抛 ReferenceError,导致 Settings → Plugins → Device Fleet 配置页消失。
150
+ - 修复:头像库改用普通卡片容器;client smoke 增加配置页真实渲染用例(plugins.row.config)防止回归。
151
+ ## 0.1.0-dev.28(Room 复用 DSH 输入框 + @点名解析)
152
+
153
+ - 移除房间页自带发送栏,房间消息直接使用 DSH 底部输入框;
154
+ - Host 监听 Room Host 会话的 user/message:解析 `@成员名`,被点名成员才加入收件人;未点名默认发给所有参与者;
155
+ - 消息文本会去掉 @mention 标记后写入房间日志;host 本身已收到 Chat 输入,不重复唤醒;
156
+ - 房间页保留成员/气泡/看板,底部提示用 @ 点名。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.27",
3
+ "version": "0.1.0-dev.29",
4
4
  "description": "DeepSeek Harness device fleet: enroll devices, direct-call remote tools, shared Rooms with lazy-woken agents, and delegated subagents across devices.",
5
5
  "type": "module",
6
6
  "main": "src/plugin/index.js",
@@ -186,7 +186,7 @@ export class RoomService {
186
186
  * Send one addressed message. The Host appends it to the canonical log and
187
187
  * wakes only the addressed local members (never the sender).
188
188
  */
189
- async send({ roomId, from, to, text } = {}) {
189
+ async send({ roomId, from, to, text, skipWakeFor = [] } = {}) {
190
190
  const room = this.requireRoom(roomId)
191
191
  const body = typeof text === 'string' ? text.trim() : ''
192
192
  if (body === '') throw Object.assign(new Error('room message needs text'), { code: 'bad-request' })
@@ -216,6 +216,7 @@ export class RoomService {
216
216
  const woken = []
217
217
  for (const memberId of recipients) {
218
218
  if (sender.memberId === memberId) continue
219
+ if (skipWakeFor.includes(memberId)) continue
219
220
  const member = room.members.get(memberId)
220
221
  if (await this.wake(room, member, message)) woken.push(memberId)
221
222
  }
@@ -435,6 +435,79 @@ export function apply(ctx, profileConfig) {
435
435
  return ctx.on('approval/request', handler)
436
436
  })
437
437
 
438
+ // A Room reuses the normal DSH composer: every user message in a Room Host
439
+ // session is logged as a Room message. `@name` addresses specific members;
440
+ // without mentions it goes to every participant. Host itself already has the
441
+ // message as normal Chat input, so it is never re-woken.
442
+ ctx.effect(() =>
443
+ ctx.on?.('session/event', (session, event) => {
444
+ try {
445
+ if (!session?.id || event?.type !== 'user/message') return
446
+ const room = rooms.rooms.get(session.id)
447
+ if (!room) return
448
+ const message = event.data
449
+ if (message?.source?.kind && message.source.kind !== 'user') return
450
+ const text = (Array.isArray(message?.content) ? message.content : [])
451
+ .filter((block) => block?.type === 'text')
452
+ .map((block) => block.text)
453
+ .join('\n')
454
+ .trim()
455
+ if (text === '' || text.startsWith('[Room]') || text.startsWith('[Fleet Room')) return
456
+ const members = rooms.status(session.id).members
457
+ const hostMemberId = `host:${session.id}`
458
+ const participants = members.filter((member) => member.role !== 'host')
459
+ const mentioned = members.filter((member) => member.name && text.includes('@' + member.name))
460
+ const recipients = mentioned.length > 0
461
+ ? mentioned.map((member) => member.memberId)
462
+ : participants.map((member) => member.memberId)
463
+ if (recipients.length === 0) return
464
+ let body = text
465
+ for (const member of mentioned) body = body.split('@' + member.name).join('')
466
+ body = body.trim() || '(点名)'
467
+ void rooms.send({
468
+ roomId: session.id,
469
+ from: { kind: 'user' },
470
+ to: recipients,
471
+ text: body,
472
+ skipWakeFor: [hostMemberId],
473
+ }).catch((error) => {
474
+ logger.warn?.(`[device-fleet] cannot route room message: ${String(error?.message ?? error)}`)
475
+ })
476
+ } catch (error) {
477
+ logger.warn?.(`[device-fleet] room message routing failed: ${String(error?.message ?? error)}`)
478
+ }
479
+ }),
480
+ )
481
+
482
+ // The host agent's own Chat replies are mirrored into the Room transcript as
483
+ // host speech (read-only for participants: recorded, never re-woken).
484
+ ctx.effect(() =>
485
+ ctx.on?.('session/event', (session, event) => {
486
+ try {
487
+ if (!session?.id || event?.type !== 'assistant/message') return
488
+ const room = rooms.rooms.get(session.id)
489
+ if (!room) return
490
+ const message = event.data
491
+ const text = (Array.isArray(message?.content) ? message.content : [])
492
+ .filter((block) => block?.type === 'text')
493
+ .map((block) => block.text)
494
+ .join('\n')
495
+ .trim()
496
+ if (text === '') return
497
+ const hostMemberId = `host:${session.id}`
498
+ void rooms.send({
499
+ roomId: session.id,
500
+ from: { sessionId: session.id },
501
+ to: [hostMemberId],
502
+ text,
503
+ skipWakeFor: [hostMemberId],
504
+ }).catch(() => {})
505
+ } catch {
506
+ /* ignore */
507
+ }
508
+ }),
509
+ )
510
+
438
511
  node.onRequest(async (request) => {
439
512
  if (!host) throw Object.assign(new Error('this device cannot serve direct calls (no execution slot)'), { code: 'unsupported' })
440
513
  return host.handleRequest(request)