@lrplrplrp/dsh-live2d 0.1.2 → 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/README.md +32 -1
- package/cordis.patch.yml +2 -2
- package/lib/client.js +1059 -142
- package/lib/index.js +189 -101
- package/lib/shared/states.js +16 -0
- package/lib/src/00-header.js +21 -0
- package/lib/src/10-config.js +172 -0
- package/lib/src/20-libs.js +195 -0
- package/lib/src/30-css.js +121 -0
- package/lib/src/40-engine.js +704 -0
- package/lib/src/50-drag.js +141 -0
- package/lib/src/60-state.js +113 -0
- package/lib/src/70-widget.js +271 -0
- package/lib/src/80-settings.js +618 -0
- package/lib/src/90-apply.js +54 -0
- package/package.json +5 -2
- package/scripts/build-client.mjs +88 -0
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
|
|
|
@@ -37,10 +39,11 @@ const PLUGIN_ROOT = resolvedDir ? join(resolvedDir, '..') : null
|
|
|
37
39
|
const LIB_DIR = PLUGIN_ROOT ? join(PLUGIN_ROOT, 'lib') : null
|
|
38
40
|
const ASSETS_DIR = PLUGIN_ROOT ? join(PLUGIN_ROOT, 'assets') : null
|
|
39
41
|
|
|
40
|
-
export const name = 'dsh-live2d'
|
|
42
|
+
export const name = '@lrplrplrp/dsh-live2d'
|
|
41
43
|
|
|
42
44
|
const STATE_ENDPOINT = '/plugins/dsh-live2d/state'
|
|
43
|
-
|
|
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
|
-
// ──
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
|
412
|
-
jsonResponse(res,
|
|
500
|
+
if (req.method !== 'POST') {
|
|
501
|
+
jsonResponse(res, 405, { error: 'method not allowed' })
|
|
413
502
|
return
|
|
414
503
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
jsonResponse(res,
|
|
419
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|