dsh-device-fleet 0.1.0-dev.24 → 0.1.0-dev.26
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/assets/avatars/host.png +0 -0
- package/assets/avatars/user.png +0 -0
- package/assets/avatars/worker.png +0 -0
- package/client.js +136 -16
- package/docs/notes/dev-release.md +11 -1
- package/package.json +2 -1
- package/src/host/avatar-web.js +179 -0
- package/src/host/index.js +1 -0
- package/src/plugin/index.js +9 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
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,26 @@ 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
|
+
sectionCard('Room 成员头像',
|
|
1098
|
+
h('div', null,
|
|
1099
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
|
|
1100
|
+
h('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp', multiple: true, onChange: (event) => void uploadAvatars(event) }),
|
|
1101
|
+
avatarStatus ? h('span', { style: { fontSize: 12, opacity: 0.7 } }, avatarStatus) : null,
|
|
1102
|
+
),
|
|
1103
|
+
library.length === 0
|
|
1104
|
+
? h('div', { style: { fontSize: 12, opacity: 0.6, marginTop: 8 } }, '未上传头像时使用内置头像;上传后 Room 成员按顺序循环分配')
|
|
1105
|
+
: h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 } },
|
|
1106
|
+
...library.map((item) =>
|
|
1107
|
+
h('div', { key: item.name, style: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 } },
|
|
1108
|
+
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)' } }),
|
|
1109
|
+
smallButton('删除', () => void removeAvatar(item.name), 'deny'),
|
|
1110
|
+
),
|
|
1111
|
+
),
|
|
1112
|
+
),
|
|
1113
|
+
),
|
|
1114
|
+
'上传的图片存放在设备数据目录的 avatars/ 下;Room 视图按成员顺序循环取用,可点「换头像」切换到下一组。',
|
|
1115
|
+
),
|
|
1050
1116
|
h('div', { style: sectionStyle }, '远端权限'),
|
|
1051
1117
|
h(Row, { label: '允许的根目录', wide: true },
|
|
1052
1118
|
h('textarea', {
|
|
@@ -1085,10 +1151,21 @@ window.__ModuleLoader__.load({
|
|
|
1085
1151
|
|
|
1086
1152
|
// Room is a normal Session with participants. This dock keeps the member
|
|
1087
1153
|
// strip inside the conversation instead of a separate dashboard.
|
|
1088
|
-
function RoomDock(
|
|
1154
|
+
function RoomDock(props) {
|
|
1155
|
+
const sessionId = props?.sessionId;
|
|
1089
1156
|
const [room, setRoom] = useState(null);
|
|
1090
1157
|
const [missing, setMissing] = useState(false);
|
|
1091
1158
|
const [status, setStatus] = useState('');
|
|
1159
|
+
const [autoOpened, setAutoOpened] = useState(false);
|
|
1160
|
+
// Standard slot props expose conversation hooks; use them when available
|
|
1161
|
+
// to switch a Room session from Chat to the Room view automatically.
|
|
1162
|
+
const conversation = (() => {
|
|
1163
|
+
try {
|
|
1164
|
+
return typeof props?.useConversation === 'function' ? props.useConversation() : null;
|
|
1165
|
+
} catch {
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
})();
|
|
1092
1169
|
|
|
1093
1170
|
const load = async () => {
|
|
1094
1171
|
if (!sessionId) return;
|
|
@@ -1114,6 +1191,19 @@ window.__ModuleLoader__.load({
|
|
|
1114
1191
|
return () => clearInterval(timer);
|
|
1115
1192
|
}, [sessionId]);
|
|
1116
1193
|
|
|
1194
|
+
useEffect(() => {
|
|
1195
|
+
if (!room || autoOpened) return;
|
|
1196
|
+
const openView =
|
|
1197
|
+
conversation?.openView ?? conversation?.selectView ?? conversation?.setView ?? conversation?.goView;
|
|
1198
|
+
if (typeof openView !== 'function') return;
|
|
1199
|
+
try {
|
|
1200
|
+
openView('room', '');
|
|
1201
|
+
setAutoOpened(true);
|
|
1202
|
+
} catch {
|
|
1203
|
+
/* the shell did not expose a compatible view switch */
|
|
1204
|
+
}
|
|
1205
|
+
}, [room, autoOpened, conversation]);
|
|
1206
|
+
|
|
1117
1207
|
if (!sessionId) return null;
|
|
1118
1208
|
const upgrade = async () => {
|
|
1119
1209
|
setStatus('升级中…');
|
|
@@ -1230,24 +1320,26 @@ window.__ModuleLoader__.load({
|
|
|
1230
1320
|
);
|
|
1231
1321
|
}
|
|
1232
1322
|
|
|
1233
|
-
function
|
|
1234
|
-
|
|
1235
|
-
|
|
1323
|
+
function avatarImg(src, alt) {
|
|
1324
|
+
return h('img', {
|
|
1325
|
+
src,
|
|
1326
|
+
alt,
|
|
1327
|
+
draggable: false,
|
|
1236
1328
|
style: {
|
|
1237
1329
|
width: 26,
|
|
1238
1330
|
height: 26,
|
|
1239
1331
|
borderRadius: '50%',
|
|
1240
1332
|
flex: '0 0 26px',
|
|
1241
|
-
|
|
1242
|
-
alignItems: 'center',
|
|
1243
|
-
justifyContent: 'center',
|
|
1244
|
-
fontSize: 12,
|
|
1245
|
-
fontWeight: 600,
|
|
1246
|
-
background: tone === 'user' ? '#2f6fed' : 'var(--dsw-alias-bg-layer-2, #262626)',
|
|
1247
|
-
color: tone === 'user' ? '#fff' : 'var(--dsw-alias-label-primary, #e8e8e8)',
|
|
1333
|
+
objectFit: 'cover',
|
|
1248
1334
|
border: '1px solid var(--dsw-alias-border-l1, #333)',
|
|
1335
|
+
background: 'var(--dsw-alias-bg-layer-2, #262626)',
|
|
1249
1336
|
},
|
|
1250
|
-
}
|
|
1337
|
+
})
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function roomAvatar(name, tone) {
|
|
1341
|
+
const kind = tone === 'user' ? 'user' : String(name ?? '') === 'host' ? 'host' : 'worker'
|
|
1342
|
+
return avatarImg('/device-fleet/avatar/' + kind, String(name ?? kind))
|
|
1251
1343
|
}
|
|
1252
1344
|
|
|
1253
1345
|
// Room conversation rendered as a chat: agents on the left with names and
|
|
@@ -1263,6 +1355,24 @@ window.__ModuleLoader__.load({
|
|
|
1263
1355
|
const [showBoard, setShowBoard] = useState(false);
|
|
1264
1356
|
const [taskTitle, setTaskTitle] = useState('');
|
|
1265
1357
|
const [taskOwner, setTaskOwner] = useState('');
|
|
1358
|
+
const [library, setLibrary] = useState([]);
|
|
1359
|
+
const [avatarOffset, setAvatarOffset] = useState(0);
|
|
1360
|
+
|
|
1361
|
+
useEffect(() => {
|
|
1362
|
+
let disposed = false;
|
|
1363
|
+
const loadLibrary = async () => {
|
|
1364
|
+
try {
|
|
1365
|
+
const response = await fetch('/device-fleet/avatar-library', { headers: { accept: 'application/json' } });
|
|
1366
|
+
if (!response.ok) return;
|
|
1367
|
+
const data = await response.json();
|
|
1368
|
+
if (!disposed) setLibrary(Array.isArray(data.items) ? data.items : []);
|
|
1369
|
+
} catch {
|
|
1370
|
+
/* keep built-in avatars */
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
void loadLibrary();
|
|
1374
|
+
return () => { disposed = true };
|
|
1375
|
+
}, []);
|
|
1266
1376
|
|
|
1267
1377
|
const load = async () => {
|
|
1268
1378
|
if (!roomId) return;
|
|
@@ -1307,6 +1417,14 @@ window.__ModuleLoader__.load({
|
|
|
1307
1417
|
if (!detail) return h('div', { style: { padding: 16, opacity: 0.7, fontSize: 12 } }, error ? '房间加载失败:' + error : '加载房间…');
|
|
1308
1418
|
|
|
1309
1419
|
const nameOf = (memberId) => detail.members.find((member) => member.memberId === memberId)?.name ?? memberId
|
|
1420
|
+
const memberAvatarSrc = (member) => {
|
|
1421
|
+
if (!member) return '/device-fleet/avatar/user'
|
|
1422
|
+
const index = detail.members.findIndex((item) => item.memberId === member.memberId)
|
|
1423
|
+
if (library.length > 0) {
|
|
1424
|
+
return library[((index >= 0 ? index : 0) + avatarOffset) % library.length].url
|
|
1425
|
+
}
|
|
1426
|
+
return member.role === 'host' ? '/device-fleet/avatar/host' : '/device-fleet/avatar/worker'
|
|
1427
|
+
}
|
|
1310
1428
|
const participants = detail.members.filter((member) => member.role !== 'host')
|
|
1311
1429
|
const defaultRecipients = participants.length > 0 ? participants.map((member) => member.memberId) : detail.members.map((member) => member.memberId)
|
|
1312
1430
|
const recipients = sendTo.length > 0 ? sendTo : defaultRecipients
|
|
@@ -1325,9 +1443,10 @@ window.__ModuleLoader__.load({
|
|
|
1325
1443
|
const messages = detail.messages.map((message) => {
|
|
1326
1444
|
const fromUser = message.from?.kind === 'user'
|
|
1327
1445
|
const author = fromUser ? '你' : message.from?.name ?? 'agent'
|
|
1446
|
+
const senderMember = fromUser ? null : detail.members.find((member) => member.memberId === message.from?.memberId)
|
|
1328
1447
|
const targets = (message.to ?? []).map(nameOf).join(', ')
|
|
1329
1448
|
return h('div', { key: message.id, style: { display: 'flex', justifyContent: fromUser ? 'flex-end' : 'flex-start', gap: 8, margin: '10px 0' } },
|
|
1330
|
-
fromUser ? null :
|
|
1449
|
+
fromUser ? null : avatarImg(memberAvatarSrc(senderMember), author),
|
|
1331
1450
|
h('div', { style: { maxWidth: '74%' } },
|
|
1332
1451
|
fromUser ? null : h('div', { style: { fontSize: 11, opacity: 0.6, marginBottom: 2 } }, author + (targets ? ' · → ' + targets : '')),
|
|
1333
1452
|
h('div', {
|
|
@@ -1343,7 +1462,7 @@ window.__ModuleLoader__.load({
|
|
|
1343
1462
|
}, message.text),
|
|
1344
1463
|
h('div', { style: { fontSize: 10, opacity: 0.45, marginTop: 2, textAlign: fromUser ? 'right' : 'left' } }, fmtTime(message.createdAt)),
|
|
1345
1464
|
),
|
|
1346
|
-
fromUser ?
|
|
1465
|
+
fromUser ? avatarImg('/device-fleet/avatar/user', '你') : null,
|
|
1347
1466
|
)
|
|
1348
1467
|
})
|
|
1349
1468
|
|
|
@@ -1352,12 +1471,13 @@ window.__ModuleLoader__.load({
|
|
|
1352
1471
|
h('strong', { style: { fontSize: 15 } }, 'Room'),
|
|
1353
1472
|
...detail.members.map((member) =>
|
|
1354
1473
|
h('span', { key: member.memberId, style: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, opacity: 0.8 } },
|
|
1355
|
-
|
|
1474
|
+
avatarImg(memberAvatarSrc(member), member.name ?? member.memberId),
|
|
1356
1475
|
member.name + (member.role === 'host' ? '(host)' : ''),
|
|
1357
1476
|
),
|
|
1358
1477
|
),
|
|
1359
1478
|
h('span', { style: { flex: 1 } }),
|
|
1360
1479
|
status ? h('span', { style: { fontSize: 11, opacity: 0.6 } }, status) : null,
|
|
1480
|
+
library.length > 0 ? smallButton('换头像', () => setAvatarOffset((value) => value + 1), 'normal') : null,
|
|
1361
1481
|
smallButton(showBoard ? '收起看板' : '看板', () => setShowBoard((value) => !value), 'normal'),
|
|
1362
1482
|
smallButton('关闭房间', () => { void act('关闭房间', () => postJson('/device-fleet/room/close', { roomId })) }, 'deny'),
|
|
1363
1483
|
),
|
|
@@ -1429,7 +1549,7 @@ window.__ModuleLoader__.load({
|
|
|
1429
1549
|
})
|
|
1430
1550
|
},
|
|
1431
1551
|
style: { display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '6px 9px', background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: 12, textAlign: 'left' },
|
|
1432
|
-
},
|
|
1552
|
+
}, avatarImg(memberAvatarSrc(member), member.name ?? member.memberId), member.name + (member.role === 'host' ? '(host)' : '')),
|
|
1433
1553
|
),
|
|
1434
1554
|
)
|
|
1435
1555
|
: null,
|
|
@@ -128,4 +128,14 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
|
|
|
128
128
|
|
|
129
129
|
- 「房间」Conversation View 重做为聊天式:agent 消息在左(头像 + 名字 + 收件人),用户消息在右;时间戳、Enter 发送、收件人勾选;
|
|
130
130
|
- 参与者消息原文直接作为左侧气泡展示,不再进入 host 的普通 Chat;
|
|
131
|
-
- 看板可展开/收起,支持任务状态更新与添加。
|
|
131
|
+
- 看板可展开/收起,支持任务状态更新与添加。
|
|
132
|
+
## 0.1.0-dev.24(Room @点名)
|
|
133
|
+
|
|
134
|
+
- 房间输入框支持输入 `@` 弹出成员自动补全;选中成员变为「发给」chip,可点击取消;
|
|
135
|
+
- 无点名时默认发给所有非 host 参与者;被点名成员才唤醒;
|
|
136
|
+
- 聊天气泡显示发送者与收件人(worker-c · → host),用户侧为右气泡、agent 侧为左气泡;
|
|
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。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-device-fleet",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.26",
|
|
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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"files": [
|
|
41
41
|
"src",
|
|
42
42
|
"client.js",
|
|
43
|
+
"assets",
|
|
43
44
|
"cordis.patch.yml",
|
|
44
45
|
"README.md",
|
|
45
46
|
"CONTEXT.md",
|
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
|
|
7
|
+
export const AVATARS = {
|
|
8
|
+
host: 'host.png',
|
|
9
|
+
worker: 'worker.png',
|
|
10
|
+
user: 'user.png',
|
|
11
|
+
}
|
|
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
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} options
|
|
62
|
+
* @param {object} options.webServer ctx.webServer
|
|
63
|
+
* @param {string} options.dir built-in avatar directory
|
|
64
|
+
* @param {string} options.libraryDir user-uploaded avatar directory
|
|
65
|
+
*/
|
|
66
|
+
export function registerAvatarRoutes({ webServer, dir, libraryDir = null, logger = null }) {
|
|
67
|
+
if (!webServer?.register || !dir) return () => {}
|
|
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
|
+
}
|
|
83
|
+
for (const [name, file] of Object.entries(AVATARS)) {
|
|
84
|
+
disposers.push(
|
|
85
|
+
webServer.register({
|
|
86
|
+
kind: 'exact',
|
|
87
|
+
path: `/device-fleet/avatar/${name}`,
|
|
88
|
+
handler: async (req, res) => {
|
|
89
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
90
|
+
res.statusCode = 405
|
|
91
|
+
res.end()
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (req.method === 'HEAD') {
|
|
95
|
+
res.statusCode = 200
|
|
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') ?? ''
|
|
144
|
+
} catch {
|
|
145
|
+
name = ''
|
|
146
|
+
}
|
|
147
|
+
const clean = basename(name)
|
|
148
|
+
if (clean === '' || clean !== name || !EXTENSIONS.has(clean.slice(clean.lastIndexOf('.')).toLowerCase())) {
|
|
149
|
+
res.statusCode = 400
|
|
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) })
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
return () => {
|
|
177
|
+
for (const dispose of disposers) dispose()
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/host/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export * from './approval-inbox.js'
|
|
|
8
8
|
export * from './approval-web.js'
|
|
9
9
|
export * from './ui-web.js'
|
|
10
10
|
export * from './config-web.js'
|
|
11
|
+
export * from './avatar-web.js'
|
|
11
12
|
export * from './runtime-config.js'
|
|
12
13
|
export * from './room-service.js'
|
|
13
14
|
export * from './tools.js'
|
package/src/plugin/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { RoomService } from '../host/room-service.js'
|
|
|
20
20
|
import { registerApprovalRoutes } from '../host/approval-web.js'
|
|
21
21
|
import { registerUiRoutes } from '../host/ui-web.js'
|
|
22
22
|
import { registerConfigRoutes } from '../host/config-web.js'
|
|
23
|
+
import { registerAvatarRoutes } from '../host/avatar-web.js'
|
|
23
24
|
import { RuntimeConfig, deepMerge } from '../host/runtime-config.js'
|
|
24
25
|
import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
|
|
25
26
|
import { ToolPolicy } from '../host/tool-policy.js'
|
|
@@ -280,6 +281,14 @@ export function apply(ctx, profileConfig) {
|
|
|
280
281
|
}
|
|
281
282
|
let pairingRef = null
|
|
282
283
|
if (webServer) {
|
|
284
|
+
ctx.effect(() =>
|
|
285
|
+
registerAvatarRoutes({
|
|
286
|
+
webServer,
|
|
287
|
+
dir: fileURLToPath(new URL('../../assets/avatars/', import.meta.url)),
|
|
288
|
+
libraryDir: join(state.dataDir, 'avatars'),
|
|
289
|
+
logger,
|
|
290
|
+
}),
|
|
291
|
+
)
|
|
283
292
|
ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
|
|
284
293
|
ctx.effect(() =>
|
|
285
294
|
registerConfigRoutes({
|