@lrplrplrp/dsh-live2d 0.1.0

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/assets/BCSZ1.1/BCSZ1.1.cdi3.json +470 -0
  4. package/assets/BCSZ1.1/BCSZ1.1.moc3 +0 -0
  5. package/assets/BCSZ1.1/BCSZ1.1.model3.json +174 -0
  6. package/assets/BCSZ1.1/BCSZ1.1.physics3.json +2494 -0
  7. package/assets/BCSZ1.1/SZ-/344/270/215/345/217/230.motion3.json +13703 -0
  8. package/assets/BCSZ1.1/SZ-/344/270/255/345/215/210.motion3.json +7201 -0
  9. package/assets/BCSZ1.1/SZ-/345/210/235/350/247/201.motion3.json +27609 -0
  10. package/assets/BCSZ1.1/SZ-/345/244/234/346/231/232.motion3.json +14909 -0
  11. package/assets/BCSZ1.1/SZ-/345/244/264/351/245/260.motion3.json +69 -0
  12. package/assets/BCSZ1.1/SZ-/345/260/276/345/267/264.motion3.json +840 -0
  13. package/assets/BCSZ1.1/SZ-/345/261/221/347/234/274/347/245/236.motion3.json +69 -0
  14. package/assets/BCSZ1.1/SZ-/345/275/222/351/233/266.motion3.json +300 -0
  15. package/assets/BCSZ1.1/SZ-/346/227/240/350/201/212.motion3.json +6617 -0
  16. package/assets/BCSZ1.1/SZ-/346/227/251/344/270/212/345/245/275.motion3.json +16785 -0
  17. package/assets/BCSZ1.1/template.json +59 -0
  18. package/assets/BCSZ1.1/textures/texture_00.png +0 -0
  19. package/assets/BCSZ1.1/waifu-tips-BCSZ.json +49 -0
  20. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/344/270/215/345/217/230.wav +0 -0
  21. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/344/270/255/345/215/210.wav +0 -0
  22. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/345/210/235/350/247/201.wav +0 -0
  23. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/345/244/234/346/231/232.wav +0 -0
  24. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/346/227/240/350/201/212.wav +0 -0
  25. package/assets/BCSZ1.1//345/205/253/351/207/215/347/245/236/345/255/220-/346/227/251/344/270/212.wav +0 -0
  26. package/assets/deepseek_l2d/deepseek.2048/texture_00.png +0 -0
  27. package/assets/deepseek_l2d/deepseek.cdi3.json +205 -0
  28. package/assets/deepseek_l2d/deepseek.moc3 +0 -0
  29. package/assets/deepseek_l2d/deepseek.model3.json +67 -0
  30. package/assets/deepseek_l2d/deepseek.physics3.json +311 -0
  31. package/assets/deepseek_l2d/happy.exp3.json +45 -0
  32. package/assets/deepseek_l2d/happy2.exp3.json +35 -0
  33. package/assets/deepseek_l2d/idle.motion3.json +5574 -0
  34. package/assets/deepseek_l2d/index.html +13 -0
  35. package/assets/deepseek_l2d/puzzled.exp3.json +45 -0
  36. package/assets/deepseek_l2d/relax.exp3.json +60 -0
  37. package/assets/deepseek_l2d/speak.motion3.json +6975 -0
  38. package/assets/deepseek_l2d/thinkend.motion3.json +876 -0
  39. package/assets/deepseek_l2d/thinking.motion3.json +4992 -0
  40. package/assets/deepseek_l2d/unhappy.exp3.json +50 -0
  41. package/assets/model_list.json +39 -0
  42. package/cordis.patch.yml +11 -0
  43. package/lib/client.js +1487 -0
  44. package/lib/index.js +642 -0
  45. package/lib/index.min.js +1 -0
  46. package/lib/live2d.min.js +1 -0
  47. package/lib/live2dcubismcore.min.js +9 -0
  48. package/lib/pixi.min.js +9 -0
  49. package/package.json +62 -0
package/lib/index.js ADDED
@@ -0,0 +1,642 @@
1
+ // dsh-live2d — Host (Node) side.
2
+ //
3
+ // 职责:
4
+ // 1. 监听 DSH 会话事件,跟踪当前 Agent 状态(IDLE / THINKING / WORKING / SUCCESS / ERROR)
5
+ // 2. 注册 webserver 路由,供客户端轮询状态、读写配置、加载本地库文件和模型资源
6
+ //
7
+ // 客户端行为(Live2D 渲染、拖动、设置页)全部在 client.js 中。
8
+
9
+ import { createRequire } from 'node:module'
10
+ import { createReadStream, statSync, existsSync, mkdirSync, writeFileSync, rmSync, readdirSync, readFileSync } from 'node:fs'
11
+ import { join, extname, dirname, resolve, sep } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const require = createRequire(import.meta.url)
15
+
16
+ // ── 路径解析 ──────────────────────────────────────────────────────────
17
+ // 多种方式尝试解析 __dirname,兼容不同运行环境
18
+ let resolvedDir
19
+ try {
20
+ resolvedDir = dirname(fileURLToPath(import.meta.url))
21
+ } catch {}
22
+ if (!resolvedDir) {
23
+ try { resolvedDir = import.meta.dirname } catch {}
24
+ }
25
+ if (!resolvedDir) {
26
+ try { resolvedDir = dirname(new URL(import.meta.url).pathname) } catch {}
27
+ }
28
+ if (!resolvedDir) {
29
+ // fallback: 使用 require.resolve 找到 package.json 的位置
30
+ try {
31
+ const pkgPath = require.resolve('../package.json')
32
+ resolvedDir = dirname(pkgPath)
33
+ } catch {}
34
+ }
35
+
36
+ const PLUGIN_ROOT = resolvedDir ? join(resolvedDir, '..') : null
37
+ const LIB_DIR = PLUGIN_ROOT ? join(PLUGIN_ROOT, 'lib') : null
38
+ const ASSETS_DIR = PLUGIN_ROOT ? join(PLUGIN_ROOT, 'assets') : null
39
+
40
+ export const name = 'dsh-live2d'
41
+
42
+ const STATE_ENDPOINT = '/plugins/dsh-live2d/state'
43
+ const CONFIG_ENDPOINT = '/plugins/dsh-live2d/config'
44
+
45
+ // ── MIME 类型 ─────────────────────────────────────────────────────────
46
+ const MIME = {
47
+ '.js': 'application/javascript; charset=utf-8',
48
+ '.json': 'application/json; charset=utf-8',
49
+ '.png': 'image/png',
50
+ '.jpg': 'image/jpeg',
51
+ '.jpeg': 'image/jpeg',
52
+ '.gif': 'image/gif',
53
+ '.webp': 'image/webp',
54
+ '.moc3': 'application/octet-stream',
55
+ }
56
+
57
+ function guessMime(filePath) {
58
+ const ext = extname(filePath).toLowerCase()
59
+ return MIME[ext] || 'application/octet-stream'
60
+ }
61
+
62
+ // ── DSH 状态常量 ──────────────────────────────────────────────────────
63
+ const DSHState = Object.freeze({
64
+ IDLE: 'IDLE',
65
+ THINKING: 'THINKING',
66
+ WORKING: 'WORKING',
67
+ SPEAKING: 'SPEAKING', // 开始输出对话内容
68
+ SUCCESS: 'SUCCESS',
69
+ ERROR: 'ERROR',
70
+ })
71
+
72
+ // ── 当前状态(内存,客户端轮询读取) ──────────────────────────────────
73
+ let currentState = DSHState.IDLE
74
+ let stateUpdatedAt = Date.now()
75
+
76
+ // ── 工具调用跟踪 ──────────────────────────────────────────────────────
77
+ const openTools = new Map()
78
+ let waitingCallId = undefined
79
+
80
+ function toolCallIdOf(event, fallback) {
81
+ const data = event?.data || {}
82
+ const msg = data.message || {}
83
+ const content = msg.content
84
+ const contentCallId = Array.isArray(content)
85
+ ? content.find((item) => item?.toolCallId)?.toolCallId
86
+ : undefined
87
+ return String(
88
+ data.message?.source?.callId
89
+ ?? contentCallId
90
+ ?? msg.toolCallId
91
+ ?? msg.callId
92
+ ?? data.callId
93
+ ?? fallback
94
+ )
95
+ }
96
+
97
+ function isUserQuestionTool(name) {
98
+ const value = String(name || '').toLowerCase()
99
+ const tokens = value.split(/[^a-z0-9]+/u).filter(Boolean)
100
+ const asks = new Set(['ask', 'asking', 'request', 'requests', 'prompt', 'needs', 'need', 'get', 'gets'])
101
+ const filler = new Set(['for', 'from', 'the', 'a', 'an'])
102
+ const userWords = new Set(['user', 'human', 'me'])
103
+ const nouns = new Set(['question', 'questions', 'input', 'answer', 'answers', 'decision', 'confirmation', 'approval', 'permission'])
104
+ const hasAsk = tokens.some((token, index) => {
105
+ if (!asks.has(token)) return false
106
+ let cursor = index + 1
107
+ while (cursor < tokens.length && (filler.has(tokens[cursor]) || userWords.has(tokens[cursor]))) {
108
+ if (userWords.has(tokens[cursor])) {
109
+ const next = tokens[cursor + 1]
110
+ return !next || nouns.has(next)
111
+ }
112
+ cursor += 1
113
+ }
114
+ return cursor < tokens.length && nouns.has(tokens[cursor])
115
+ })
116
+ const submitsPlanForApproval = tokens.some((token, index) =>
117
+ token === 'exit' && tokens[index + 1] === 'plan' && tokens[index + 2] === 'mode'
118
+ )
119
+ return hasAsk || submitsPlanForApproval
120
+ }
121
+
122
+ function isSubagent(session) {
123
+ return session?.header?.origin === 'subagent'
124
+ || Number(session?.header?.delegationDepth ?? 0) > 0
125
+ }
126
+
127
+ function setState(state) {
128
+ if (currentState !== state) {
129
+ currentState = state
130
+ stateUpdatedAt = Date.now()
131
+ // 实时推送给所有已连接的 SSE 客户端,避免前端轮询延迟
132
+ broadcastState(currentState)
133
+ }
134
+ }
135
+
136
+ // ── 状态实时推送(SSE) ──────────────────────────────────────────────
137
+ const stateClients = new Set()
138
+
139
+ function broadcastState(state) {
140
+ const payload = `data: ${JSON.stringify({ state, updatedAt: stateUpdatedAt })}\n\n`
141
+ for (const res of stateClients) {
142
+ try { res.write(payload) } catch {}
143
+ }
144
+ }
145
+
146
+ // ── 会话事件处理 ──────────────────────────────────────────────────────
147
+ function handleSessionEvent(session, event) {
148
+ if (!event || typeof event.type !== 'string') return
149
+ if (isSubagent(session)) return
150
+
151
+ switch (event.type) {
152
+ case 'turn/start':
153
+ openTools.clear()
154
+ waitingCallId = undefined
155
+ setState(DSHState.THINKING)
156
+ break
157
+
158
+ case 'assistant/chunk':
159
+ case 'assistant/message':
160
+ // 没有正在执行的工具调用时,助手消息即"开始输出对话内容"
161
+ if (openTools.size > 0) break
162
+ setState(DSHState.SPEAKING)
163
+ break
164
+
165
+ case 'tool/call': {
166
+ const callId = toolCallIdOf(event, `seq-${event.seq ?? 'unknown'}`)
167
+ const name = String(event.data?.name ?? event.data?.message?.name ?? 'tool')
168
+ openTools.set(callId, name)
169
+ setState(DSHState.WORKING)
170
+ break
171
+ }
172
+
173
+ case 'tool/result': {
174
+ const callId = toolCallIdOf(event)
175
+ if (callId) openTools.delete(callId)
176
+ if (callId && callId === waitingCallId) waitingCallId = undefined
177
+ setState(openTools.size > 0 ? DSHState.WORKING : DSHState.THINKING)
178
+ break
179
+ }
180
+
181
+ case 'user/message':
182
+ if (waitingCallId) {
183
+ openTools.delete(waitingCallId)
184
+ waitingCallId = undefined
185
+ setState(DSHState.THINKING)
186
+ }
187
+ break
188
+
189
+ case 'turn/end': {
190
+ openTools.clear()
191
+ waitingCallId = undefined
192
+ const kind = String(event.data?.reason?.kind ?? 'completed')
193
+ if (kind === 'completed') {
194
+ setState(DSHState.SUCCESS)
195
+ setTimeout(() => {
196
+ if (currentState === DSHState.SUCCESS) setState(DSHState.IDLE)
197
+ }, 2500)
198
+ } else if (kind !== 'blocked') {
199
+ setState(DSHState.ERROR)
200
+ setTimeout(() => {
201
+ if (currentState === DSHState.ERROR) setState(DSHState.IDLE)
202
+ }, 4000)
203
+ }
204
+ break
205
+ }
206
+ }
207
+ }
208
+
209
+ // ── 工具函数 ──────────────────────────────────────────────────────────
210
+ function jsonResponse(res, status, body) {
211
+ const payload = JSON.stringify(body)
212
+ res.writeHead(status, {
213
+ 'content-type': 'application/json; charset=utf-8',
214
+ 'cache-control': 'no-store',
215
+ 'content-length': Buffer.byteLength(payload),
216
+ })
217
+ res.end(payload)
218
+ }
219
+
220
+ function isLoopback(address) {
221
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
222
+ }
223
+
224
+ async function readPatch(req) {
225
+ const chunks = []
226
+ let bytes = 0
227
+ for await (const chunk of req) {
228
+ bytes += chunk.length
229
+ if (bytes > 65536) throw new Error('request body is too large')
230
+ chunks.push(chunk)
231
+ }
232
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
233
+ }
234
+
235
+ /**
236
+ * 把 URL 子路径解析成 `rootDir` 下的真实文件。
237
+ * 拒绝任何逃出 rootDir 的路径(`..`、绝对路径、分隔符穿越)。
238
+ * @returns 绝对路径,或 null(非法)
239
+ */
240
+ function resolveWithin(rootDir, rel) {
241
+ if (!rootDir || !rel) return null
242
+ if (rel.includes('\0')) return null
243
+ const abs = resolve(rootDir, rel)
244
+ if (abs !== rootDir && !abs.startsWith(rootDir + sep)) return null
245
+ return abs
246
+ }
247
+
248
+ /**
249
+ * 解析 multipart/form-data 请求体(用于上传整个模型文件夹)。
250
+ * 支持字段(field)与文件(file,含 filename 与相对路径 webkitRelativePath)。
251
+ */
252
+ async function parseMultipart(req) {
253
+ const contentType = req.headers['content-type'] || ''
254
+ const m = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i)
255
+ if (!m) throw new Error('missing multipart boundary')
256
+ const boundary = m[1] || m[2]
257
+ const delimiter = Buffer.from('\r\n--' + boundary)
258
+ const chunks = []
259
+ let bytes = 0
260
+ for await (const chunk of req) {
261
+ bytes += chunk.length
262
+ if (bytes > 64 * 1024 * 1024) throw new Error('upload too large (max 64MB)')
263
+ chunks.push(chunk)
264
+ }
265
+ const buf = Buffer.concat(chunks)
266
+ const parts = []
267
+ let start = buf.indexOf(delimiter)
268
+ if (start === -1) return parts
269
+ start += delimiter.length
270
+ while (start < buf.length) {
271
+ if (buf[start] === 0x2d && buf[start + 1] === 0x2d) break // 结束边界 "--"
272
+ const end = buf.indexOf(delimiter, start)
273
+ if (end === -1) break
274
+ const partBuf = buf.subarray(start, end)
275
+ // partBuf 开头是 \r\n,结尾也是 \r\n
276
+ let headerEnd = partBuf.indexOf('\r\n\r\n')
277
+ if (headerEnd === -1) break
278
+ const headerStr = partBuf.subarray(2, headerEnd).toString('utf8')
279
+ let content = partBuf.subarray(headerEnd + 4)
280
+ if (content.endsWith('\r\n')) content = content.subarray(0, content.length - 2)
281
+ const headers = {}
282
+ for (const line of headerStr.split('\r\n')) {
283
+ const idx = line.indexOf(':')
284
+ if (idx !== -1) headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim()
285
+ }
286
+ const disp = headers['content-disposition'] || ''
287
+ const nameM = disp.match(/name="([^"]+)"/i)
288
+ const fileM = disp.match(/filename="([^"]*)"/i)
289
+ const relM = disp.match(/filename\*?=[^;]*webkitrelativepath="([^"]*)"/i)
290
+ || disp.match(/webkitrelativepath="([^"]*)"/i)
291
+ const entry = {
292
+ name: nameM ? nameM[1] : null,
293
+ filename: fileM ? fileM[1] : null,
294
+ webkitRelativePath: relM ? relM[1] : (fileM ? fileM[1] : ''),
295
+ data: content,
296
+ }
297
+ parts.push(entry)
298
+ start = end + delimiter.length
299
+ }
300
+ return parts
301
+ }
302
+
303
+ /** 安全地提供本地文件 */
304
+ function serveFile(res, filePath) {
305
+ try {
306
+ if (!existsSync(filePath)) {
307
+ res.writeHead(404)
308
+ res.end('Not found')
309
+ return
310
+ }
311
+ const stat = statSync(filePath)
312
+ if (!stat.isFile()) {
313
+ res.writeHead(404)
314
+ res.end('Not found')
315
+ return
316
+ }
317
+ const mime = guessMime(filePath)
318
+ res.writeHead(200, {
319
+ 'content-type': mime,
320
+ 'cache-control': 'public, max-age=3600',
321
+ 'content-length': stat.size,
322
+ })
323
+ createReadStream(filePath).pipe(res)
324
+ } catch {
325
+ res.writeHead(404)
326
+ res.end('Not found')
327
+ }
328
+ }
329
+
330
+ // ── 插件入口 ──────────────────────────────────────────────────────────
331
+ export function apply(ctx) {
332
+ const logger = ctx.logger ?? console
333
+
334
+ if (!PLUGIN_ROOT || !LIB_DIR || !ASSETS_DIR) {
335
+ logger.error?.('[dsh-live2d] Could not resolve plugin root directory. File serving disabled.')
336
+ }
337
+
338
+ // 监听会话事件,跟踪 DSH 状态
339
+ const offEvent = ctx.on('session/event', (session, event) => {
340
+ try {
341
+ handleSessionEvent(session, event)
342
+ } catch (err) {
343
+ logger.error?.('dsh-live2d: failed to handle session event', err)
344
+ }
345
+ }, { global: true })
346
+
347
+ // 注册 webserver 路由
348
+ if (typeof ctx.inject === 'function') {
349
+ ctx.inject(['webServer'], (httpCtx) => {
350
+ // GET /plugins/dsh-live2d/state — 返回当前 Agent 状态
351
+ httpCtx.effect(
352
+ () => httpCtx.webServer.register({
353
+ kind: 'exact',
354
+ path: STATE_ENDPOINT,
355
+ handler: (req, res) => {
356
+ if (!isLoopback(req.socket?.remoteAddress)) {
357
+ jsonResponse(res, 403, { error: 'local access only' })
358
+ return
359
+ }
360
+ jsonResponse(res, 200, { state: currentState, updatedAt: stateUpdatedAt })
361
+ },
362
+ }),
363
+ 'dsh-live2d: state endpoint',
364
+ )
365
+
366
+ // GET /plugins/dsh-live2d/state/stream — SSE 实时推送状态变更(替代轮询,消除动画延迟)
367
+ httpCtx.effect(
368
+ () => httpCtx.webServer.register({
369
+ kind: 'prefix',
370
+ path: STATE_ENDPOINT + '/stream',
371
+ handler: (req, res) => {
372
+ if (!isLoopback(req.socket?.remoteAddress)) {
373
+ res.writeHead(403)
374
+ res.end('local access only')
375
+ return
376
+ }
377
+ res.writeHead(200, {
378
+ 'Content-Type': 'text/event-stream',
379
+ 'Cache-Control': 'no-cache, no-transform',
380
+ 'Connection': 'keep-alive',
381
+ 'X-Accel-Buffering': 'no',
382
+ })
383
+ // 立即发送当前状态,避免新连接错过最新值
384
+ res.write(`data: ${JSON.stringify({ state: currentState, updatedAt: stateUpdatedAt })}\n\n`)
385
+ stateClients.add(res)
386
+ // 保活:每 15s 发一个注释,防止代理/浏览器断开空闲连接
387
+ const keepAlive = setInterval(() => {
388
+ try { res.write(': keep-alive\n\n') } catch {}
389
+ }, 15000)
390
+ const cleanup = () => {
391
+ clearInterval(keepAlive)
392
+ stateClients.delete(res)
393
+ }
394
+ req.on('close', cleanup)
395
+ req.on('error', cleanup)
396
+ },
397
+ }),
398
+ 'dsh-live2d: state sse endpoint',
399
+ )
400
+
401
+ // GET/PATCH/POST /plugins/dsh-live2d/config
402
+ httpCtx.effect(
403
+ () => httpCtx.webServer.register({
404
+ kind: 'exact',
405
+ path: CONFIG_ENDPOINT,
406
+ handler: async (req, res) => {
407
+ if (!isLoopback(req.socket?.remoteAddress)) {
408
+ jsonResponse(res, 403, { error: 'local access only' })
409
+ return
410
+ }
411
+ if (req.method === 'GET') {
412
+ jsonResponse(res, 200, { ok: true })
413
+ return
414
+ }
415
+ if (req.method === 'PATCH') {
416
+ try {
417
+ const patch = await readPatch(req)
418
+ jsonResponse(res, 200, { ok: true, patch })
419
+ } catch (err) {
420
+ jsonResponse(res, 400, { error: err instanceof Error ? err.message : String(err) })
421
+ }
422
+ return
423
+ }
424
+ if (req.method === 'POST') {
425
+ // 上传整个模型文件夹:multipart/form-data,字段 dirName + 若干 file(含 webkitRelativePath)
426
+ try {
427
+ if (!ASSETS_DIR) {
428
+ jsonResponse(res, 500, { error: 'assets dir unavailable' })
429
+ return
430
+ }
431
+ const ct = req.headers['content-type'] || ''
432
+ if (!ct.includes('multipart/form-data')) {
433
+ jsonResponse(res, 400, { error: 'expected multipart/form-data' })
434
+ return
435
+ }
436
+ const parts = await parseMultipart(req)
437
+ const dirField = parts.find((p) => p.name === 'dirName')
438
+ const dirName = (dirField ? dirField.data.toString('utf8') : '').trim()
439
+ if (!dirName || dirName.includes('/') || dirName.includes('\\') || dirName.includes('..') || /[^\w.\-]/.test(dirName)) {
440
+ jsonResponse(res, 400, { error: 'invalid dirName' })
441
+ return
442
+ }
443
+ const files = parts.filter((p) => p.filename)
444
+ if (files.length === 0) {
445
+ jsonResponse(res, 400, { error: 'no files' })
446
+ return
447
+ }
448
+ // 必须包含 model3.json
449
+ const hasModel3 = files.some((p) => p.filename.toLowerCase().endsWith('.model3.json'))
450
+ if (!hasModel3) {
451
+ jsonResponse(res, 400, { error: 'missing .model3.json in folder' })
452
+ return
453
+ }
454
+ const targetDir = join(ASSETS_DIR, dirName)
455
+ const abs = resolve(targetDir)
456
+ if (abs !== ASSETS_DIR && !abs.startsWith(ASSETS_DIR + sep)) {
457
+ jsonResponse(res, 400, { error: 'invalid dirName' })
458
+ return
459
+ }
460
+ // 覆盖式写入:先清空旧目录
461
+ try { rmSync(targetDir, { recursive: true, force: true }) } catch {}
462
+ mkdirSync(targetDir, { recursive: true })
463
+ let written = 0
464
+ for (const f of files) {
465
+ // 用 webkitRelativePath(去掉首个目录段即 dirName)决定相对位置
466
+ let relPath = f.webkitRelativePath || f.filename
467
+ // webkitRelativePath 形如 dirName/sub/file —— 去掉开头的 dirName/
468
+ const segs = relPath.split('/').filter(Boolean)
469
+ if (segs.length > 1 && segs[0] === dirName) segs.shift()
470
+ const rel = segs.join('/')
471
+ const fpath = resolveWithin(targetDir, rel)
472
+ if (!fpath) continue
473
+ mkdirSync(dirname(fpath), { recursive: true })
474
+ writeFileSync(fpath, f.data)
475
+ written += 1
476
+ }
477
+ jsonResponse(res, 200, { ok: true, dirName, fileCount: written, modelUrl: `/plugins/dsh-live2d/assets/${dirName}/model.model3.json` })
478
+ } catch (err) {
479
+ jsonResponse(res, 400, { error: err instanceof Error ? err.message : String(err) })
480
+ }
481
+ return
482
+ }
483
+ jsonResponse(res, 405, { error: 'method not allowed' })
484
+ },
485
+ }),
486
+ 'dsh-live2d: config endpoint',
487
+ )
488
+
489
+ // ── 模型自动扫描接口 ──────────────────────────────────
490
+ // GET /plugins/dsh-live2d/models
491
+ // 扫描 assets 目录下所有含 .model3.json 的子文件夹,自动生成模型列表;
492
+ // 同时解析每个 model3.json 的 Motions/Expressions 作为动画映射下拉数据源。
493
+ httpCtx.effect(
494
+ () => httpCtx.webServer.register({
495
+ kind: 'exact',
496
+ path: '/plugins/dsh-live2d/models',
497
+ handler: (req, res) => {
498
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
499
+ jsonResponse(res, 405, { error: 'method not allowed' })
500
+ return
501
+ }
502
+ if (!isLoopback(req.socket?.remoteAddress)) {
503
+ jsonResponse(res, 403, { error: 'local access only' })
504
+ return
505
+ }
506
+ const result = { assetsDir: ASSETS_DIR || '', models: [] }
507
+ if (!ASSETS_DIR || !existsSync(ASSETS_DIR)) {
508
+ jsonResponse(res, 200, result)
509
+ return
510
+ }
511
+ // 读取内置的 model_list.json(若存在),将其动画映射/画布配置合并到扫描结果,
512
+ // 使插件安装后无需手动配置即可使用默认动画映射。
513
+ let presetMap = {}
514
+ try {
515
+ const presetPath = join(ASSETS_DIR, 'model_list.json')
516
+ if (existsSync(presetPath)) {
517
+ const preset = JSON.parse(readFileSync(presetPath, 'utf8'))
518
+ const list = Array.isArray(preset.models) ? preset.models : []
519
+ for (const m of list) {
520
+ if (m && m.url) {
521
+ // 归一化 url 子路径,便于和扫描得到的 url 匹配
522
+ const key = m.url.replace(/^\.?\//, '').replace(/^\/plugins\/dsh-live2d\/assets\//, '')
523
+ presetMap[key] = m
524
+ }
525
+ }
526
+ }
527
+ } catch {}
528
+ const findModel3 = (dir) => {
529
+ let entries = []
530
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return null }
531
+ for (const e of entries) {
532
+ if (e.isFile() && e.name.toLowerCase().endsWith('.model3.json')) return join(dir, e.name)
533
+ }
534
+ for (const e of entries) {
535
+ if (e.isDirectory()) {
536
+ const found = findModel3(join(dir, e.name))
537
+ if (found) return found
538
+ }
539
+ }
540
+ return null
541
+ }
542
+ let dirEntries = []
543
+ try { dirEntries = readdirSync(ASSETS_DIR, { withFileTypes: true }) } catch {}
544
+ for (const e of dirEntries) {
545
+ if (!e.isDirectory()) continue
546
+ const modelPath = findModel3(join(ASSETS_DIR, e.name))
547
+ if (!modelPath) continue
548
+ const rel = modelPath.slice(ASSETS_DIR.length).replace(/\\/g, '/').replace(/^\//, '')
549
+ const url = '/plugins/dsh-live2d/assets/' + rel
550
+ let groups = []
551
+ let groupCounts = {}
552
+ // groupMotions: { groupName: ['idle', 'tap', ...] } 取每个 motion 文件名(去扩展名)作展示
553
+ let groupMotions = {}
554
+ let expressions = []
555
+ try {
556
+ const model3 = JSON.parse(readFileSync(modelPath, 'utf8'))
557
+ const fr = model3.FileReferences || {}
558
+ const motions = fr.Motions || {}
559
+ groups = Object.keys(motions)
560
+ for (const g of groups) {
561
+ const entries = Array.isArray(motions[g]) ? motions[g] : []
562
+ groupCounts[g] = entries.length
563
+ groupMotions[g] = entries.map((m) => {
564
+ const f = (typeof m === 'string' ? m : (m.File || m.file || ''))
565
+ return f.split('/').pop().replace(/\.motion3\.json$/i, '').replace(/\.motion3$/i, '')
566
+ })
567
+ }
568
+ expressions = (fr.Expressions || []).map((x) =>
569
+ typeof x === 'string'
570
+ ? x.split('/').pop().replace(/\.exp3\.json$/i, '')
571
+ : (x.Name || (x.File || '')).toString())
572
+ } catch {}
573
+ // 用 url 子路径匹配内置 model_list.json 中的预设(动画映射/画布配置)
574
+ const relKey = rel.replace(/^\/+/, '')
575
+ const preset = presetMap[relKey] || {}
576
+ result.models.push({
577
+ name: preset.name || e.name,
578
+ url,
579
+ canvasWidth: preset.canvasWidth || 300,
580
+ canvasHeight: preset.canvasHeight || 400,
581
+ config: preset.config || { x: 0, y: 0, scaleX: 1, scaleY: 1 },
582
+ groups,
583
+ groupCounts,
584
+ groupMotions,
585
+ expressions,
586
+ // 内置动画映射优先;用户后续在设置页的修改仍以 localStorage 为准
587
+ animations: preset.animations || {},
588
+ })
589
+ }
590
+ jsonResponse(res, 200, result)
591
+ },
592
+ }),
593
+ 'dsh-live2d: models scan endpoint',
594
+ )
595
+
596
+ // ── 本地文件路由 ──────────────────────────────────────────
597
+ // 必须用 `kind: 'prefix'` 的具名路由,绝不能用 registerFallback:
598
+ // fallback 座位是全局唯一的,spa 静态资源服务(dsh-host-frontend-static)
599
+ // 已经占用了它。抢占之后 index.html / 所有未命中路由都会落到本插件的
600
+ // handler 上,而它对这些路径不写任何响应 —— 请求永久挂住,页面打不开。
601
+ //
602
+ // webserver 的 prefix 匹配取最长前缀胜出,所以这两条比
603
+ // dsh-client-modules 注册的 `/plugins` 更具体,会优先命中。
604
+ const fileRoots = [
605
+ { routePath: '/plugins/dsh-live2d/lib', dir: LIB_DIR },
606
+ { routePath: '/plugins/dsh-live2d/assets', dir: ASSETS_DIR },
607
+ ]
608
+
609
+ for (const { routePath, dir } of fileRoots) {
610
+ if (!dir) continue
611
+ httpCtx.effect(
612
+ () => httpCtx.webServer.register({
613
+ kind: 'prefix',
614
+ path: routePath,
615
+ handler: (req, res) => {
616
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
617
+ res.writeHead(405)
618
+ res.end()
619
+ return
620
+ }
621
+ const urlPath = new URL(req.url || '/', 'http://localhost').pathname
622
+ const rel = decodeURIComponent(urlPath.slice(routePath.length).replace(/^\//u, ''))
623
+ const filePath = resolveWithin(dir, rel)
624
+ if (!filePath) {
625
+ res.writeHead(403)
626
+ res.end('Forbidden')
627
+ return
628
+ }
629
+ serveFile(res, filePath)
630
+ },
631
+ }),
632
+ `dsh-live2d: ${routePath}`,
633
+ )
634
+ }
635
+ })
636
+ }
637
+
638
+ // 清理
639
+ ctx.effect(() => () => {
640
+ offEvent?.()
641
+ }, 'dsh-live2d: cleanup')
642
+ }