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

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
@@ -916,6 +916,51 @@ window.__ModuleLoader__.load({
916
916
  const [options, setOptions] = useState({})
917
917
  const [status, setStatus] = useState('')
918
918
  const [busy, setBusy] = useState(false)
919
+ const [library, setLibrary] = useState([])
920
+ const [avatarStatus, setAvatarStatus] = useState('')
921
+
922
+ const loadLibrary = async () => {
923
+ try {
924
+ const response = await fetch('/device-fleet/avatar-library', { headers: { accept: 'application/json' } })
925
+ if (!response.ok) return
926
+ const data = await response.json()
927
+ setLibrary(Array.isArray(data.items) ? data.items : [])
928
+ } catch {
929
+ /* ignore */
930
+ }
931
+ }
932
+
933
+ const uploadAvatars = async (event) => {
934
+ const files = Array.from(event.target.files ?? [])
935
+ if (files.length === 0) return
936
+ setAvatarStatus('上传中…')
937
+ try {
938
+ for (const file of files) {
939
+ const dataUrl = await new Promise((resolve, reject) => {
940
+ const reader = new FileReader()
941
+ reader.onload = () => resolve(reader.result)
942
+ reader.onerror = () => reject(new Error('读取文件失败'))
943
+ reader.readAsDataURL(file)
944
+ })
945
+ await postJson('/device-fleet/avatar-library', { name: file.name, dataUrl })
946
+ }
947
+ await loadLibrary()
948
+ setAvatarStatus('已加入头像库')
949
+ } catch (problem) {
950
+ setAvatarStatus('上传失败:' + String(problem.message ?? problem))
951
+ } finally {
952
+ event.target.value = ''
953
+ }
954
+ }
955
+
956
+ const removeAvatar = async (name) => {
957
+ try {
958
+ await postJson('/device-fleet/avatar-library/remove', { name })
959
+ await loadLibrary()
960
+ } catch (problem) {
961
+ setAvatarStatus('删除失败:' + String(problem.message ?? problem))
962
+ }
963
+ }
919
964
 
920
965
  useEffect(() => {
921
966
  let disposed = false
@@ -935,6 +980,7 @@ window.__ModuleLoader__.load({
935
980
  }
936
981
  }
937
982
  void load()
983
+ void loadLibrary()
938
984
  return () => { disposed = true }
939
985
  }, [])
940
986
 
@@ -1047,6 +1093,24 @@ window.__ModuleLoader__.load({
1047
1093
  h(Row, { label: '委派超时 (ms)' },
1048
1094
  h(NumberControl, { value: draft.execDelegateTimeoutMs, onChange: (value) => set('execDelegateTimeoutMs', value) }),
1049
1095
  ),
1096
+ h('div', { style: sectionStyle }, '头像库'),
1097
+ h('div', { style: { ...cardStyle, padding: 14 } },
1098
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
1099
+ h('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp', multiple: true, onChange: (event) => void uploadAvatars(event) }),
1100
+ avatarStatus ? h('span', { style: { fontSize: 12, opacity: 0.7 } }, avatarStatus) : null,
1101
+ ),
1102
+ library.length === 0
1103
+ ? h('div', { style: { fontSize: 12, opacity: 0.6, marginTop: 8 } }, '未上传头像时使用内置头像;上传后 Room 成员按顺序循环分配')
1104
+ : h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 } },
1105
+ ...library.map((item) =>
1106
+ h('div', { key: item.name, style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 } },
1107
+ h('img', { src: item.url, alt: item.name, style: { width: 46, height: 46, borderRadius: '50%', objectFit: 'cover', border: '1px solid var(--dsw-alias-border-l1, #333)' } }),
1108
+ smallButton('删除', () => void removeAvatar(item.name), 'deny'),
1109
+ ),
1110
+ ),
1111
+ ),
1112
+ h('div', { style: { fontSize: 11, opacity: 0.55, marginTop: 8 } }, '图片存放在设备数据目录的 avatars/ 下;Room 视图按成员顺序循环取用,可点「换头像」切换到下一组。'),
1113
+ ),
1050
1114
  h('div', { style: sectionStyle }, '远端权限'),
1051
1115
  h(Row, { label: '允许的根目录', wide: true },
1052
1116
  h('textarea', {
@@ -1085,10 +1149,21 @@ window.__ModuleLoader__.load({
1085
1149
 
1086
1150
  // Room is a normal Session with participants. This dock keeps the member
1087
1151
  // strip inside the conversation instead of a separate dashboard.
1088
- function RoomDock({ sessionId }) {
1152
+ function RoomDock(props) {
1153
+ const sessionId = props?.sessionId;
1089
1154
  const [room, setRoom] = useState(null);
1090
1155
  const [missing, setMissing] = useState(false);
1091
1156
  const [status, setStatus] = useState('');
1157
+ const [autoOpened, setAutoOpened] = useState(false);
1158
+ // Standard slot props expose conversation hooks; use them when available
1159
+ // to switch a Room session from Chat to the Room view automatically.
1160
+ const conversation = (() => {
1161
+ try {
1162
+ return typeof props?.useConversation === 'function' ? props.useConversation() : null;
1163
+ } catch {
1164
+ return null;
1165
+ }
1166
+ })();
1092
1167
 
1093
1168
  const load = async () => {
1094
1169
  if (!sessionId) return;
@@ -1114,6 +1189,19 @@ window.__ModuleLoader__.load({
1114
1189
  return () => clearInterval(timer);
1115
1190
  }, [sessionId]);
1116
1191
 
1192
+ useEffect(() => {
1193
+ if (!room || autoOpened) return;
1194
+ const openView =
1195
+ conversation?.openView ?? conversation?.selectView ?? conversation?.setView ?? conversation?.goView;
1196
+ if (typeof openView !== 'function') return;
1197
+ try {
1198
+ openView('room', '');
1199
+ setAutoOpened(true);
1200
+ } catch {
1201
+ /* the shell did not expose a compatible view switch */
1202
+ }
1203
+ }, [room, autoOpened, conversation]);
1204
+
1117
1205
  if (!sessionId) return null;
1118
1206
  const upgrade = async () => {
1119
1207
  setStatus('升级中…');
@@ -1230,11 +1318,10 @@ window.__ModuleLoader__.load({
1230
1318
  );
1231
1319
  }
1232
1320
 
1233
- function roomAvatar(name, tone) {
1234
- const kind = tone === 'user' ? 'user' : String(name ?? '') === 'host' ? 'host' : 'worker'
1321
+ function avatarImg(src, alt) {
1235
1322
  return h('img', {
1236
- src: '/device-fleet/avatar/' + kind,
1237
- alt: String(name ?? kind),
1323
+ src,
1324
+ alt,
1238
1325
  draggable: false,
1239
1326
  style: {
1240
1327
  width: 26,
@@ -1248,6 +1335,11 @@ window.__ModuleLoader__.load({
1248
1335
  })
1249
1336
  }
1250
1337
 
1338
+ function roomAvatar(name, tone) {
1339
+ const kind = tone === 'user' ? 'user' : String(name ?? '') === 'host' ? 'host' : 'worker'
1340
+ return avatarImg('/device-fleet/avatar/' + kind, String(name ?? kind))
1341
+ }
1342
+
1251
1343
  // Room conversation rendered as a chat: agents on the left with names and
1252
1344
  // avatars, the user on the right. This is the Room's presentation instead of
1253
1345
  // injecting participant speech into the host's ordinary Chat.
@@ -1261,6 +1353,24 @@ window.__ModuleLoader__.load({
1261
1353
  const [showBoard, setShowBoard] = useState(false);
1262
1354
  const [taskTitle, setTaskTitle] = useState('');
1263
1355
  const [taskOwner, setTaskOwner] = useState('');
1356
+ const [library, setLibrary] = useState([]);
1357
+ const [avatarOffset, setAvatarOffset] = useState(0);
1358
+
1359
+ useEffect(() => {
1360
+ let disposed = false;
1361
+ const loadLibrary = async () => {
1362
+ try {
1363
+ const response = await fetch('/device-fleet/avatar-library', { headers: { accept: 'application/json' } });
1364
+ if (!response.ok) return;
1365
+ const data = await response.json();
1366
+ if (!disposed) setLibrary(Array.isArray(data.items) ? data.items : []);
1367
+ } catch {
1368
+ /* keep built-in avatars */
1369
+ }
1370
+ };
1371
+ void loadLibrary();
1372
+ return () => { disposed = true };
1373
+ }, []);
1264
1374
 
1265
1375
  const load = async () => {
1266
1376
  if (!roomId) return;
@@ -1305,6 +1415,14 @@ window.__ModuleLoader__.load({
1305
1415
  if (!detail) return h('div', { style: { padding: 16, opacity: 0.7, fontSize: 12 } }, error ? '房间加载失败:' + error : '加载房间…');
1306
1416
 
1307
1417
  const nameOf = (memberId) => detail.members.find((member) => member.memberId === memberId)?.name ?? memberId
1418
+ const memberAvatarSrc = (member) => {
1419
+ if (!member) return '/device-fleet/avatar/user'
1420
+ const index = detail.members.findIndex((item) => item.memberId === member.memberId)
1421
+ if (library.length > 0) {
1422
+ return library[((index >= 0 ? index : 0) + avatarOffset) % library.length].url
1423
+ }
1424
+ return member.role === 'host' ? '/device-fleet/avatar/host' : '/device-fleet/avatar/worker'
1425
+ }
1308
1426
  const participants = detail.members.filter((member) => member.role !== 'host')
1309
1427
  const defaultRecipients = participants.length > 0 ? participants.map((member) => member.memberId) : detail.members.map((member) => member.memberId)
1310
1428
  const recipients = sendTo.length > 0 ? sendTo : defaultRecipients
@@ -1323,9 +1441,10 @@ window.__ModuleLoader__.load({
1323
1441
  const messages = detail.messages.map((message) => {
1324
1442
  const fromUser = message.from?.kind === 'user'
1325
1443
  const author = fromUser ? '你' : message.from?.name ?? 'agent'
1444
+ const senderMember = fromUser ? null : detail.members.find((member) => member.memberId === message.from?.memberId)
1326
1445
  const targets = (message.to ?? []).map(nameOf).join(', ')
1327
1446
  return h('div', { key: message.id, style: { display: 'flex', justifyContent: fromUser ? 'flex-end' : 'flex-start', gap: 8, margin: '10px 0' } },
1328
- fromUser ? null : roomAvatar(author, 'agent'),
1447
+ fromUser ? null : avatarImg(memberAvatarSrc(senderMember), author),
1329
1448
  h('div', { style: { maxWidth: '74%' } },
1330
1449
  fromUser ? null : h('div', { style: { fontSize: 11, opacity: 0.6, marginBottom: 2 } }, author + (targets ? ' · → ' + targets : '')),
1331
1450
  h('div', {
@@ -1341,7 +1460,7 @@ window.__ModuleLoader__.load({
1341
1460
  }, message.text),
1342
1461
  h('div', { style: { fontSize: 10, opacity: 0.45, marginTop: 2, textAlign: fromUser ? 'right' : 'left' } }, fmtTime(message.createdAt)),
1343
1462
  ),
1344
- fromUser ? roomAvatar('你', 'user') : null,
1463
+ fromUser ? avatarImg('/device-fleet/avatar/user', '你') : null,
1345
1464
  )
1346
1465
  })
1347
1466
 
@@ -1350,12 +1469,13 @@ window.__ModuleLoader__.load({
1350
1469
  h('strong', { style: { fontSize: 15 } }, 'Room'),
1351
1470
  ...detail.members.map((member) =>
1352
1471
  h('span', { key: member.memberId, style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, opacity: 0.8 } },
1353
- roomAvatar(member.name ?? member.memberId, member.role === 'host' ? 'user' : 'agent'),
1472
+ avatarImg(memberAvatarSrc(member), member.name ?? member.memberId),
1354
1473
  member.name + (member.role === 'host' ? '(host)' : ''),
1355
1474
  ),
1356
1475
  ),
1357
1476
  h('span', { style: { flex: 1 } }),
1358
1477
  status ? h('span', { style: { fontSize: 11, opacity: 0.6 } }, status) : null,
1478
+ library.length > 0 ? smallButton('换头像', () => setAvatarOffset((value) => value + 1), 'normal') : null,
1359
1479
  smallButton(showBoard ? '收起看板' : '看板', () => setShowBoard((value) => !value), 'normal'),
1360
1480
  smallButton('关闭房间', () => { void act('关闭房间', () => postJson('/device-fleet/room/close', { roomId })) }, 'deny'),
1361
1481
  ),
@@ -1427,7 +1547,7 @@ window.__ModuleLoader__.load({
1427
1547
  })
1428
1548
  },
1429
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' },
1430
- }, roomAvatar(member.name ?? member.memberId, member.role === 'host' ? 'user' : 'agent'), member.name + (member.role === 'host' ? '(host)' : '')),
1550
+ }, avatarImg(memberAvatarSrc(member), member.name ?? member.memberId), member.name + (member.role === 'host' ? '(host)' : '')),
1431
1551
  ),
1432
1552
  )
1433
1553
  : null,
@@ -134,4 +134,13 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
134
134
  - 房间输入框支持输入 `@` 弹出成员自动补全;选中成员变为「发给」chip,可点击取消;
135
135
  - 无点名时默认发给所有非 host 参与者;被点名成员才唤醒;
136
136
  - 聊天气泡显示发送者与收件人(worker-c · → host),用户侧为右气泡、agent 侧为左气泡;
137
- - 后续:把 Room 状态下的 Chat 替换为这套界面(插件增量,不卸载系统 Chat)。
137
+ - 后续:把 Room 状态下的 Chat 替换为这套界面(插件增量,不卸载系统 Chat)。
138
+ ## 0.1.0-dev.25(Room 头像)
139
+
140
+ - Room 对话头像改为图片:worker → worker.png(蓝色大肥鱼),host → host.png(偷吃 Token),user → user.png;
141
+ - Host 新增 `/device-fleet/avatar/{host,worker,user}` 精确路由,从插件 `assets/avatars/` 提供 PNG;package `files` 包含 assets。
142
+ ## 0.1.0-dev.26(Room 自动切换 + 头像库)
143
+
144
+ - RoomDock 在检测到会话是 Room 时尝试通过标准 slot 的 `useConversation` 钩子自动切到「房间」Conversation View(不同版本的 shell 若未暴露该 API 则保持手动切换);
145
+ - 头像库:Settings → Plugins → Device Fleet 配置页新增「头像库」,可多选上传 png/jpeg/webp;Host 存到 `$DSH_HOME/.../avatars/`,提供列表/预览/删除接口;
146
+ - Room 成员头像按成员顺序循环分配库中图片,可点「换头像」切换到下一组;未上传时使用内置三张图。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.25",
3
+ "version": "0.1.0-dev.27",
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",
@@ -1,8 +1,8 @@
1
- // Agent avatars used by the Room conversation (dev.25). Files live in the
2
- // package's assets/avatars directory and are served on exact same-origin routes
3
- // so the browser can render them without an auth-bypassing asset surface.
4
- import { readFileSync } from 'node:fs'
5
- import { join } from 'node:path'
1
+ // Agent avatars used by the Room conversation (dev.25/26). Built-in PNG files
2
+ // ship with the package; the user can upload a library into the state dir and
3
+ // Room members cycle through it.
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
5
+ import { basename, join } from 'node:path'
6
6
 
7
7
  export const AVATARS = {
8
8
  host: 'host.png',
@@ -10,15 +10,76 @@ export const AVATARS = {
10
10
  user: 'user.png',
11
11
  }
12
12
 
13
+ const EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
14
+ const MAX_UPLOAD_BYTES = 3 * 1024 * 1024
15
+
16
+ function safeFileName(input, extension) {
17
+ const base = String(input ?? 'avatar')
18
+ .replace(/[^a-zA-Z0-9_-]+/g, '-')
19
+ .replace(/^-+|-+$/g, '')
20
+ .slice(0, 48)
21
+ return `${base || 'avatar'}-${Date.now().toString(36)}${extension}`
22
+ }
23
+
24
+ function listLibrary(dir) {
25
+ if (!existsSync(dir)) return []
26
+ return readdirSync(dir)
27
+ .filter((name) => EXTENSIONS.has(name.slice(name.lastIndexOf('.')).toLowerCase()))
28
+ .sort()
29
+ .map((name) => ({ name, url: `/device-fleet/avatar-file?name=${encodeURIComponent(name)}` }))
30
+ }
31
+
32
+ function parseDataUrl(dataUrl) {
33
+ const match = /^data:image\/(png|jpe?g|webp);base64,([A-Za-z0-9+/=]+)$/.exec(String(dataUrl ?? ''))
34
+ if (!match) throw Object.assign(new Error('dataUrl must be a base64 png/jpeg/webp image'), { code: 'bad-request' })
35
+ const extension = match[1] === 'jpeg' ? '.jpg' : `.${match[1]}`
36
+ const buffer = Buffer.from(match[2], 'base64')
37
+ if (buffer.length === 0 || buffer.length > MAX_UPLOAD_BYTES) {
38
+ throw Object.assign(new Error(`avatar must be between 1 byte and ${MAX_UPLOAD_BYTES} bytes`), { code: 'bad-request' })
39
+ }
40
+ return { extension, buffer }
41
+ }
42
+
43
+ function json(res, status, value) {
44
+ res.statusCode = status
45
+ res.setHeader('content-type', 'application/json; charset=utf-8')
46
+ res.end(JSON.stringify(value))
47
+ }
48
+
49
+ async function readJson(req) {
50
+ const chunks = []
51
+ for await (const chunk of req) chunks.push(chunk)
52
+ if (chunks.length === 0) return {}
53
+ try {
54
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
55
+ } catch {
56
+ throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
57
+ }
58
+ }
59
+
13
60
  /**
14
61
  * @param {object} options
15
62
  * @param {object} options.webServer ctx.webServer
16
- * @param {string} options.dir directory holding the avatar PNG files
17
- * @returns {() => void} disposer
63
+ * @param {string} options.dir built-in avatar directory
64
+ * @param {string} options.libraryDir user-uploaded avatar directory
18
65
  */
19
- export function registerAvatarRoutes({ webServer, dir, logger = null }) {
66
+ export function registerAvatarRoutes({ webServer, dir, libraryDir = null, logger = null }) {
20
67
  if (!webServer?.register || !dir) return () => {}
21
68
  const disposers = []
69
+ const sendPng = (res, file, alt = null) => {
70
+ try {
71
+ const data = readFileSync(file)
72
+ res.statusCode = 200
73
+ res.setHeader('content-type', alt ?? 'image/png')
74
+ res.setHeader('cache-control', 'public, max-age=3600')
75
+ res.setHeader('content-length', String(data.length))
76
+ res.end(data)
77
+ } catch {
78
+ logger?.warn?.(`[device-fleet] avatar missing: ${file}`)
79
+ res.statusCode = 404
80
+ res.end()
81
+ }
82
+ }
22
83
  for (const [name, file] of Object.entries(AVATARS)) {
23
84
  disposers.push(
24
85
  webServer.register({
@@ -30,17 +91,83 @@ export function registerAvatarRoutes({ webServer, dir, logger = null }) {
30
91
  res.end()
31
92
  return
32
93
  }
33
- try {
34
- const data = readFileSync(join(dir, file))
94
+ if (req.method === 'HEAD') {
35
95
  res.statusCode = 200
36
- res.setHeader('content-type', 'image/png')
37
- res.setHeader('cache-control', 'public, max-age=3600')
38
- res.setHeader('content-length', String(data.length))
39
- res.end(req.method === 'HEAD' ? undefined : data)
96
+ res.end()
97
+ return
98
+ }
99
+ sendPng(res, join(dir, file))
100
+ },
101
+ }),
102
+ )
103
+ }
104
+ if (libraryDir) {
105
+ try {
106
+ mkdirSync(libraryDir, { recursive: true })
107
+ } catch {
108
+ /* best effort */
109
+ }
110
+ disposers.push(
111
+ webServer.register({
112
+ kind: 'exact',
113
+ path: '/device-fleet/avatar-library',
114
+ handler: async (req, res) => {
115
+ try {
116
+ if (req.method === 'GET') return json(res, 200, { items: listLibrary(libraryDir) })
117
+ if (req.method === 'POST') {
118
+ const body = await readJson(req)
119
+ const { extension, buffer } = parseDataUrl(body.dataUrl)
120
+ const name = safeFileName(body.name, extension)
121
+ writeFileSync(join(libraryDir, name), buffer)
122
+ return json(res, 200, { ok: true, item: { name, url: `/device-fleet/avatar-file?name=${encodeURIComponent(name)}` } })
123
+ }
124
+ return json(res, 405, { error: 'method not allowed' })
125
+ } catch (error) {
126
+ return json(res, 400, { error: String(error?.message ?? error), code: error?.code ?? 'bad-request' })
127
+ }
128
+ },
129
+ }),
130
+ )
131
+ disposers.push(
132
+ webServer.register({
133
+ kind: 'exact',
134
+ path: '/device-fleet/avatar-file',
135
+ handler: async (req, res) => {
136
+ if (req.method !== 'GET') {
137
+ res.statusCode = 405
138
+ res.end()
139
+ return
140
+ }
141
+ let name = ''
142
+ try {
143
+ name = new URL(req.url ?? '/', 'http://local').searchParams.get('name') ?? ''
40
144
  } catch {
41
- logger?.warn?.(`[device-fleet] avatar missing: ${file}`)
42
- res.statusCode = 404
145
+ name = ''
146
+ }
147
+ const clean = basename(name)
148
+ if (clean === '' || clean !== name || !EXTENSIONS.has(clean.slice(clean.lastIndexOf('.')).toLowerCase())) {
149
+ res.statusCode = 400
43
150
  res.end()
151
+ return
152
+ }
153
+ sendPng(res, join(libraryDir, clean))
154
+ },
155
+ }),
156
+ )
157
+ disposers.push(
158
+ webServer.register({
159
+ kind: 'exact',
160
+ path: '/device-fleet/avatar-library/remove',
161
+ handler: async (req, res) => {
162
+ if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
163
+ try {
164
+ const body = await readJson(req)
165
+ const clean = basename(String(body.name ?? ''))
166
+ if (clean === '' || clean !== body.name) throw Object.assign(new Error('invalid name'), { code: 'bad-request' })
167
+ unlinkSync(join(libraryDir, clean))
168
+ return json(res, 200, { ok: true })
169
+ } catch (error) {
170
+ return json(res, 400, { error: String(error?.message ?? error) })
44
171
  }
45
172
  },
46
173
  }),
@@ -285,6 +285,7 @@ export function apply(ctx, profileConfig) {
285
285
  registerAvatarRoutes({
286
286
  webServer,
287
287
  dir: fileURLToPath(new URL('../../assets/avatars/', import.meta.url)),
288
+ libraryDir: join(state.dataDir, 'avatars'),
288
289
  logger,
289
290
  }),
290
291
  )