@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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/assets/BCSZ1.1/BCSZ1.1.cdi3.json +470 -0
- package/assets/BCSZ1.1/BCSZ1.1.moc3 +0 -0
- package/assets/BCSZ1.1/BCSZ1.1.model3.json +174 -0
- package/assets/BCSZ1.1/BCSZ1.1.physics3.json +2494 -0
- package/assets/BCSZ1.1/SZ-/344/270/215/345/217/230.motion3.json +13703 -0
- package/assets/BCSZ1.1/SZ-/344/270/255/345/215/210.motion3.json +7201 -0
- package/assets/BCSZ1.1/SZ-/345/210/235/350/247/201.motion3.json +27609 -0
- package/assets/BCSZ1.1/SZ-/345/244/234/346/231/232.motion3.json +14909 -0
- package/assets/BCSZ1.1/SZ-/345/244/264/351/245/260.motion3.json +69 -0
- package/assets/BCSZ1.1/SZ-/345/260/276/345/267/264.motion3.json +840 -0
- package/assets/BCSZ1.1/SZ-/345/261/221/347/234/274/347/245/236.motion3.json +69 -0
- package/assets/BCSZ1.1/SZ-/345/275/222/351/233/266.motion3.json +300 -0
- package/assets/BCSZ1.1/SZ-/346/227/240/350/201/212.motion3.json +6617 -0
- package/assets/BCSZ1.1/SZ-/346/227/251/344/270/212/345/245/275.motion3.json +16785 -0
- package/assets/BCSZ1.1/template.json +59 -0
- package/assets/BCSZ1.1/textures/texture_00.png +0 -0
- package/assets/BCSZ1.1/waifu-tips-BCSZ.json +49 -0
- 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
- 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
- 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
- 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
- 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
- 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
- package/assets/deepseek_l2d/deepseek.2048/texture_00.png +0 -0
- package/assets/deepseek_l2d/deepseek.cdi3.json +205 -0
- package/assets/deepseek_l2d/deepseek.moc3 +0 -0
- package/assets/deepseek_l2d/deepseek.model3.json +67 -0
- package/assets/deepseek_l2d/deepseek.physics3.json +311 -0
- package/assets/deepseek_l2d/happy.exp3.json +45 -0
- package/assets/deepseek_l2d/happy2.exp3.json +35 -0
- package/assets/deepseek_l2d/idle.motion3.json +5574 -0
- package/assets/deepseek_l2d/index.html +13 -0
- package/assets/deepseek_l2d/puzzled.exp3.json +45 -0
- package/assets/deepseek_l2d/relax.exp3.json +60 -0
- package/assets/deepseek_l2d/speak.motion3.json +6975 -0
- package/assets/deepseek_l2d/thinkend.motion3.json +876 -0
- package/assets/deepseek_l2d/thinking.motion3.json +4992 -0
- package/assets/deepseek_l2d/unhappy.exp3.json +50 -0
- package/assets/model_list.json +39 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +1487 -0
- package/lib/index.js +642 -0
- package/lib/index.min.js +1 -0
- package/lib/live2d.min.js +1 -0
- package/lib/live2dcubismcore.min.js +9 -0
- package/lib/pixi.min.js +9 -0
- 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
|
+
}
|