@lrplrplrp/dsh-live2d 0.1.3 → 0.1.4

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/lib/index.js CHANGED
@@ -10,6 +10,8 @@ import { createRequire } from 'node:module'
10
10
  import { createReadStream, statSync, existsSync, mkdirSync, writeFileSync, rmSync, readdirSync, readFileSync } from 'node:fs'
11
11
  import { join, extname, dirname, resolve, sep } from 'node:path'
12
12
  import { fileURLToPath } from 'node:url'
13
+ // DSH 状态常量的单一来源(client 端由 scripts/build-client.mjs 内联同一份定义)
14
+ import { DSHState } from './shared/states.js'
13
15
 
14
16
  const require = createRequire(import.meta.url)
15
17
 
@@ -40,7 +42,8 @@ const ASSETS_DIR = PLUGIN_ROOT ? join(PLUGIN_ROOT, 'assets') : null
40
42
  export const name = '@lrplrplrp/dsh-live2d'
41
43
 
42
44
  const STATE_ENDPOINT = '/plugins/dsh-live2d/state'
43
- const CONFIG_ENDPOINT = '/plugins/dsh-live2d/config'
45
+ // 模型文件夹上传端点(multipart/form-data)。客户端配置以 localStorage 为准,不经此端点。
46
+ const UPLOAD_ENDPOINT = '/plugins/dsh-live2d/config'
44
47
 
45
48
  // ── MIME 类型 ─────────────────────────────────────────────────────────
46
49
  const MIME = {
@@ -59,15 +62,7 @@ function guessMime(filePath) {
59
62
  return MIME[ext] || 'application/octet-stream'
60
63
  }
61
64
 
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
- })
65
+ // ── 工具函数 ──────────────────────────────────────────────────────────
71
66
 
72
67
  // ── 当前状态(内存,客户端轮询读取) ──────────────────────────────────
73
68
  let currentState = DSHState.IDLE
@@ -76,6 +71,10 @@ let stateUpdatedAt = Date.now()
76
71
  // ── 工具调用跟踪 ──────────────────────────────────────────────────────
77
72
  const openTools = new Map()
78
73
  let waitingCallId = undefined
74
+ // 本轮是否已开始输出正文(面向用户的对话)。一旦为 true,就保持「输出对话」状态
75
+ // 直到本轮 turn 结束:模型常在输出一小段正文后立刻发起工具调用,若让 tool/call
76
+ // 抢走状态,SPEAKING 会被反复打断成一闪而过。
77
+ let speakingLocked = false
79
78
 
80
79
  function toolCallIdOf(event, fallback) {
81
80
  const data = event?.data || {}
@@ -124,6 +123,35 @@ function isSubagent(session) {
124
123
  || Number(session?.header?.delegationDepth ?? 0) > 0
125
124
  }
126
125
 
126
+ // ── 「输出对话」判定 ───────────────────────────────────────────────────
127
+ // DSH 的 assistant/chunk 既承载思维链(reasoning-delta)也承载面向用户的对话
128
+ // 正文(text-delta)。只有后者才算“开始输出对话内容”,思维链/工具参数不算。
129
+ // 参见 @deepseek-ai/dsh-llm 的 StreamChunk 定义。
130
+ function chunkIsDialogue(chunk) {
131
+ if (!chunk || typeof chunk !== 'object') return false
132
+ switch (chunk.type) {
133
+ case 'text-delta':
134
+ // 正文增量:需含非空白内容(纯换行/缩进不算开始说话)
135
+ return typeof chunk.text === 'string' && chunk.text.trim().length > 0
136
+ case 'block-end':
137
+ // 完整块:仅 text 块算对话正文;reasoning / tool-call 等不算
138
+ return !!chunk.block && chunk.block.type === 'text'
139
+ && typeof chunk.block.text === 'string' && chunk.block.text.trim().length > 0
140
+ default:
141
+ // reasoning-delta / tool-call-delta / block-start / usage / finish 一律不算
142
+ return false
143
+ }
144
+ }
145
+
146
+ /** 持久化的 assistant/message 是否含面向用户的正文(而非仅思维链/工具调用)。 */
147
+ function messageHasDialogue(data) {
148
+ const content = data?.message?.content
149
+ if (!Array.isArray(content)) return false
150
+ return content.some((block) =>
151
+ block && block.type === 'text'
152
+ && typeof block.text === 'string' && block.text.trim().length > 0)
153
+ }
154
+
127
155
  function setState(state) {
128
156
  if (currentState !== state) {
129
157
  currentState = state
@@ -133,9 +161,43 @@ function setState(state) {
133
161
  }
134
162
  }
135
163
 
164
+ // 临时状态(SUCCESS/ERROR)自动回到 IDLE:记录“本次复位对应的设置时刻”,
165
+ // 定时器到点时只有仍处于同一次设置(时间戳未变)才复位。避免旧定时器
166
+ // 在状态已被后续事件改写后又把 IDLE 覆盖上去。
167
+ let pendingIdleResetAt = 0
168
+ function scheduleIdleReset(expectedState, delayMs) {
169
+ const setAt = stateUpdatedAt
170
+ pendingIdleResetAt = setAt
171
+ setTimeout(() => {
172
+ if (pendingIdleResetAt === setAt && stateUpdatedAt === setAt && currentState === expectedState) {
173
+ setState(DSHState.IDLE)
174
+ }
175
+ }, delayMs)
176
+ }
177
+
136
178
  // ── 状态实时推送(SSE) ──────────────────────────────────────────────
137
179
  const stateClients = new Set()
138
180
 
181
+ /**
182
+ * 处理 `agent/assistant-stream` 的实时帧,用于「正文一开始输出就进入输出对话」。
183
+ * 帧结构见 @deepseek-ai/dsh-agent 的 AssistantStreamFrame:
184
+ * { type: 'start' } | { type: 'chunk', chunk: StreamChunk } | { type: 'end', outcome }
185
+ * 只有面向用户的 text-delta / text 块才算正文;reasoning-delta(思维链)不算。
186
+ */
187
+ function handleAssistantStream(payload) {
188
+ const frame = payload && payload.frame
189
+ if (!frame || typeof frame !== 'object') return
190
+ if (frame.type !== 'chunk') return
191
+
192
+ const chunk = frame.chunk
193
+ if (!chunkIsDialogue(chunk)) return
194
+ // 正文开始:立即进入「输出对话」并锁定,直到本轮结束
195
+ if (!speakingLocked) {
196
+ speakingLocked = true
197
+ setState(DSHState.SPEAKING)
198
+ }
199
+ }
200
+
139
201
  function broadcastState(state) {
140
202
  const payload = `data: ${JSON.stringify({ state, updatedAt: stateUpdatedAt })}\n\n`
141
203
  for (const res of stateClients) {
@@ -152,13 +214,23 @@ function handleSessionEvent(session, event) {
152
214
  case 'turn/start':
153
215
  openTools.clear()
154
216
  waitingCallId = undefined
217
+ speakingLocked = false
155
218
  setState(DSHState.THINKING)
156
219
  break
157
220
 
158
221
  case 'assistant/chunk':
222
+ // 仅“面向用户的正文”才算开始输出对话;思维链 / 工具参数增量不改变状态
223
+ // (否则会在思考阶段就误报“输出对话”)。
224
+ if (!chunkIsDialogue(event.data?.chunk)) break
225
+ // 正文一开始就锁住「输出对话」,直到本轮结束(见 speakingLocked 说明)
226
+ speakingLocked = true
227
+ setState(DSHState.SPEAKING)
228
+ break
229
+
159
230
  case 'assistant/message':
160
- // 没有正在执行的工具调用时,助手消息即"开始输出对话内容"
161
- if (openTools.size > 0) break
231
+ // 持久化消息:只有含正文(text 块)才算输出对话;仅思维链/工具调用不算
232
+ if (!messageHasDialogue(event.data)) break
233
+ speakingLocked = true
162
234
  setState(DSHState.SPEAKING)
163
235
  break
164
236
 
@@ -166,6 +238,12 @@ function handleSessionEvent(session, event) {
166
238
  const callId = toolCallIdOf(event, `seq-${event.seq ?? 'unknown'}`)
167
239
  const name = String(event.data?.name ?? event.data?.message?.name ?? 'tool')
168
240
  openTools.set(callId, name)
241
+ // 若该工具是“向用户提问/请求确认”类(AskUserQuestion、exit plan mode 等),
242
+ // 标记为等待用户回答:这类调用会一直挂在 openTools 里(直到用户回复),
243
+ // 用户回复时靠 waitingCallId 把它清掉并回到 THINKING,否则会一直显示 WORKING。
244
+ if (isUserQuestionTool(name)) waitingCallId = callId
245
+ // 已开始输出正文:不被工具调用打断,保持「输出对话」直到本轮结束
246
+ if (speakingLocked) break
169
247
  setState(DSHState.WORKING)
170
248
  break
171
249
  }
@@ -174,6 +252,8 @@ function handleSessionEvent(session, event) {
174
252
  const callId = toolCallIdOf(event)
175
253
  if (callId) openTools.delete(callId)
176
254
  if (callId && callId === waitingCallId) waitingCallId = undefined
255
+ // 已开始输出正文:继续保持「输出对话」
256
+ if (speakingLocked) break
177
257
  setState(openTools.size > 0 ? DSHState.WORKING : DSHState.THINKING)
178
258
  break
179
259
  }
@@ -182,6 +262,8 @@ function handleSessionEvent(session, event) {
182
262
  if (waitingCallId) {
183
263
  openTools.delete(waitingCallId)
184
264
  waitingCallId = undefined
265
+ // 用户回复后新一轮开始:解除正文锁定,重新从思考开始
266
+ speakingLocked = false
185
267
  setState(DSHState.THINKING)
186
268
  }
187
269
  break
@@ -190,16 +272,17 @@ function handleSessionEvent(session, event) {
190
272
  openTools.clear()
191
273
  waitingCallId = undefined
192
274
  const kind = String(event.data?.reason?.kind ?? 'completed')
275
+ // 本轮结束:立即切到完成/错误,不再人为保留「输出对话」。
276
+ // 正文的开始与结束现在由 agent/assistant-stream 实时驱动(见 handleAssistantStream),
277
+ // SPEAKING 已覆盖真实输出时长,无需再按字数估算补一段展示时间。
278
+ speakingLocked = false
279
+
193
280
  if (kind === 'completed') {
194
281
  setState(DSHState.SUCCESS)
195
- setTimeout(() => {
196
- if (currentState === DSHState.SUCCESS) setState(DSHState.IDLE)
197
- }, 2500)
282
+ scheduleIdleReset(DSHState.SUCCESS, 2500)
198
283
  } else if (kind !== 'blocked') {
199
284
  setState(DSHState.ERROR)
200
- setTimeout(() => {
201
- if (currentState === DSHState.ERROR) setState(DSHState.IDLE)
202
- }, 4000)
285
+ scheduleIdleReset(DSHState.ERROR, 4000)
203
286
  }
204
287
  break
205
288
  }
@@ -221,17 +304,6 @@ function isLoopback(address) {
221
304
  return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
222
305
  }
223
306
 
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
307
  /**
236
308
  * 把 URL 子路径解析成 `rootDir` 下的真实文件。
237
309
  * 拒绝任何逃出 rootDir 的路径(`..`、绝对路径、分隔符穿越)。
@@ -240,7 +312,9 @@ async function readPatch(req) {
240
312
  function resolveWithin(rootDir, rel) {
241
313
  if (!rootDir || !rel) return null
242
314
  if (rel.includes('\0')) return null
315
+ // 归一化后为空(如 "."、"./"、"//")视为非法,避免解析到 rootDir 目录本身
243
316
  const abs = resolve(rootDir, rel)
317
+ if (abs === rootDir) return null
244
318
  if (abs !== rootDir && !abs.startsWith(rootDir + sep)) return null
245
319
  return abs
246
320
  }
@@ -344,6 +418,19 @@ export function apply(ctx) {
344
418
  }
345
419
  }, { global: true })
346
420
 
421
+ // 监听「实时助手流」(agent/assistant-stream):
422
+ // session/event 是「持久化事件日志」,只在事件落盘时触发,因此正文不是流式落盘时
423
+ // 我们只能在 assistant/message(已完成的整条消息)才得知正文存在 —— 触发时机自然滞后。
424
+ // agent/assistant-stream 是逐 chunk 的实时发布(含 text-delta / reasoning-delta),
425
+ // 用它才能做到「正文一开始输出就进入输出对话」。
426
+ const offStream = ctx.on('agent/assistant-stream', (payload) => {
427
+ try {
428
+ handleAssistantStream(payload)
429
+ } catch (err) {
430
+ logger.error?.('dsh-live2d: failed to handle assistant stream', err)
431
+ }
432
+ }, { global: true })
433
+
347
434
  // 注册 webserver 路由
348
435
  if (typeof ctx.inject === 'function') {
349
436
  ctx.inject(['webServer'], (httpCtx) => {
@@ -398,92 +485,81 @@ export function apply(ctx) {
398
485
  'dsh-live2d: state sse endpoint',
399
486
  )
400
487
 
401
- // GET/PATCH/POST /plugins/dsh-live2d/config
488
+ // POST /plugins/dsh-live2d/config — 上传整个模型文件夹(multipart/form-data)。
489
+ // 注:原先的 GET/PATCH 分支只是空壳(GET 恒返回 {ok:true},PATCH 仅回显且从不落盘),
490
+ // 客户端也从不请求它们,已删除以免误导;客户端配置以 localStorage 为准。
402
491
  httpCtx.effect(
403
492
  () => httpCtx.webServer.register({
404
493
  kind: 'exact',
405
- path: CONFIG_ENDPOINT,
494
+ path: UPLOAD_ENDPOINT,
406
495
  handler: async (req, res) => {
407
496
  if (!isLoopback(req.socket?.remoteAddress)) {
408
497
  jsonResponse(res, 403, { error: 'local access only' })
409
498
  return
410
499
  }
411
- if (req.method === 'GET') {
412
- jsonResponse(res, 200, { ok: true })
500
+ if (req.method !== 'POST') {
501
+ jsonResponse(res, 405, { error: 'method not allowed' })
413
502
  return
414
503
  }
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) })
504
+ // 上传整个模型文件夹:multipart/form-data,字段 dirName + 若干 file(含 webkitRelativePath)
505
+ try {
506
+ if (!ASSETS_DIR) {
507
+ jsonResponse(res, 500, { error: 'assets dir unavailable' })
508
+ return
421
509
  }
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) })
510
+ const ct = req.headers['content-type'] || ''
511
+ if (!ct.includes('multipart/form-data')) {
512
+ jsonResponse(res, 400, { error: 'expected multipart/form-data' })
513
+ return
480
514
  }
481
- return
515
+ const parts = await parseMultipart(req)
516
+ const dirField = parts.find((p) => p.name === 'dirName')
517
+ const dirName = (dirField ? dirField.data.toString('utf8') : '').trim()
518
+ if (!dirName || dirName.includes('/') || dirName.includes('\\') || dirName.includes('..') || /[^\w.\-]/.test(dirName)) {
519
+ jsonResponse(res, 400, { error: 'invalid dirName' })
520
+ return
521
+ }
522
+ const files = parts.filter((p) => p.filename)
523
+ if (files.length === 0) {
524
+ jsonResponse(res, 400, { error: 'no files' })
525
+ return
526
+ }
527
+ // 必须包含 model3.json
528
+ const hasModel3 = files.some((p) => p.filename.toLowerCase().endsWith('.model3.json'))
529
+ if (!hasModel3) {
530
+ jsonResponse(res, 400, { error: 'missing .model3.json in folder' })
531
+ return
532
+ }
533
+ const targetDir = join(ASSETS_DIR, dirName)
534
+ const abs = resolve(targetDir)
535
+ if (abs !== ASSETS_DIR && !abs.startsWith(ASSETS_DIR + sep)) {
536
+ jsonResponse(res, 400, { error: 'invalid dirName' })
537
+ return
538
+ }
539
+ // 覆盖式写入:先清空旧目录
540
+ try { rmSync(targetDir, { recursive: true, force: true }) } catch {}
541
+ mkdirSync(targetDir, { recursive: true })
542
+ let written = 0
543
+ for (const f of files) {
544
+ // 用 webkitRelativePath(去掉首个目录段即 dirName)决定相对位置
545
+ let relPath = f.webkitRelativePath || f.filename
546
+ // webkitRelativePath 形如 dirName/sub/file —— 去掉开头的 dirName/
547
+ const segs = relPath.split('/').filter(Boolean)
548
+ if (segs.length > 1 && segs[0] === dirName) segs.shift()
549
+ const rel = segs.join('/')
550
+ const fpath = resolveWithin(targetDir, rel)
551
+ if (!fpath) continue
552
+ mkdirSync(dirname(fpath), { recursive: true })
553
+ writeFileSync(fpath, f.data)
554
+ written += 1
555
+ }
556
+ jsonResponse(res, 200, { ok: true, dirName, fileCount: written, modelUrl: `/plugins/dsh-live2d/assets/${dirName}/model.model3.json` })
557
+ } catch (err) {
558
+ jsonResponse(res, 400, { error: err instanceof Error ? err.message : String(err) })
482
559
  }
483
- jsonResponse(res, 405, { error: 'method not allowed' })
484
560
  },
485
561
  }),
486
- 'dsh-live2d: config endpoint',
562
+ 'dsh-live2d: model upload endpoint',
487
563
  )
488
564
 
489
565
  // ── 模型自动扫描接口 ──────────────────────────────────
@@ -618,6 +694,12 @@ export function apply(ctx) {
618
694
  res.end()
619
695
  return
620
696
  }
697
+ // 与其他端点保持一致:仅允许本机访问
698
+ if (!isLoopback(req.socket?.remoteAddress)) {
699
+ res.writeHead(403)
700
+ res.end('local access only')
701
+ return
702
+ }
621
703
  const urlPath = new URL(req.url || '/', 'http://localhost').pathname
622
704
  const rel = decodeURIComponent(urlPath.slice(routePath.length).replace(/^\//u, ''))
623
705
  const filePath = resolveWithin(dir, rel)
@@ -638,5 +720,11 @@ export function apply(ctx) {
638
720
  // 清理
639
721
  ctx.effect(() => () => {
640
722
  offEvent?.()
723
+ offStream?.()
724
+ // 关闭并清空所有 SSE 连接,避免插件卸载/热重载后残留客户端(集合泄漏 + 僵尸连接)
725
+ for (const res of stateClients) {
726
+ try { res.end() } catch {}
727
+ }
728
+ stateClients.clear()
641
729
  }, 'dsh-live2d: cleanup')
642
730
  }
@@ -0,0 +1,16 @@
1
+ // dsh-live2d — 共享的状态常量(host 与 client 的单一来源)。
2
+ //
3
+ // host 端(lib/index.js)直接 `import { DSHState }`;
4
+ // client 端(lib/client.js)由 scripts/build-client.mjs 在构建时内联本文件内容
5
+ // (浏览器侧无法 import 本地文件,client.js 必须保持单文件投放)。
6
+ //
7
+ // 本文件不得依赖 node 或浏览器任何专有 API,保持“纯数据 + 纯函数”以便两端复用。
8
+
9
+ export const DSHState = Object.freeze({
10
+ IDLE: 'IDLE',
11
+ THINKING: 'THINKING',
12
+ WORKING: 'WORKING',
13
+ SPEAKING: 'SPEAKING', // 开始输出对话内容
14
+ SUCCESS: 'SUCCESS',
15
+ ERROR: 'ERROR',
16
+ })
@@ -0,0 +1,21 @@
1
+ // dsh-live2d — Client (browser) side.
2
+ //
3
+ // DSH 插件:在 Web GUI 中渲染 Live2D 看板娘模型。
4
+ //
5
+ // 功能:
6
+ // - Live2D 模型渲染(PIXI.js + pixi-live2d-dsl,全部本地加载)
7
+ // - 鼠标拖动移动位置
8
+ // - DSH 状态驱动动画(思考/工作/完成/错误)
9
+ // - 设置页:模型列表编辑、画布大小配置、动画映射
10
+ // - 支持本地模型导入
11
+ //
12
+ // 移除了原项目的:对话框、小飞机游戏、关闭/关于/GitHub 按钮。
13
+
14
+ window.__ModuleLoader__.load({ id: '@lrplrplrp/dsh-live2d', factory: (require) => {
15
+ var module = { exports: {} }
16
+ var exports = module.exports
17
+
18
+ const React = require('react')
19
+ const { useState, useEffect, useRef, useCallback } = React
20
+ const h = React.createElement
21
+
@@ -0,0 +1,172 @@
1
+ // ══════════════════════════════════════════════════════════════════════
2
+ // 常量 & 配置
3
+ // ══════════════════════════════════════════════════════════════════════
4
+ // (源码分片:常量、默认模型列表、localStorage 配置读写与内存缓存(+ 构建时内联的共享 DSHState))
5
+
6
+ const STORAGE_KEY = 'dsh-live2d.v1'
7
+ const POSITION_KEY = 'dsh-live2d.position'
8
+ const ACTIVE_MODEL_KEY = 'dsh-live2d.activeModel'
9
+
10
+ // 容器默认屏幕位置(无本地存档时使用),对应 position:fixed 的 left/top
11
+ const DEFAULT_POSITION = { x: 1240, y: 330 }
12
+
13
+ // 本地文件路由前缀(由 host 端 index.js 注册)
14
+ const LIB_BASE = '/plugins/dsh-live2d/lib/'
15
+ const ASSETS_BASE = '/plugins/dsh-live2d/assets/'
16
+
17
+ // 需要按【依赖顺序】加载的本地库文件,分组表示加载阶段:
18
+ // - 第一段 pixi 必须先行执行完成;
19
+ // - 第二段的 live2d / core / display 可并行,但都要在 pixi 之后(display 在
20
+ // 加载期就访问 PIXI,若与 pixi 并行下载可能因 pixi 体积大而晚到,导致报错)。
21
+ const LIB_PHASES = [
22
+ ['pixi.min.js'],
23
+ ['live2d.min.js', 'live2dcubismcore.min.js', 'index.min.js'],
24
+ ]
25
+
26
+ // DSHState 由 lib/shared/states.js 内联提供(host 与 client 的单一来源),
27
+ // 见 scripts/build-client.mjs —— 此处不再重复定义。
28
+
29
+ // 客户端动画状态:除 host 广播的 DSHState 外,额外含 WELCOME(欢迎)与
30
+ // THINK_END(思考结束过渡态,由客户端在 THINKING → 非 THINKING 时触发一次)。
31
+ const ANIMATION_STATES = ['WELCOME', 'IDLE', 'THINKING', 'THINK_END', 'WORKING', 'SPEAKING', 'SUCCESS', 'ERROR']
32
+
33
+ const ANIMATION_STATE_LABELS = {
34
+ WELCOME: '欢迎',
35
+ IDLE: '空闲',
36
+ THINKING: '开始思考',
37
+ THINK_END: '思考结束',
38
+ WORKING: '工具调用',
39
+ SPEAKING: '输出对话',
40
+ SUCCESS: '任务完成',
41
+ ERROR: '任务出错',
42
+ }
43
+
44
+ const DEFAULT_MODEL_LIST = {
45
+ models: [{
46
+ name: 'DeepSeek',
47
+ url: ASSETS_BASE + 'deepseek_l2d/deepseek.model3.json',
48
+ canvasWidth: 300,
49
+ canvasHeight: 400,
50
+ config: { x: 0, y: 0, scaleX: 1.5, scaleY: 1.5 },
51
+ animations: {
52
+ WELCOME: { motion: { group: 'Idle', index: 0 }, loop: false },
53
+ IDLE: { motion: { group: 'Idle', index: 0 }, loop: false },
54
+ THINKING: { motion: { group: 'Anima', index: 2 }, loop: true },
55
+ THINK_END: { motion: { group: 'Anima', index: 1 }, loop: false },
56
+ WORKING: { expression: 'puzzled' },
57
+ SPEAKING: { motion: { group: 'Anima', index: 0 }, loop: true },
58
+ SUCCESS: { expression: 'happy' },
59
+ ERROR: { expression: 'unhappy' },
60
+ },
61
+ }],
62
+ // 眼睛是否跟随鼠标(全局开关)
63
+ eyeFollow: true,
64
+ // 点击命中区域开关(全局开关,默认开启)
65
+ hitArea: true,
66
+ }
67
+
68
+ // ══════════════════════════════════════════════════════════════════════
69
+ // 配置管理(localStorage 持久化)
70
+ // ══════════════════════════════════════════════════════════════════════
71
+
72
+ // 规范化配置:保证 models 一定是数组,且每个 model 的动画相关字段都是数组。
73
+ // 兼容早期脏数据(如 models 被存成对象 {} 而非数组,或字段缺失)。
74
+ function normalizeConfig(cfg) {
75
+ if (!cfg || typeof cfg !== 'object') cfg = {}
76
+ var models = cfg.models
77
+ if (!Array.isArray(models)) models = []
78
+ models = models.map(function(m) {
79
+ if (!m || typeof m !== 'object') return { name: '', url: '', groups: [], groupCounts: {}, groupMotions: {}, expressions: [], animations: {}, timeMappings: [] }
80
+ return {
81
+ name: m.name || '',
82
+ url: m.url || '',
83
+ canvasWidth: m.canvasWidth || 300,
84
+ canvasHeight: m.canvasHeight || 400,
85
+ config: m.config || { x: 0, y: 0, scaleX: 1, scaleY: 1 },
86
+ groups: Array.isArray(m.groups) ? m.groups : [],
87
+ groupCounts: m.groupCounts && typeof m.groupCounts === 'object' ? m.groupCounts : {},
88
+ groupMotions: m.groupMotions && typeof m.groupMotions === 'object' ? m.groupMotions : {},
89
+ expressions: Array.isArray(m.expressions) ? m.expressions : [],
90
+ animations: m.animations && typeof m.animations === 'object' ? m.animations : {},
91
+ // 时间映射:[{ start: 'HH:MM', end: 'HH:MM', animation: {...} }]
92
+ timeMappings: Array.isArray(m.timeMappings) ? m.timeMappings : [],
93
+ }
94
+ })
95
+ return Object.assign({}, cfg, { models: models, eyeFollow: cfg.eyeFollow === false ? false : true, hitArea: cfg.hitArea === false ? false : true })
96
+ }
97
+
98
+ // 配置在内存中缓存一份:缩放 / 拖动画布这类高频操作需要在内存里连续累加,
99
+ // 不能每次都从 localStorage 重新解析(否则上一次的防抖写入还没落盘,会读到旧值)。
100
+ // 读取一律走缓存,写入由 saveConfig 防抖落盘,保证内存与持久化一致。
101
+ let _configCache = null
102
+
103
+ function getConfig() {
104
+ if (_configCache) return _configCache
105
+ try {
106
+ const raw = window.localStorage.getItem(STORAGE_KEY)
107
+ _configCache = raw
108
+ ? normalizeConfig(JSON.parse(raw))
109
+ : normalizeConfig(JSON.parse(JSON.stringify(DEFAULT_MODEL_LIST)))
110
+ } catch {
111
+ _configCache = normalizeConfig(JSON.parse(JSON.stringify(DEFAULT_MODEL_LIST)))
112
+ }
113
+ return _configCache
114
+ }
115
+
116
+ let _saveTimer = null
117
+ function saveConfig(cfg) {
118
+ // 始终以传入对象(或当前缓存)作为权威状态,确保与内存一致
119
+ if (cfg) _configCache = cfg
120
+ if (_saveTimer) clearTimeout(_saveTimer)
121
+ _saveTimer = setTimeout(function() {
122
+ _saveTimer = null
123
+ try {
124
+ if (_configCache) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(_configCache))
125
+ } catch {}
126
+ }, 150)
127
+ }
128
+
129
+ // 立即落盘:在页面卸载/隐藏前把待写的配置同步刷入 localStorage,
130
+ // 避免 debounce 窗口内(150ms)用户关闭页面导致最后一次修改丢失。
131
+ function flushConfig() {
132
+ if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null }
133
+ try {
134
+ if (_configCache) window.localStorage.setItem(STORAGE_KEY, JSON.stringify(_configCache))
135
+ } catch {}
136
+ }
137
+ window.addEventListener('pagehide', flushConfig)
138
+ window.addEventListener('beforeunload', flushConfig)
139
+
140
+ function getPosition() {
141
+ try {
142
+ const raw = window.localStorage.getItem(POSITION_KEY)
143
+ if (!raw) return null
144
+ return JSON.parse(raw)
145
+ } catch {
146
+ return null
147
+ }
148
+ }
149
+
150
+ function savePosition(x, y) {
151
+ try {
152
+ window.localStorage.setItem(POSITION_KEY, JSON.stringify({ x, y }))
153
+ } catch {}
154
+ }
155
+
156
+ function getActiveModelIndex(models) {
157
+ let idx = 0
158
+ try {
159
+ const raw = window.localStorage.getItem(ACTIVE_MODEL_KEY)
160
+ if (raw !== null) {
161
+ const n = parseInt(raw, 10)
162
+ if (Number.isFinite(n) && n >= 0 && n < models.length) idx = n
163
+ }
164
+ } catch {}
165
+ return idx
166
+ }
167
+
168
+ function saveActiveModelIndex(idx) {
169
+ try {
170
+ window.localStorage.setItem(ACTIVE_MODEL_KEY, String(idx))
171
+ } catch {}
172
+ }