dsh-live2dwaifu 2.0.1 → 2.2.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/lib/index.js CHANGED
@@ -1,943 +1,1069 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, createReadStream } from 'node:fs';
2
- import { join, resolve, normalize, relative, extname, sep } from 'node:path';
3
- import { homedir } from 'node:os';
4
- import { fileURLToPath } from 'node:url';
5
-
6
- const HERE = fileURLToPath(new URL('.', import.meta.url));
7
- const ASSETS = resolve(HERE, '..', 'assets');
8
- const ROOT = join(homedir(), '.dsh', 'dsh-live2d');
9
- const MODELS = join(ROOT, 'models');
10
- const CONFIG = join(ROOT, 'config.json');
11
- const PREFIX = '/dsh-live2d';
12
- const MEMES_ROOT = join(homedir(), '.dsh', 'profiles', 'desktop', 'node_modules', 'dsh-meme', 'memes');
13
-
14
- /** 情绪 → 图库分类 */
15
- const MEME_TAGS = {
16
- 开心: ['happy', 'like'],
17
- 生气: ['angry', 'baka'],
18
- 委屈: ['sad', 'fool'],
19
- 无语: ['sigh', 'cpu', 'color'],
20
- 害羞: ['shy', 'see'],
21
- 困: ['sleep'],
22
- 惊讶: ['surprised'],
23
- 平静: ['daily', 'morning', 'meow'],
24
- };
25
-
26
- const DEFAULT_SETTINGS = {
27
- /** 多久没人说话才自己开口,单位分钟;0 = 关闭 */
28
- proactiveInterval: 30,
29
- /** off | own | both | external —— 测试功能,默认关 */
30
- memesMode: 'off',
31
- choices: true,
32
- memory: true,
33
- follow: true,
34
- gaze: true,
35
- poke: true,
36
- motion: true,
37
- sound: true,
38
- };
39
-
40
- const MEMES_MODES = ['off', 'own', 'both', 'external'];
41
- const OWN_MEME_DIR = join(ASSETS, 'memes');
42
-
43
- function readOwnMemes() {
44
- try {
45
- const m = JSON.parse(readFileSync(join(OWN_MEME_DIR, 'manifest.json'), 'utf8'));
46
- return Array.isArray(m.items) ? m.items : [];
47
- } catch {
48
- return [];
49
- }
50
- }
51
-
52
- /** 从自带那套里按情绪挑一张,返回文件名 */
53
- function pickOwnMeme(mood) {
54
- const items = readOwnMemes();
55
- if (!items.length) return null;
56
- const hit = items.filter((it) => Array.isArray(it.moods) && it.moods.includes(mood));
57
- const pool = hit.length ? hit : items;
58
- const it = pool[Math.floor(Math.random() * pool.length)];
59
- return it && it.file ? it.file : null;
60
- }
61
-
62
- /** 按模式挑一张配图,返回可直接用的 URL */
63
- function pickImage(mode, mood, label) {
64
- const m = MEMES_MODES.includes(mode) ? mode : 'off';
65
- if (m === 'off') return null;
66
- const pool = [];
67
- if (m === 'own' || m === 'both') {
68
- const f = (label && pickOwnMemeByLabel(label)) || pickOwnMeme(mood);
69
- if (f) pool.push('/dsh-live2d/ownmeme/' + encodeURIComponent(f));
70
- }
71
- if (m === 'external' || m === 'both') {
72
- const f = pickMeme(mood);
73
- if (f) pool.push('/dsh-live2d/media/' + f.split('/').map(encodeURIComponent).join('/'));
74
- }
75
- if (!pool.length) return null;
76
- return pool[Math.floor(Math.random() * pool.length)];
77
- }
78
-
79
- function settingsOf(cfg) {
80
- return { ...DEFAULT_SETTINGS, ...(cfg?.settings || {}) };
81
- }
82
-
83
- /** 从表情图库按情绪挑一张,返回相对路径 */
84
- function pickMeme(mood) {
85
- try {
86
- const tags = MEME_TAGS[mood] || MEME_TAGS['平静'];
87
- const galleries = readdirSync(MEMES_ROOT, { withFileTypes: true }).filter((e) => e.isDirectory());
88
- const pool = [];
89
- for (const g of galleries) {
90
- for (const tag of tags) {
91
- const dir = join(MEMES_ROOT, g.name, 'memes', tag);
92
- let files = [];
93
- try {
94
- files = readdirSync(dir);
95
- } catch {
96
- continue;
97
- }
98
- for (const f of files) {
99
- if (/\.(png|jpe?g|webp|gif)$/i.test(f)) pool.push(g.name + '/memes/' + tag + '/' + f);
100
- }
101
- }
102
- }
103
- if (!pool.length) return null;
104
- return pool[Math.floor(Math.random() * pool.length)];
105
- } catch {
106
- return null;
107
- }
108
- }
109
-
110
- /** 从回复里摘出「||选项1|选项2」这种尾巴 */
111
- function splitChoices(raw) {
112
- const m = /\|\|\s*([^|]+(?:\|[^|]+)*)\s*$/.exec(raw);
113
- if (!m) return { text: raw.trim(), choices: [] };
114
- const choices = m[1]
115
- .split('|')
116
- .map((s) => s.trim())
117
- .filter(Boolean)
118
- .slice(0, 4);
119
- return { text: raw.slice(0, m.index).trim(), choices };
120
- }
121
-
122
- /** 先摘「||图:名字」,再摘「||选项1|选项2」 */
123
- function splitMeta(raw) {
124
- let text = String(raw || '');
125
- let imageLabel = '';
126
- const imgMatch = /\|\|\s*图\s*[::]\s*([^\n|]+)\s*$/m.exec(text);
127
- if (imgMatch) {
128
- imageLabel = imgMatch[1].trim().replace(/^["“「『']+|["”」』']+$/g, '');
129
- text = (text.slice(0, imgMatch.index) + text.slice(imgMatch.index + imgMatch[0].length)).trim();
130
- }
131
- const parsed = splitChoices(text);
132
- return { text: parsed.text, choices: parsed.choices, imageLabel };
133
- }
134
-
135
- /** 按名字取自带那张 */
136
- function pickOwnMemeByLabel(label) {
137
- const items = readOwnMemes();
138
- if (!items.length || !label) return null;
139
- const key = String(label).trim();
140
- const hit =
141
- items.find((it) => it.label === key) ||
142
- items.find((it) => String(it.file).replace(/\.\w+$/, '') === key) ||
143
- items.find((it) => key && String(it.label).includes(key));
144
- return hit ? hit.file : null;
145
- }
146
- function guessMood(text) {
147
- const s = String(text || '');
148
- if (/[气烦讨厌]/.test(s)) return '生气';
149
- if (/[委屈难过哭]/.test(s)) return '委屈';
150
- if (/[困睡熬夜]/.test(s)) return '困';
151
- if (/[唔害羞]/.test(s)) return '害羞';
152
- if (/[无语算了]/.test(s)) return '无语';
153
- if (/[好谢喜欢]/.test(s)) return '开心';
154
- return '平静';
155
- }
156
-
157
- /** 默认音色:一个字一声短音 */
158
- const DEFAULT_VOICE = { preset: 'soft', type: 'triangle', base: 400, jit: 10, dur: 60, vol: 32, spd: 62 };
159
-
160
- /** 没写人设卡时用的通用版,不绑定具体角色 */
161
- const DEFAULT_PERSONA = [
162
- '你叫{name}。',
163
- '说话短,一句常常不到十五个字;不用感叹号,不写书面语,不解释心情。',
164
- '情绪落在具体的东西上——桌上有什么、手上在做什么、外面天色怎样。',
165
- '不总结,不升华,不写括号里的动作。',
166
- ].join('\n');
167
-
168
- /** 参考密度:只给节奏和信息量,不是让模型照抄内容 */
169
- const TALK_SAMPLES = [
170
- '他:今天吃啥 —— 你:锅里还有,热的。',
171
- '他:累死了 —— 你:先坐下。水在桌上。',
172
- '他:你在干嘛 —— 你:擦桌子。手是湿的。',
173
- '他:我想你了 —— 你:嗯。我也是。',
174
- '他:明天要早起 —— 你:那我今晚早点关灯。',
175
- ].join('\n');
176
-
177
- /** 世界隔离:不说破自己在哪儿 */
178
- const ISOLATION = [
179
- '【世界隔离】你不知道什么是程序、界面、插件、模型、参数。',
180
- '有人问起,你就当他就站在你身边。不要提“系统”“设置”“网页”“token”这些词。',
181
- '你不知道他那边什么样,得他讲给你听。',
182
- ].join('\n');
183
-
184
- /** 语料:lines = 她的台词,pairs = 一问一答 */
185
- function readCorpus(id) {
186
- try {
187
- const d = JSON.parse(readFileSync(join(MODELS, id, 'corpus.json'), 'utf8'));
188
- return {
189
- lines: Array.isArray(d.lines) ? d.lines.filter((x) => typeof x === 'string' && x.trim()).slice(0, 500) : [],
190
- pairs: Array.isArray(d.pairs)
191
- ? d.pairs
192
- .filter((p) => p && typeof p.assistant === 'string' && p.assistant.trim())
193
- .slice(0, 500)
194
- .map((p) => ({ user: String(p.user || '').slice(0, 200), assistant: String(p.assistant).slice(0, 400) }))
195
- : [],
196
- };
197
- } catch {
198
- return { lines: [], pairs: [] };
199
- }
200
- }
201
-
202
- function writeCorpus(id, corpus) {
203
- try {
204
- writeFileSync(
205
- join(MODELS, id, 'corpus.json'),
206
- JSON.stringify({ lines: corpus.lines.slice(0, 500), pairs: corpus.pairs.slice(0, 500) }, null, 2),
207
- 'utf8',
208
- );
209
- } catch {}
210
- }
211
-
212
- /** 从语料里挑几条最贴当前对话的,拼成样例 */
213
- function pickCorpusSamples(corpus, text, toneN = 2) {
214
- const out = [];
215
- const query = String(text || '').trim();
216
- if (query) {
217
- const chars = new Set(query);
218
- const scored = (corpus.pairs || [])
219
- .map((p) => {
220
- let hit = 0;
221
- for (const ch of String(p.user || '')) if (chars.has(ch)) hit += 1;
222
- return { p, hit };
223
- })
224
- .sort((a, b) => b.hit - a.hit);
225
- for (const s of scored.slice(0, 2)) {
226
- if (s.p.user) out.push('他:' + s.p.user + ' —— 你:' + s.p.assistant);
227
- else out.push('你:' + s.p.assistant);
228
- }
229
- }
230
- const pool = (corpus.lines || []).slice();
231
- for (let i = 0; i < toneN && pool.length; i += 1) {
232
- const k = Math.floor(Math.random() * pool.length);
233
- out.push('你:' + pool.splice(k, 1)[0]);
234
- }
235
- return out;
236
- }
237
-
238
- function personaOf(meta) {
239
- const p = typeof meta?.persona === 'string' ? meta.persona.trim() : '';
240
- if (p) return p;
241
- const nm = String(meta?.name || '她').trim() || '她';
242
- return DEFAULT_PERSONA.replace('{name}', nm);
243
- }
244
-
245
- function readChatStore(id) {
246
- try {
247
- const d = JSON.parse(readFileSync(join(MODELS, id, 'chat.json'), 'utf8'));
248
- return {
249
- messages: Array.isArray(d.messages) ? d.messages : [],
250
- summary: typeof d.summary === 'string' ? d.summary : '',
251
- facts: Array.isArray(d.facts) ? d.facts : [],
252
- };
253
- } catch {
254
- return { messages: [], summary: '', facts: [] };
255
- }
256
- }
257
-
258
- function readChat(id) {
259
- return readChatStore(id).messages;
260
- }
261
-
262
- function writeChat(id, messages, summary, facts) {
263
- try {
264
- const prev = readChatStore(id);
265
- const data = {
266
- messages: messages.slice(-40),
267
- summary: summary === undefined ? prev.summary : summary,
268
- facts: (facts === undefined ? prev.facts : facts).slice(-60),
269
- };
270
- writeFileSync(join(MODELS, id, 'chat.json'), JSON.stringify(data, null, 2), 'utf8');
271
- } catch {}
272
- }
273
-
274
- /** 把压缩出来的两段拆开:小结 + 关于他的事实 */
275
- function parseDigest(raw) {
276
- let text = String(raw || '').trim();
277
- let summary = text;
278
- const facts = [];
279
- const at = text.search(/事实\s*[::]/);
280
- if (at >= 0) {
281
- summary = text.slice(0, at).replace(/^小结\s*[::]?\s*/, '').trim();
282
- const body = text.slice(at).replace(/^事实\s*[::]\s*/, '');
283
- for (const line of body.split(/\n+/)) {
284
- const item = line.replace(/^[-*·\s]+/, '').trim();
285
- if (item.length >= 2 && item.length <= 60) facts.push(item);
286
- }
287
- }
288
- summary = summary.replace(/^小结\s*[::]?\s*/, '').trim();
289
- return { summary, facts: facts.slice(0, 12) };
290
- }
291
-
292
- /** 攒够了就把前二十句压成小结 + 事实,人物记得住更早的事 */
293
- async function compactChat(slot, store) {
294
- const head = store.messages.slice(0, 20);
295
- const tail = store.messages.slice(20);
296
- if (!head.length) return store;
297
- const text = head.map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? '')).join('\n');
298
- const prompt =
299
- '下面是一段聊天记录。请用两段输出:\n' +
300
- '第一段以「小结:」开头,把这段聊了什么压成不超过一百字的第三人称概述,只留事实和情绪走向。\n' +
301
- '第二段以「事实:」开头,逐行列出从对话里能确定的、关于「他」的稳定信息(名字、作息、口味、忌讳、答应过的事、反复提到的人或物),' +
302
- '每条一行、以短横线开头、不超过二十字;不确定的不要编。没有就写「事实:无」。\n\n' + text;
303
- let raw = '';
304
- try {
305
- raw = await generate(slot, prompt, 900);
306
- } catch {
307
- raw = '';
308
- }
309
- const digest = parseDigest(raw);
310
- const summary = [store.summary, digest.summary].filter(Boolean).join('\n').slice(-1200);
311
- const merged = [...(store.facts || [])];
312
- for (const f of digest.facts) {
313
- if (f === '无') continue;
314
- if (!merged.some((x) => x === f)) merged.push(f);
315
- }
316
- return { messages: tail, summary, facts: merged.slice(-60) };
317
- }
318
-
319
- /** 跟他此刻说的话最贴的几条事实 */
320
- function pickFacts(facts, text) {
321
- if (!facts || !facts.length) return [];
322
- const chars = new Set(String(text || '').replace(/[\s,。!?、,.!?]/g, ''));
323
- const scored = facts.map((f) => {
324
- let hit = 0;
325
- for (const ch of String(f)) if (chars.has(ch)) hit += 1;
326
- return { f, hit };
327
- });
328
- scored.sort((a, b) => b.hit - a.hit);
329
- const top = scored.filter((s) => s.hit > 0).slice(0, 6).map((s) => s.f);
330
- return top;
331
- }
332
-
333
- /** 去 AI 味:删套话、砍尾巴、压重复 */
334
- const AI_WORDS = [
335
- /^(好的|当然|没问题|明白了|收到)[,,。!!、\s]+/,
336
- /(希望|但愿)[^。!?]{0,12}帮(到|上)你[^。!?]*[。!!]?/g,
337
- /还有什么(需要|想要|问题|可以帮|能帮)[^。!?]*[。!?]?/g,
338
- /(总之|总的来说|综上|先说结论|首先|其次|另外|值得一提的是)[,,::]?/g,
339
- /作为(一个)?(AI|人工智能|语言模型)[^。!?]*[。!?]?/g,
340
- /(?:^|[。!?])(我理解你的感受|我很抱歉听到|这一定很不容易)[^。!?]*[。!?]?/g,
341
- ];
342
-
343
- function humanize(input) {
344
- const original = String(input || '');
345
- let t = original;
346
- for (const re of AI_WORDS) t = t.replace(re, '');
347
- t = t
348
- .replace(/[ \t]+/g, ' ')
349
- .replace(/[。!?]{2,}/g, (m) => m[0])
350
- .replace(/…{3,}/g, '……')
351
- .replace(/[,,]\s*$/g, '')
352
- .trim();
353
- const parts = t.split(/(?<=[。!?!?~])/).filter((s) => s.trim());
354
- if (parts.length > 3) t = parts.slice(0, 3).join('');
355
- if (t.length > 60) {
356
- const cut = t.match(/^[\s\S]{0,60}?[。!?!?]/);
357
- t = cut ? cut[0] : t.slice(0, 60);
358
- }
359
- return t.trim() || original.trim();
360
- }
361
-
362
- /* ── 出口队列:谁能说话,都往这儿扔 ───────────────────────── */
363
- const OUTBOX = [];
364
- const LAST_ACTIVE = { at: Date.now() };
365
- let outSeq = 0;
366
-
367
- function pushOut(msg) {
368
- outSeq += 1;
369
- OUTBOX.push({ seq: outSeq, at: Date.now(), ...msg });
370
- if (OUTBOX.length > 64) OUTBOX.splice(0, OUTBOX.length - 64);
371
- return outSeq;
372
- }
373
-
374
- function nowText() { try {
375
- return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
376
- } catch {
377
- return new Date().toISOString();
378
- }
379
- }
380
-
381
- /** 拿默认模型,缺了就返回 null */
382
- function modelSlot(ctx) {
383
- try {
384
- const sel = ctx?.get?.('agentDefaultModel')?.currentSelection?.();
385
- const llm = ctx?.get?.('llm');
386
- if (!llm || typeof llm.stream !== 'function' || !sel?.provider || !sel?.model) return null;
387
- return { llm, provider: sel.provider, model: sel.model };
388
- } catch {
389
- return null;
390
- }
391
- }
392
-
393
- /** 一次生成,只要文字 */
394
- async function generate(slot, prompt, limit = 900) {
395
- let text = '';
396
- for await (const chunk of slot.llm.stream({
397
- provider: slot.provider,
398
- model: slot.model,
399
- messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
400
- })) {
401
- if (chunk?.type === 'text-delta') text += chunk.text ?? '';
402
- else if (chunk?.type === 'finish') break;
403
- if (text.length > limit) break;
404
- }
405
- return text
406
- .replace(/\r?\n+/g, ' ')
407
- .trim()
408
- .replace(/^["“「『']+|["”」』']+$/g, '')
409
- .trim();
410
- }
411
-
412
- /* ── 主动开口:没人说话,她自己开一句 ─────────────────────── */
413
- let lastAutoAt = 0;
414
-
415
- async function maybeTalk(ctx) {
416
- const cfg = readConfig();
417
- const st = settingsOf(cfg);
418
- const mins = Number(st.proactiveInterval ?? 30);
419
- if (!(mins > 0)) return;
420
- const gap = mins * 60 * 1000;
421
- const now = Date.now();
422
- if (now - LAST_ACTIVE.at < gap) return;
423
- if (now - lastAutoAt < gap) return;
424
- const id = String(cfg.selected || '');
425
- const meta = id ? readMeta(id) : null;
426
- if (!meta) return;
427
- const slot = modelSlot(ctx);
428
- if (!slot) return;
429
- lastAutoAt = now;
430
- const hist = readChat(id)
431
- .slice(-6)
432
- .map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
433
- .join('\n');
434
- const prompt = [
435
- personaOf(meta),
436
- '现在是 ' + nowText() + '。',
437
- hist ? '你们之前说的:\n' + hist : '',
438
- '有一阵子没人说话了。你想开口,扔一句过去——提一件你正在做的事、问一句、或者吐槽一下都行。',
439
- '一到两句,四十字以内;具体,别总结,别讲道理。只输出这句话本身,不要引号,不要旁白。',
440
- '密度参考(别照抄):\n' + TALK_SAMPLES,
441
- ]
442
- .filter(Boolean)
443
- .join('\n\n');
444
- let text = '';
445
- try {
446
- text = await generate(slot, prompt, 400);
447
- } catch {
448
- return;
449
- }
450
- if (!text) return;
451
- const line = humanize(text).slice(0, 120);
452
- pushOut({ text: line, from: 'proactive' });
453
- writeChat(id, readChat(id).concat([{ role: 'assistant', text: line, at: Date.now() }]));
454
- }
455
-
456
- const MIME = {
457
- '.html': 'text/html; charset=utf-8',
458
- '.js': 'text/javascript; charset=utf-8',
459
- '.json': 'application/json; charset=utf-8',
460
- '.png': 'image/png',
461
- '.jpg': 'image/jpeg',
462
- '.jpeg': 'image/jpeg',
463
- '.webp': 'image/webp',
464
- '.gif': 'image/gif',
465
- '.wasm': 'application/wasm',
466
- '.moc3': 'application/octet-stream',
467
- };
468
-
469
- export const name = 'dsh-live2d';
470
- export const inject = ['webServer'];
471
-
472
- function ensureDirs() {
473
- mkdirSync(MODELS, { recursive: true });
474
- }
475
-
476
- function readConfig() {
477
- try {
478
- return JSON.parse(readFileSync(CONFIG, 'utf8'));
479
- } catch {
480
- return {};
481
- }
482
- }
483
-
484
- function writeConfig(cfg) {
485
- ensureDirs();
486
- writeFileSync(CONFIG, JSON.stringify(cfg, null, 2), 'utf8');
487
- }
488
-
489
- /** 目录里找指定后缀的文件(最多两层),返回相对路径 */
490
- function findFiles(dir, suffix) {
491
- const out = [];
492
- const walk = (d, depth) => {
493
- if (depth > 2) return;
494
- let entries = [];
495
- try {
496
- entries = readdirSync(d, { withFileTypes: true });
497
- } catch {
498
- return;
499
- }
500
- for (const e of entries) {
501
- const p = join(d, e.name);
502
- if (e.isDirectory()) walk(p, depth + 1);
503
- else if (e.name.toLowerCase().endsWith(suffix)) out.push(relative(dir, p));
504
- }
505
- };
506
- walk(dir, 0);
507
- return out.sort();
508
- }
509
-
510
- /** 目录里找 model3.json(最多两层) */
511
- function findModel3(dir) {
512
- const out = [];
513
- const walk = (d, depth) => {
514
- if (depth > 2) return;
515
- let entries = [];
516
- try {
517
- entries = readdirSync(d, { withFileTypes: true });
518
- } catch {
519
- return;
520
- }
521
- for (const e of entries) {
522
- const p = join(d, e.name);
523
- if (e.isDirectory()) walk(p, depth + 1);
524
- else if (e.name.toLowerCase().endsWith('.model3.json')) out.push(relative(dir, p));
525
- }
526
- };
527
- walk(dir, 0);
528
- return out[0];
529
- }
530
-
531
- function validId(id) {
532
- return /^[\w.-]+$/.test(String(id || ''));
533
- }
534
-
535
- function readMeta(id) {
536
- if (!validId(id)) return null;
537
- try {
538
- return JSON.parse(readFileSync(join(MODELS, id, 'meta.json'), 'utf8'));
539
- } catch {
540
- return null;
541
- }
542
- }
543
-
544
- function listModels() {
545
- ensureDirs();
546
- const list = [];
547
- for (const e of readdirSync(MODELS, { withFileTypes: true })) {
548
- if (!e.isDirectory()) continue;
549
- const dir = join(MODELS, e.name);
550
- const meta = readMeta(e.name);
551
- const model3 = meta?.model3 ?? findModel3(dir);
552
- if (!model3) continue;
553
- list.push({
554
- id: e.name,
555
- name: meta?.name ?? e.name,
556
- model3,
557
- expressions: meta?.expressions ?? [],
558
- voice: { ...DEFAULT_VOICE, ...(meta?.voice || {}) },
559
- });
560
- }
561
- return list;
562
- }
563
-
564
- function safeJoin(base, rel) {
565
- const clean = normalize(rel).replace(/^([/\\]|\.\.[/\\])+/, '');
566
- const full = resolve(base, clean);
567
- if (!full.startsWith(resolve(base) + sep)) return null;
568
- return full;
569
- }
570
-
571
- function send(res, status, body, type) {
572
- res.writeHead(status, { 'content-type': type || 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
573
- res.end(body);
574
- }
575
-
576
- function sendJson(res, status, obj) {
577
- send(res, status, JSON.stringify(obj), 'application/json; charset=utf-8');
578
- }
579
-
580
- function sendFile(res, file, type) {
581
- if (!existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found');
582
- res.writeHead(200, {
583
- 'content-type': type || MIME[extname(file).toLowerCase()] || 'application/octet-stream',
584
- 'cache-control': 'no-store',
585
- });
586
- createReadStream(file).pipe(res);
587
- }
588
-
589
- async function readBody(req, limit = 512 * 1024 * 1024) {
590
- const chunks = [];
591
- let size = 0;
592
- for await (const c of req) {
593
- size += c.length;
594
- if (size > limit) throw new Error('too large');
595
- chunks.push(c);
596
- }
597
- return Buffer.concat(chunks);
598
- }
599
-
600
- async function handle(req, res, ctx) {
601
- const url = new URL(req.url ?? '/', 'http://localhost');
602
- const path = url.pathname.replace(PREFIX, '') || '/';
603
- const method = (req.method ?? 'GET').toUpperCase();
604
-
605
- // 观看页
606
- if (path === '/' || path === '/viewer' || path === '/viewer.html') {
607
- return sendFile(res, join(ASSETS, 'viewer.html'), MIME['.html']);
608
- }
609
-
610
- // 内置静态资源
611
- if (path.startsWith('/assets/')) {
612
- const file = safeJoin(ASSETS, path.slice('/assets/'.length));
613
- return file ? sendFile(res, file) : send(res, 403, 'forbidden');
614
- }
615
-
616
- // 自带的 Q 版表情包
617
- if (path.startsWith('/ownmeme/')) {
618
- const file = safeJoin(OWN_MEME_DIR, decodeURIComponent(path.slice('/ownmeme/'.length)));
619
- return file ? sendFile(res, file) : send(res, 403, 'forbidden');
620
- }
621
-
622
- // 表情图库
623
- if (path.startsWith('/media/')) {
624
- const file = safeJoin(MEMES_ROOT, decodeURIComponent(path.slice('/media/'.length)));
625
- return file ? sendFile(res, file) : send(res, 403, 'forbidden');
626
- }
627
-
628
- // 模型文件
629
- if (path.startsWith('/model/')) {
630
- const rest = path.slice('/model/'.length);
631
- const slash = rest.indexOf('/');
632
- if (slash < 0) return send(res, 404, 'not found');
633
- const id = decodeURIComponent(rest.slice(0, slash));
634
- const file = safeJoin(join(MODELS, id), decodeURIComponent(rest.slice(slash + 1)));
635
- return file ? sendFile(res, file) : send(res, 403, 'forbidden');
636
- }
637
-
638
- // 跟她说话:不进聊天记录,直接问模型
639
- if (path === '/api/chat' && method === 'POST') {
640
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
641
- const text = String(body.text ?? '').trim().slice(0, 1000);
642
- if (!text) return sendJson(res, 400, { error: 'empty' });
643
- const cfg = readConfig();
644
- const id = String(body.id || cfg.selected || '');
645
- const meta = id ? readMeta(id) : null;
646
- if (!meta) return sendJson(res, 404, { error: 'model not found' });
647
-
648
- const slot = modelSlot(ctx);
649
- if (!slot) return sendJson(res, 503, { error: '模型接口不可用' });
650
-
651
- const st = settingsOf(readConfig());
652
- let store = st.memory ? readChatStore(id) : { messages: [], summary: '' };
653
- if (st.memory && store.messages.length > 30) {
654
- try {
655
- store = await compactChat(slot, store);
656
- writeChat(id, store.messages, store.summary, store.facts);
657
- } catch {}
658
- }
659
- const facts = st.memory ? pickFacts(store.facts, text) : [];
660
- const samples = pickCorpusSamples(readCorpus(id), text);
661
- const hist = store.messages;
662
- const lines = hist
663
- .slice(-8)
664
- .map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
665
- .join('\n');
666
- const prompt = [
667
- personaOf(meta),
668
- meta.isolation === false ? '' : ISOLATION,
669
- '现在是 ' + nowText() + '。',
670
- store.summary ? '之前聊过的,你还记得:\n' + store.summary : '',
671
- facts.length ? '关于他,你记得这些:\n' + facts.map((f) => '- ' + f).join('\n') : '',
672
- samples.length ? '这个人平时这么说话,只参考语气,别照抄,也别拿这些去回答:\n' + samples.join('\n') : '',
673
- lines ? '刚才的对话:\n' + lines : '',
674
- '他现在说:' + text,
675
- '先接住他这句话:同意、拒绝、反问、解释、或者提一件和他这句有关的事。别答非所问,别自顾自说别的。',
676
- '一到两句,四十字以内。不总结他,不讲道理,不说“我理解你”“希望帮到你”“还需要什么”;他没问就别建议。接不住就说短的,不要编。',
677
- '只输出这句话本身:不要引号,不要旁白,不要括号里的动作。',
678
- '说话密度参考(别照抄内容):\n' + TALK_SAMPLES,
679
- st.choices ? '需要他挑的话,最后另起一行写 ||选项一|选项二,最多三项。' : '',
680
- st.memesMode === 'own' || st.memesMode === 'both'
681
- ? '想附一张贴图,就在最后另起一行写 ||图:名字。能挑的:' +
682
- readOwnMemes()
683
- .map((it) => it.label)
684
- .filter(Boolean)
685
- .join('、') +
686
- '。不想配就别写。'
687
- : '',
688
- ]
689
- .filter(Boolean)
690
- .join('\n\n');
691
-
692
- let reply = '';
693
- try {
694
- reply = await generate(slot, prompt);
695
- } catch (e) {
696
- return sendJson(res, 502, { error: '生成失败:' + (e instanceof Error ? e.message : String(e)) });
697
- }
698
- LAST_ACTIVE.at = Date.now();
699
- reply = humanize(reply);
700
- const parsed = splitMeta(reply || '');
701
- let line = parsed.text;
702
- let choices = st.choices ? parsed.choices : [];
703
- if (line.length > 240) line = line.slice(0, 240);
704
- if (!line && !choices.length) return sendJson(res, 200, { ok: true, reply: '', id });
705
- const moodNow = guessMood(line);
706
- const image =
707
- st.memesMode === 'off'
708
- ? null
709
- : parsed.imageLabel
710
- ? pickImage(st.memesMode, moodNow, parsed.imageLabel)
711
- : Math.random() < 0.35
712
- ? pickImage(st.memesMode, moodNow)
713
- : null;
714
-
715
- if (st.memory) {
716
- writeChat(
717
- id,
718
- hist.concat([
719
- { role: 'user', text, at: Date.now() },
720
- { role: 'assistant', text: line, at: Date.now() },
721
- ]),
722
- store.summary,
723
- store.facts,
724
- );
725
- }
726
- return sendJson(res, 200, { ok: true, reply: line, choices, image: image || undefined, id });
727
- }
728
-
729
- // 人设卡
730
- if (path === '/api/persona' && method === 'GET') {
731
- const id = String(url.searchParams.get('id') ?? '');
732
- const meta = id ? readMeta(id) : null;
733
- return sendJson(res, 200, {
734
- id,
735
- persona: personaOf(meta),
736
- custom: typeof meta?.persona === 'string' && !!meta.persona.trim(),
737
- isolation: meta?.isolation !== false,
738
- });
739
- }
740
-
741
- if (path === '/api/persona' && method === 'POST') {
742
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
743
- const id = String(body.id ?? '');
744
- const meta = id ? readMeta(id) : null;
745
- if (!meta) return sendJson(res, 404, { error: 'model not found' });
746
- const persona = typeof body.persona === 'string' ? body.persona : meta.persona;
747
- const isolation = typeof body.isolation === 'boolean' ? body.isolation : meta.isolation !== false;
748
- writeFileSync(join(MODELS, id, 'meta.json'), JSON.stringify({ ...meta, persona, isolation }, null, 2), 'utf8');
749
- return sendJson(res, 200, { ok: true, persona: personaOf({ persona }), isolation });
750
- }
751
-
752
- // 开关:哪些功能开着
753
- if (path === '/api/settings' && method === 'GET') {
754
- return sendJson(res, 200, { settings: settingsOf(readConfig()) });
755
- }
756
-
757
- if (path === '/api/settings' && method === 'POST') {
758
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
759
- const cfg = readConfig();
760
- const cur = settingsOf(cfg);
761
- const next = { ...cur };
762
- const patch = body && typeof body.settings === 'object' ? body.settings : body;
763
- for (const k of Object.keys(DEFAULT_SETTINGS)) {
764
- if (typeof patch?.[k] === 'boolean') next[k] = patch[k];
765
- }
766
- if (typeof patch?.proactiveInterval === 'number' && Number.isFinite(patch.proactiveInterval)) {
767
- next.proactiveInterval = Math.max(0, Math.min(720, Math.round(patch.proactiveInterval)));
768
- }
769
- if (typeof patch?.memesMode === 'string' && MEMES_MODES.includes(patch.memesMode)) {
770
- next.memesMode = patch.memesMode;
771
- }
772
- writeConfig({ ...cfg, settings: next });
773
- return sendJson(res, 200, { ok: true, settings: next });
774
- }
775
-
776
- // 出口:别的插件、im 机器人、脚本都能把她要说的话推进来
777
- if (path === '/api/push' && method === 'POST') {
778
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
779
- const text = String(body.text ?? '').trim().slice(0, 2000);
780
- const image = String(body.image ?? '').trim().slice(0, 2000);
781
- if (!text && !image) return sendJson(res, 400, { error: 'empty' });
782
- const seq = pushOut({
783
- text,
784
- image: image || undefined,
785
- mood: body.mood ? String(body.mood) : undefined,
786
- from: body.from ? String(body.from).slice(0, 40) : 'external',
787
- });
788
- LAST_ACTIVE.at = Date.now();
789
- return sendJson(res, 200, { ok: true, seq });
790
- }
791
-
792
- // 客户端轮询:拿到她要说的话
793
- if (path === '/api/poll' && method === 'GET') {
794
- const since = Number(url.searchParams.get('since') || 0);
795
- return sendJson(res, 200, { seq: outSeq, items: OUTBOX.filter((m) => m.seq > since) });
796
- }
797
-
798
- // 手机上一页够用:她的脸 + 一个输入框
799
- if (path === '/chat' || path === '/mobile') {
800
- return sendFile(res, join(ASSETS, 'mobile.html'), MIME['.html']);
801
- }
802
-
803
- // 语料页
804
- if (path === '/corpus' || path === '/corpus.html') {
805
- return sendFile(res, join(ASSETS, 'corpus.html'), MIME['.html']);
806
- }
807
-
808
- // 人设卡页
809
- if (path === '/persona' || path === '/persona.html') {
810
- return sendFile(res, join(ASSETS, 'persona.html'), MIME['.html']);
811
- }
812
-
813
- // 音色调音页
814
- if (path === '/tone' || path === '/tone.html') {
815
- return sendFile(res, join(ASSETS, 'tone.html'), MIME['.html']);
816
- }
817
-
818
- // API
819
-
820
- // 语料
821
- if (path === '/api/corpus' && method === 'GET') {
822
- const id = String(url.searchParams.get('id') ?? '');
823
- if (id && !/^[\w.-]+$/.test(id)) return sendJson(res, 400, { error: 'bad id' });
824
- const c = id ? readCorpus(id) : { lines: [], pairs: [] };
825
- return sendJson(res, 200, { id, lines: c.lines, pairs: c.pairs, total: c.lines.length + c.pairs.length });
826
- }
827
-
828
- if (path === '/api/corpus' && method === 'POST') {
829
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
830
- const id = String(body.id ?? '');
831
- if (!/^[\w.-]+$/.test(id)) return sendJson(res, 400, { error: 'bad id' });
832
- const meta = readMeta(id);
833
- if (!meta) return sendJson(res, 404, { error: 'model not found' });
834
- const corpus = {
835
- lines: Array.isArray(body.lines) ? body.lines.filter((x) => typeof x === 'string' && x.trim()) : [],
836
- pairs: Array.isArray(body.pairs)
837
- ? body.pairs.filter((p) => p && typeof p.assistant === 'string' && p.assistant.trim())
838
- : [],
839
- };
840
- writeCorpus(id, corpus);
841
- return sendJson(res, 200, { ok: true, total: corpus.lines.length + corpus.pairs.length });
842
- }
843
- if (path === '/api/voice' && method === 'GET') {
844
- const id = String(url.searchParams.get('id') ?? '');
845
- const meta = id ? readMeta(id) : null;
846
- return sendJson(res, 200, { voice: { ...DEFAULT_VOICE, ...(meta?.voice || {}) }, id });
847
- }
848
-
849
- if (path === '/api/voice' && method === 'POST') {
850
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
851
- const id = String(body.id ?? '');
852
- const meta = id ? readMeta(id) : null;
853
- if (!meta) return sendJson(res, 404, { error: 'model not found' });
854
- const v = body.voice && typeof body.voice === 'object' ? body.voice : {};
855
- const voice = {
856
- preset: String(v.preset || 'soft'),
857
- type: ['triangle', 'square', 'sawtooth', 'sine'].includes(v.type) ? v.type : 'triangle',
858
- base: Math.max(80, Math.min(1200, Number(v.base) || DEFAULT_VOICE.base)),
859
- jit: Math.max(0, Math.min(50, Number(v.jit) || 0)),
860
- dur: Math.max(10, Math.min(200, Number(v.dur) || DEFAULT_VOICE.dur)),
861
- vol: Math.max(0, Math.min(100, Number(v.vol) || 0)),
862
- spd: Math.max(20, Math.min(200, Number(v.spd) || DEFAULT_VOICE.spd)),
863
- };
864
- writeFileSync(join(MODELS, id, 'meta.json'), JSON.stringify({ ...meta, voice }, null, 2), 'utf8');
865
- return sendJson(res, 200, { ok: true, voice });
866
- }
867
-
868
- if (path === '/api/models' && method === 'GET') {
869
- const cfg = readConfig();
870
- return sendJson(res, 200, { models: listModels(), selected: cfg.selected ?? null });
871
- }
872
-
873
- if (path === '/api/select' && method === 'POST') {
874
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
875
- writeConfig({ ...readConfig(), selected: body.id ?? null });
876
- return sendJson(res, 200, { ok: true });
877
- }
878
-
879
- if (path === '/api/import' && method === 'POST') {
880
- const id = String(url.searchParams.get('model') ?? '').replace(/[^\w.-]/g, '_');
881
- const rel = url.searchParams.get('path') ?? '';
882
- if (!id || !rel) return sendJson(res, 400, { error: 'model/path required' });
883
- const dest = safeJoin(join(MODELS, id), rel);
884
- if (!dest) return sendJson(res, 403, { error: 'bad path' });
885
- mkdirSync(resolve(dest, '..'), { recursive: true });
886
- writeFileSync(dest, await readBody(req));
887
- return sendJson(res, 200, { ok: true });
888
- }
889
-
890
- if (path === '/api/commit' && method === 'POST') {
891
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
892
- const id = String(body.id ?? '');
893
- const dir = join(MODELS, id);
894
- const model3 = findModel3(dir);
895
- if (!model3) return sendJson(res, 400, { error: '没有找到 .model3.json' });
896
- const meta = { name: body.name || id, model3, expressions: findFiles(dir, '.exp3.json') };
897
- writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8');
898
- writeConfig({ ...readConfig(), selected: id });
899
- return sendJson(res, 200, { ok: true, id, model3 });
900
- }
901
-
902
- if (path === '/api/delete' && method === 'POST') {
903
- const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
904
- const id = String(body.id ?? '');
905
- const dir = safeJoin(MODELS, id);
906
- if (dir && existsSync(dir)) {
907
- const { rmSync } = await import('node:fs');
908
- rmSync(dir, { recursive: true, force: true });
909
- const cfg = readConfig();
910
- if (cfg.selected === id) writeConfig({ ...cfg, selected: listModels()[0]?.id ?? null });
911
- }
912
- return sendJson(res, 200, { ok: true });
913
- }
914
-
915
- return send(res, 404, 'not found');
916
- }
917
-
918
- export function apply(ctx) {
919
- ensureDirs();
920
- ctx.effect(
921
- () =>
922
- ctx.webServer.register({
923
- kind: 'prefix',
924
- path: PREFIX,
925
- handler: async (req, res) => {
926
- try {
927
- await handle(req, res, ctx);
928
- } catch (e) {
929
- if (!res.headersSent) sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
930
- else res.end();
931
- }
932
- },
933
- }),
934
- 'dsh-live2d: /dsh-live2d 路由',
935
- );
936
- ctx.effect(() => {
937
- const timer = setInterval(() => {
938
- maybeTalk(ctx).catch(() => {});
939
- }, 60 * 1000);
940
- timer.unref?.();
941
- return () => clearInterval(timer);
942
- }, 'dsh-live2d: 主动开口');
943
- }
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, createReadStream } from 'node:fs';
2
+ import { join, resolve, normalize, relative, extname, sep } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const HERE = fileURLToPath(new URL('.', import.meta.url));
7
+ const ASSETS = resolve(HERE, '..', 'assets');
8
+ const ROOT = join(homedir(), '.dsh', 'dsh-live2d');
9
+ const MODELS = join(ROOT, 'models');
10
+ const CONFIG = join(ROOT, 'config.json');
11
+ const PREFIX = '/dsh-live2d';
12
+ const MEMES_ROOT = join(homedir(), '.dsh', 'profiles', 'desktop', 'node_modules', 'dsh-meme', 'memes');
13
+
14
+ /** 情绪 → 图库分类 */
15
+ const MEME_TAGS = {
16
+ 开心: ['happy', 'like'],
17
+ 生气: ['angry', 'baka'],
18
+ 委屈: ['sad', 'fool'],
19
+ 无语: ['sigh', 'cpu', 'color'],
20
+ 害羞: ['shy', 'see'],
21
+ 困: ['sleep'],
22
+ 惊讶: ['surprised'],
23
+ 平静: ['daily', 'morning', 'meow'],
24
+ };
25
+
26
+ const DEFAULT_SETTINGS = {
27
+ /** 多久没人说话才自己开口,单位分钟;0 = 关闭 */
28
+ proactiveInterval: 30,
29
+ /** off | own | both | external —— 测试功能,默认关 */
30
+ memesMode: 'off',
31
+ choices: true,
32
+ memory: true,
33
+ follow: true,
34
+ gaze: true,
35
+ poke: true,
36
+ motion: true,
37
+ sound: true,
38
+ };
39
+
40
+ const MEMES_MODES = ['off', 'own', 'both', 'external'];
41
+ const OWN_MEME_DIR = join(ASSETS, 'memes');
42
+
43
+ function readOwnMemes() {
44
+ try {
45
+ const m = JSON.parse(readFileSync(join(OWN_MEME_DIR, 'manifest.json'), 'utf8'));
46
+ return Array.isArray(m.items) ? m.items : [];
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ /** 从自带那套里按情绪挑一张,返回文件名 */
53
+ function pickOwnMeme(mood) {
54
+ const items = readOwnMemes();
55
+ if (!items.length) return null;
56
+ const hit = items.filter((it) => Array.isArray(it.moods) && it.moods.includes(mood));
57
+ const pool = hit.length ? hit : items;
58
+ const it = pool[Math.floor(Math.random() * pool.length)];
59
+ return it && it.file ? it.file : null;
60
+ }
61
+
62
+ /** 按模式挑一张配图,返回可直接用的 URL */
63
+ function pickImage(mode, mood, label) {
64
+ const m = MEMES_MODES.includes(mode) ? mode : 'off';
65
+ if (m === 'off') return null;
66
+ const pool = [];
67
+ if (m === 'own' || m === 'both') {
68
+ const f = (label && pickOwnMemeByLabel(label)) || pickOwnMeme(mood);
69
+ if (f) pool.push('/dsh-live2d/ownmeme/' + encodeURIComponent(f));
70
+ }
71
+ if (m === 'external' || m === 'both') {
72
+ const f = pickMeme(mood);
73
+ if (f) pool.push('/dsh-live2d/media/' + f.split('/').map(encodeURIComponent).join('/'));
74
+ }
75
+ if (!pool.length) return null;
76
+ return pool[Math.floor(Math.random() * pool.length)];
77
+ }
78
+
79
+ function settingsOf(cfg) {
80
+ return { ...DEFAULT_SETTINGS, ...(cfg?.settings || {}) };
81
+ }
82
+
83
+ /** 从表情图库按情绪挑一张,返回相对路径 */
84
+ function pickMeme(mood) {
85
+ try {
86
+ const tags = MEME_TAGS[mood] || MEME_TAGS['平静'];
87
+ const galleries = readdirSync(MEMES_ROOT, { withFileTypes: true }).filter((e) => e.isDirectory());
88
+ const pool = [];
89
+ for (const g of galleries) {
90
+ for (const tag of tags) {
91
+ const dir = join(MEMES_ROOT, g.name, 'memes', tag);
92
+ let files = [];
93
+ try {
94
+ files = readdirSync(dir);
95
+ } catch {
96
+ continue;
97
+ }
98
+ for (const f of files) {
99
+ if (/\.(png|jpe?g|webp|gif)$/i.test(f)) pool.push(g.name + '/memes/' + tag + '/' + f);
100
+ }
101
+ }
102
+ }
103
+ if (!pool.length) return null;
104
+ return pool[Math.floor(Math.random() * pool.length)];
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /** 从回复里摘出「||选项1|选项2」这种尾巴 */
111
+ function splitChoices(raw) {
112
+ const m = /\|\|\s*([^|]+(?:\|[^|]+)*)\s*$/.exec(raw);
113
+ if (!m) return { text: raw.trim(), choices: [] };
114
+ const choices = m[1]
115
+ .split('|')
116
+ .map((s) => s.trim())
117
+ .filter(Boolean)
118
+ .slice(0, 4);
119
+ return { text: raw.slice(0, m.index).trim(), choices };
120
+ }
121
+
122
+ /** 先摘「||图:名字」,再摘「||选项1|选项2」 */
123
+ function splitMeta(raw) {
124
+ let text = String(raw || '');
125
+ let imageLabel = '';
126
+ const imgMatch = /\|\|\s*图\s*[::]\s*([^\n|]+)\s*$/m.exec(text);
127
+ if (imgMatch) {
128
+ imageLabel = imgMatch[1].trim().replace(/^["“「『']+|["”」』']+$/g, '');
129
+ text = (text.slice(0, imgMatch.index) + text.slice(imgMatch.index + imgMatch[0].length)).trim();
130
+ }
131
+ const parsed = splitChoices(text);
132
+ return { text: parsed.text, choices: parsed.choices, imageLabel };
133
+ }
134
+
135
+ /** 按名字取自带那张 */
136
+ function pickOwnMemeByLabel(label) {
137
+ const items = readOwnMemes();
138
+ if (!items.length || !label) return null;
139
+ const key = String(label).trim();
140
+ const hit =
141
+ items.find((it) => it.label === key) ||
142
+ items.find((it) => String(it.file).replace(/\.\w+$/, '') === key) ||
143
+ items.find((it) => key && String(it.label).includes(key));
144
+ return hit ? hit.file : null;
145
+ }
146
+ function guessMood(text) {
147
+ const s = String(text || '');
148
+ if (/(生气|烦|讨厌|别闹|去你的|哼)/.test(s)) return '生气';
149
+ if (/(委屈|难过|想哭|难受|算了|叹气)/.test(s)) return '委屈';
150
+ if (/(困|睡了|熬夜|打哈欠|几点了)/.test(s)) return '困';
151
+ if (/(害羞|别看我|脸热|唔)/.test(s)) return '害羞';
152
+ if (/(无语|随便|唉|懒得)/.test(s)) return '无语';
153
+ if (/(开心|高兴|笑|谢谢|喜欢|好呀|正合适)/.test(s)) return '开心';
154
+ if (/(惊讶|真的|不会吧|吓)/.test(s)) return '惊讶';
155
+ return '平静';
156
+ }
157
+
158
+ /** 默认音色:一个字一声短音 */
159
+ const DEFAULT_VOICE = { preset: 'soft', type: 'triangle', base: 400, jit: 10, dur: 60, vol: 32, spd: 62 };
160
+
161
+ /** 没写人设卡时用的通用版,不绑定具体角色 */
162
+ const DEFAULT_PERSONA = [
163
+ '你叫{name}。',
164
+ '说话短,一句常常不到十五个字;不用感叹号,不写书面语,不解释心情。',
165
+ '情绪落在具体的东西上——桌上有什么、手上在做什么、外面天色怎样。',
166
+ '不总结,不升华,不写括号里的动作。',
167
+ ].join('\n');
168
+
169
+ /** 参考密度:只给节奏和信息量,不是让模型照抄内容 */
170
+ const TALK_SAMPLES = [
171
+ '他:今天吃啥 —— 你:锅里还有,热的。',
172
+ '他:累死了 —— 你:先坐下。水在桌上。',
173
+ '他:你在干嘛 —— 你:擦桌子。手是湿的。',
174
+ '他:我想你了 —— 你:嗯。我也是。',
175
+ '他:明天要早起 —— 你:那我今晚早点关灯。',
176
+ ].join('\n');
177
+
178
+ /** 世界隔离:不说破自己在哪儿 */
179
+ const ISOLATION = [
180
+ '【世界隔离】你不知道什么是程序、界面、插件、模型、参数。',
181
+ '有人问起,你就当他就站在你身边。不要提“系统”“设置”“网页”“token”这些词。',
182
+ '你不知道他那边什么样,得他讲给你听。',
183
+ ].join('\n');
184
+
185
+ /** 语料:lines = 她的台词,pairs = 一问一答 */
186
+ function readCorpus(id) {
187
+ try {
188
+ const d = JSON.parse(readFileSync(join(MODELS, id, 'corpus.json'), 'utf8'));
189
+ return {
190
+ lines: Array.isArray(d.lines) ? d.lines.filter((x) => typeof x === 'string' && x.trim()).slice(0, 500) : [],
191
+ pairs: Array.isArray(d.pairs)
192
+ ? d.pairs
193
+ .filter((p) => p && typeof p.assistant === 'string' && p.assistant.trim())
194
+ .slice(0, 500)
195
+ .map((p) => ({ user: String(p.user || '').slice(0, 200), assistant: String(p.assistant).slice(0, 400) }))
196
+ : [],
197
+ };
198
+ } catch {
199
+ return { lines: [], pairs: [] };
200
+ }
201
+ }
202
+
203
+ function writeCorpus(id, corpus) {
204
+ try {
205
+ writeFileSync(
206
+ join(MODELS, id, 'corpus.json'),
207
+ JSON.stringify({ lines: corpus.lines.slice(0, 500), pairs: corpus.pairs.slice(0, 500) }, null, 2),
208
+ 'utf8',
209
+ );
210
+ } catch {}
211
+ }
212
+
213
+ /** 从语料里挑几条最贴当前对话的,拼成样例 */
214
+ function pickCorpusSamples(corpus, text, toneN = 2) {
215
+ const out = [];
216
+ const query = String(text || '').trim();
217
+ if (query) {
218
+ const chars = new Set(query);
219
+ const scored = (corpus.pairs || [])
220
+ .map((p) => {
221
+ let hit = 0;
222
+ for (const ch of String(p.user || '')) if (chars.has(ch)) hit += 1;
223
+ return { p, hit };
224
+ })
225
+ .sort((a, b) => b.hit - a.hit);
226
+ for (const s of scored.slice(0, 2)) {
227
+ if (s.p.user) out.push('他:' + s.p.user + ' —— 你:' + s.p.assistant);
228
+ else out.push('你:' + s.p.assistant);
229
+ }
230
+ }
231
+ const pool = (corpus.lines || []).slice();
232
+ for (let i = 0; i < toneN && pool.length; i += 1) {
233
+ const k = Math.floor(Math.random() * pool.length);
234
+ out.push('你:' + pool.splice(k, 1)[0]);
235
+ }
236
+ return out;
237
+ }
238
+
239
+ function personaOf(meta) {
240
+ const p = typeof meta?.persona === 'string' ? meta.persona.trim() : '';
241
+ if (p) return p;
242
+ const nm = String(meta?.name || '她').trim() || '她';
243
+ return DEFAULT_PERSONA.replace('{name}', nm);
244
+ }
245
+
246
+ function readChatStore(id) {
247
+ try {
248
+ const d = JSON.parse(readFileSync(join(MODELS, id, 'chat.json'), 'utf8'));
249
+ return {
250
+ messages: Array.isArray(d.messages) ? d.messages : [],
251
+ summary: typeof d.summary === 'string' ? d.summary : '',
252
+ facts: Array.isArray(d.facts) ? d.facts : [],
253
+ };
254
+ } catch {
255
+ return { messages: [], summary: '', facts: [] };
256
+ }
257
+ }
258
+
259
+ function readChat(id) {
260
+ return readChatStore(id).messages;
261
+ }
262
+
263
+ function writeChat(id, messages, summary, facts) {
264
+ try {
265
+ const prev = readChatStore(id);
266
+ const data = {
267
+ messages: messages.slice(-40),
268
+ summary: summary === undefined ? prev.summary : summary,
269
+ facts: (facts === undefined ? prev.facts : facts).slice(-60),
270
+ };
271
+ writeFileSync(join(MODELS, id, 'chat.json'), JSON.stringify(data, null, 2), 'utf8');
272
+ } catch {}
273
+ }
274
+
275
+ /** 把压缩出来的两段拆开:小结 + 关于他的事实 */
276
+ function parseDigest(raw) {
277
+ let text = String(raw || '').trim();
278
+ let summary = text;
279
+ const facts = [];
280
+ const at = text.search(/事实\s*[::]/);
281
+ if (at >= 0) {
282
+ summary = text.slice(0, at).replace(/^小结\s*[::]?\s*/, '').trim();
283
+ const body = text.slice(at).replace(/^事实\s*[::]\s*/, '');
284
+ for (const line of body.split(/\n+/)) {
285
+ const item = line.replace(/^[-*·\s]+/, '').trim();
286
+ if (item.length >= 2 && item.length <= 60) facts.push(item);
287
+ }
288
+ }
289
+ summary = summary.replace(/^小结\s*[::]?\s*/, '').trim();
290
+ return { summary, facts: facts.slice(0, 12) };
291
+ }
292
+
293
+ /** 攒够了就把前二十句压成小结 + 事实,人物记得住更早的事 */
294
+ async function compactChat(slot, store) {
295
+ const head = store.messages.slice(0, 20);
296
+ const tail = store.messages.slice(20);
297
+ if (!head.length) return store;
298
+ const text = head.map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? '')).join('\n');
299
+ const prompt =
300
+ '下面是一段聊天记录。请用两段输出:\n' +
301
+ '第一段以「小结:」开头,把这段聊了什么压成不超过一百字的第三人称概述,只留事实和情绪走向。\n' +
302
+ '第二段以「事实:」开头,逐行列出从对话里能确定的、关于「他」的稳定信息(名字、作息、口味、忌讳、答应过的事、反复提到的人或物),' +
303
+ '每条一行、以短横线开头、不超过二十字;不确定的不要编。没有就写「事实:无」。\n\n' + text;
304
+ let raw = '';
305
+ try {
306
+ raw = await generate(slot, prompt, 900);
307
+ } catch {
308
+ raw = '';
309
+ }
310
+ const digest = parseDigest(raw);
311
+ const summary = [store.summary, digest.summary].filter(Boolean).join('\n').slice(-1200);
312
+ const merged = [...(store.facts || [])];
313
+ for (const f of digest.facts) {
314
+ if (f === '无') continue;
315
+ if (!merged.some((x) => x === f)) merged.push(f);
316
+ }
317
+ return { messages: tail, summary, facts: merged.slice(-60) };
318
+ }
319
+
320
+ /** 跟他此刻说的话最贴的几条事实 */
321
+ function pickFacts(facts, text) {
322
+ if (!facts || !facts.length) return [];
323
+ const chars = new Set(String(text || '').replace(/[\s,。!?、,.!?]/g, ''));
324
+ const scored = facts.map((f) => {
325
+ let hit = 0;
326
+ for (const ch of String(f)) if (chars.has(ch)) hit += 1;
327
+ return { f, hit };
328
+ });
329
+ scored.sort((a, b) => b.hit - a.hit);
330
+ const top = scored.filter((s) => s.hit > 0).slice(0, 6).map((s) => s.f);
331
+ return top;
332
+ }
333
+
334
+ /** 去 AI 味:删套话、砍尾巴、压重复 */
335
+ const AI_WORDS = [
336
+ /^(好的|当然|没问题|明白了|收到)[,,。!!、\s]+/,
337
+ /(希望|但愿)[^。!?]{0,12}帮(到|上)你[^。!?]*[。!!]?/g,
338
+ /还有什么(需要|想要|问题|可以帮|能帮)[^。!?]*[。!?]?/g,
339
+ /(总之|总的来说|综上|先说结论|首先|其次|另外|值得一提的是)[,,::]?/g,
340
+ /作为(一个)?(AI|人工智能|语言模型)[^。!?]*[。!?]?/g,
341
+ /(?:^|[。!?])(我理解你的感受|我很抱歉听到|这一定很不容易)[^。!?]*[。!?]?/g,
342
+ ];
343
+
344
+ function humanize(input) {
345
+ const original = String(input || '');
346
+ let t = original;
347
+ for (const re of AI_WORDS) t = t.replace(re, '');
348
+ t = t
349
+ .replace(/[ \t]+/g, ' ')
350
+ .replace(/[。!?]{2,}/g, (m) => m[0])
351
+ .replace(/…{3,}/g, '……')
352
+ .replace(/[,,]\s*$/g, '')
353
+ .trim();
354
+ const parts = t.split(/(?<=[。!?!?~])/).filter((s) => s.trim());
355
+ if (parts.length > 3) t = parts.slice(0, 3).join('');
356
+ if (t.length > 60) {
357
+ const cut = t.match(/^[\s\S]{0,60}?[。!?!?]/);
358
+ t = cut ? cut[0] : t.slice(0, 60);
359
+ }
360
+ return t.trim() || original.trim();
361
+ }
362
+
363
+ /* ── 出口队列:谁能说话,都往这儿扔 ───────────────────────── */
364
+ const OUTBOX = [];
365
+ const BOOT_AT = Date.now();
366
+ /** 最近一次真的有来话的时间;重启不清零,靠 chat.json 兜底 */
367
+ const LAST_ACTIVE = { at: 0 };
368
+ let outSeq = 0;
369
+
370
+ function pushOut(msg) {
371
+ outSeq += 1;
372
+ OUTBOX.push({ seq: outSeq, at: Date.now(), ...msg });
373
+ if (OUTBOX.length > 64) OUTBOX.splice(0, OUTBOX.length - 64);
374
+ return outSeq;
375
+ }
376
+
377
+ function nowText() {
378
+ try {
379
+ return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
380
+ } catch {
381
+ return new Date().toISOString();
382
+ }
383
+ }
384
+
385
+ /* 主动开口的计时存在盘上,重启不清零 */
386
+ const AUTO_STATE = join(ROOT, 'auto.json');
387
+
388
+ function readAuto() {
389
+ try {
390
+ const d = JSON.parse(readFileSync(AUTO_STATE, 'utf8'));
391
+ return { autoAt: Number(d.autoAt) || 0, need: Number(d.need) || 0, mins: Number(d.mins) || 0 };
392
+ } catch {
393
+ return { autoAt: 0, need: 0, mins: 0 };
394
+ }
395
+ }
396
+
397
+ function writeAuto(next) {
398
+ ensureDirs();
399
+ try {
400
+ writeFileSync(AUTO_STATE, JSON.stringify(next, null, 2), 'utf8');
401
+ } catch {}
402
+ }
403
+
404
+ function hourNow() {
405
+ try {
406
+ const s = new Intl.DateTimeFormat('zh-CN', {
407
+ timeZone: 'Asia/Shanghai',
408
+ hour: '2-digit',
409
+ hour12: false,
410
+ }).format(new Date());
411
+ return Number(String(s).replace(/\D/g, ''));
412
+ } catch {
413
+ return new Date().getHours();
414
+ }
415
+ }
416
+
417
+ /** 两句说的是不是同一件事:按字对重合算 */
418
+ function similar(a, b) {
419
+ const norm = (s) => String(s || '').replace(/[\s,。!?、,.!?~…—:「」“”'"]/g, '');
420
+ const A = norm(a);
421
+ const B = norm(b);
422
+ if (A.length < 2 || B.length < 2) return 0;
423
+ const gram = (s) => {
424
+ const out = new Set();
425
+ for (let i = 0; i < s.length - 1; i += 1) out.add(s.slice(i, i + 2));
426
+ return out;
427
+ };
428
+ const ga = gram(A);
429
+ const gb = gram(B);
430
+ let hit = 0;
431
+ for (const g of ga) if (gb.has(g)) hit += 1;
432
+ return hit / Math.max(1, Math.min(ga.size, gb.size));
433
+ }
434
+
435
+ /** 拿默认模型,缺了就返回 null */
436
+ function modelSlot(ctx) {
437
+ try {
438
+ const sel = ctx?.get?.('agentDefaultModel')?.currentSelection?.();
439
+ const llm = ctx?.get?.('llm');
440
+ if (!llm || typeof llm.stream !== 'function' || !sel?.provider || !sel?.model) return null;
441
+ return { llm, provider: sel.provider, model: sel.model };
442
+ } catch {
443
+ return null;
444
+ }
445
+ }
446
+
447
+ /** 一次生成,只要文字 */
448
+ async function generate(slot, prompt, limit = 900) {
449
+ let text = '';
450
+ for await (const chunk of slot.llm.stream({
451
+ provider: slot.provider,
452
+ model: slot.model,
453
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
454
+ })) {
455
+ if (chunk?.type === 'text-delta') text += chunk.text ?? '';
456
+ else if (chunk?.type === 'finish') break;
457
+ if (text.length > limit) break;
458
+ }
459
+ return text
460
+ .replace(/\r?\n+/g, ' ')
461
+ .trim()
462
+ .replace(/^["“「『']+|["”」』']+$/g, '')
463
+ .trim();
464
+ }
465
+
466
+ /* ── 主动开口:没人说话,她自己开一句 ─────────────────────── */
467
+
468
+ /** 开口的几种样子,每轮抽一种,免得每次都问「吃了吗」 */
469
+ const OPEN_SHAPES = [
470
+ '说你手上正在做的事,带一个具体的东西',
471
+ '想起他之前说过的一句,接着那句讲',
472
+ '抱怨一句:天、水、猫、手上的活儿',
473
+ '说两三个字,或者一个词',
474
+ '说一件刚发现的变化:东西的位置、味道、光',
475
+ '提一件你打算做的事,说完就完,别问他',
476
+ ];
477
+
478
+ /** 开口的节奏样例:短句开头,不是答题 */
479
+ const OPEN_SAMPLES = [
480
+ '水开了。',
481
+ '窗台那盆歪了,扶不正。',
482
+ '坐了一会儿,腿麻。',
483
+ '衣服晾到一半,下雨。',
484
+ '刚尝了一口,太咸。',
485
+ ].join('\n');
486
+
487
+ /** 这一轮要等多久:按设置上下浮动,别准点报到 */
488
+ function nextNeed(mins, auto) {
489
+ if (auto.need > 0 && auto.mins === mins) return auto.need;
490
+ const need = Math.round(mins * 60000 * (0.8 + Math.random() * 0.6));
491
+ writeAuto({ ...auto, need, mins });
492
+ return need;
493
+ }
494
+
495
+ async function maybeTalk(ctx, opts = {}) {
496
+ const force = opts.force === true;
497
+ const cfg = readConfig();
498
+ const st = settingsOf(cfg);
499
+ const mins = Number(st.proactiveInterval ?? 30);
500
+ if (!force && !(mins > 0)) return null;
501
+ if (!force && hourNow() < 7) return null;
502
+ const id = String(cfg.selected || '');
503
+ const meta = id ? readMeta(id) : null;
504
+ if (!meta) return null;
505
+ const slot = modelSlot(ctx);
506
+ if (!slot) return null;
507
+
508
+ const auto = readAuto();
509
+ const msgs = readChat(id);
510
+ const histLast = msgs.length ? Number(msgs[msgs.length - 1]?.at) || 0 : 0;
511
+ const anchor = Math.max(LAST_ACTIVE.at, histLast, auto.autoAt) || BOOT_AT;
512
+ const now = Date.now();
513
+ if (!force) {
514
+ const need = nextNeed(mins, auto);
515
+ if (now - anchor < need) return null;
516
+ }
517
+
518
+ const own = msgs
519
+ .filter((m) => m.role === 'assistant')
520
+ .slice(-6)
521
+ .map((m) => String(m.text || '').trim())
522
+ .filter(Boolean);
523
+ const hist = msgs
524
+ .slice(-4)
525
+ .map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
526
+ .join('\n');
527
+ const shape = OPEN_SHAPES[Math.floor(Math.random() * OPEN_SHAPES.length)];
528
+ const base = [
529
+ personaOf(meta),
530
+ meta.isolation === false ? '' : ISOLATION,
531
+ '现在是 ' + nowText() + '。',
532
+ hist ? '你们刚才说的:\n' + hist : '',
533
+ '有一阵子没人说话了。你手边还有事,顺口丢一句过去。',
534
+ '这轮' + shape + '。',
535
+ '一句为主,最多两句,三十个字以内。不是每句都要问他,问句三次里出一次就够。别用「吃了吗」「在干嘛」这种招呼。',
536
+ own.length ? '你最近说过的,别再重复,也别换汤不换药:\n' + own.map((o) => '· ' + o).join('\n') : '',
537
+ '开口的样子是这样,只学节奏,别照抄:\n' + OPEN_SAMPLES,
538
+ '只输出这句话本身:不要引号,不要旁白,不要「||」这类标记。',
539
+ ]
540
+ .filter(Boolean)
541
+ .join('\n\n');
542
+
543
+ let line = '';
544
+ for (let round = 0; round < 2; round += 1) {
545
+ let raw = '';
546
+ try {
547
+ raw = await generate(
548
+ slot,
549
+ round === 0 ? base : base + '\n\n上一句跟你以前说过的撞了,换一件事,换个说法。',
550
+ 300,
551
+ );
552
+ } catch {
553
+ return null;
554
+ }
555
+ const parsed = splitMeta(humanize(raw));
556
+ line = String(parsed.text || '')
557
+ .replace(/^["“「『']+|["”」』']+$/g, '')
558
+ .trim();
559
+ if (!line) return null;
560
+ if (!own.some((o) => similar(o, line) >= 0.5)) break;
561
+ if (round === 1) return null;
562
+ }
563
+ if (!line) return null;
564
+ if (line.length > 60) line = line.slice(0, 58) + '。';
565
+
566
+ const mood = guessMood(line);
567
+ const image =
568
+ st.memesMode === 'off' ? null : Math.random() < 0.45 ? pickImage(st.memesMode, mood) : null;
569
+ pushOut({ text: line, mood, image: image || undefined, choices: [], from: 'proactive' });
570
+ if (st.memory) writeChat(id, msgs.concat([{ role: 'assistant', text: line, at: now, auto: true }]));
571
+ LAST_ACTIVE.at = now;
572
+ writeAuto({ autoAt: now, need: 0, mins });
573
+ return line;
574
+ }
575
+
576
+ const MIME = {
577
+ '.html': 'text/html; charset=utf-8',
578
+ '.js': 'text/javascript; charset=utf-8',
579
+ '.json': 'application/json; charset=utf-8',
580
+ '.png': 'image/png',
581
+ '.jpg': 'image/jpeg',
582
+ '.jpeg': 'image/jpeg',
583
+ '.webp': 'image/webp',
584
+ '.gif': 'image/gif',
585
+ '.wasm': 'application/wasm',
586
+ '.moc3': 'application/octet-stream',
587
+ };
588
+
589
+ export const name = 'dsh-live2d';
590
+ export const inject = ['webServer'];
591
+
592
+ function ensureDirs() {
593
+ mkdirSync(MODELS, { recursive: true });
594
+ }
595
+
596
+ function readConfig() {
597
+ try {
598
+ return JSON.parse(readFileSync(CONFIG, 'utf8'));
599
+ } catch {
600
+ return {};
601
+ }
602
+ }
603
+
604
+ function writeConfig(cfg) {
605
+ ensureDirs();
606
+ writeFileSync(CONFIG, JSON.stringify(cfg, null, 2), 'utf8');
607
+ }
608
+
609
+ /** 目录里找指定后缀的文件(最多两层),返回相对路径 */
610
+ function findFiles(dir, suffix) {
611
+ const out = [];
612
+ const walk = (d, depth) => {
613
+ if (depth > 2) return;
614
+ let entries = [];
615
+ try {
616
+ entries = readdirSync(d, { withFileTypes: true });
617
+ } catch {
618
+ return;
619
+ }
620
+ for (const e of entries) {
621
+ const p = join(d, e.name);
622
+ if (e.isDirectory()) walk(p, depth + 1);
623
+ else if (e.name.toLowerCase().endsWith(suffix)) out.push(relative(dir, p));
624
+ }
625
+ };
626
+ walk(dir, 0);
627
+ return out.sort();
628
+ }
629
+
630
+ /** 目录里找 model3.json(最多两层) */
631
+ function findModel3(dir) {
632
+ const out = [];
633
+ const walk = (d, depth) => {
634
+ if (depth > 2) return;
635
+ let entries = [];
636
+ try {
637
+ entries = readdirSync(d, { withFileTypes: true });
638
+ } catch {
639
+ return;
640
+ }
641
+ for (const e of entries) {
642
+ const p = join(d, e.name);
643
+ if (e.isDirectory()) walk(p, depth + 1);
644
+ else if (e.name.toLowerCase().endsWith('.model3.json')) out.push(relative(dir, p));
645
+ }
646
+ };
647
+ walk(dir, 0);
648
+ return out[0];
649
+ }
650
+
651
+ function validId(id) {
652
+ return /^[\w.-]+$/.test(String(id || ''));
653
+ }
654
+
655
+ function readMeta(id) {
656
+ if (!validId(id)) return null;
657
+ try {
658
+ return JSON.parse(readFileSync(join(MODELS, id, 'meta.json'), 'utf8'));
659
+ } catch {
660
+ return null;
661
+ }
662
+ }
663
+
664
+ function listModels() {
665
+ ensureDirs();
666
+ const list = [];
667
+ for (const e of readdirSync(MODELS, { withFileTypes: true })) {
668
+ if (!e.isDirectory()) continue;
669
+ const dir = join(MODELS, e.name);
670
+ const meta = readMeta(e.name);
671
+ const model3 = meta?.model3 ?? findModel3(dir);
672
+ if (!model3) continue;
673
+ list.push({
674
+ id: e.name,
675
+ name: meta?.name ?? e.name,
676
+ model3,
677
+ expressions: meta?.expressions ?? [],
678
+ voice: { ...DEFAULT_VOICE, ...(meta?.voice || {}) },
679
+ });
680
+ }
681
+ return list;
682
+ }
683
+
684
+ function safeJoin(base, rel) {
685
+ const clean = normalize(rel).replace(/^([/\\]|\.\.[/\\])+/, '');
686
+ const full = resolve(base, clean);
687
+ if (!full.startsWith(resolve(base) + sep)) return null;
688
+ return full;
689
+ }
690
+
691
+ function send(res, status, body, type) {
692
+ res.writeHead(status, { 'content-type': type || 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
693
+ res.end(body);
694
+ }
695
+
696
+ function sendJson(res, status, obj) {
697
+ send(res, status, JSON.stringify(obj), 'application/json; charset=utf-8');
698
+ }
699
+
700
+ function sendFile(res, file, type) {
701
+ if (!existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found');
702
+ res.writeHead(200, {
703
+ 'content-type': type || MIME[extname(file).toLowerCase()] || 'application/octet-stream',
704
+ 'cache-control': 'no-store',
705
+ });
706
+ createReadStream(file).pipe(res);
707
+ }
708
+
709
+ async function readBody(req, limit = 512 * 1024 * 1024) {
710
+ const chunks = [];
711
+ let size = 0;
712
+ for await (const c of req) {
713
+ size += c.length;
714
+ if (size > limit) throw new Error('too large');
715
+ chunks.push(c);
716
+ }
717
+ return Buffer.concat(chunks);
718
+ }
719
+
720
+ async function handle(req, res, ctx) {
721
+ const url = new URL(req.url ?? '/', 'http://localhost');
722
+ const path = url.pathname.replace(PREFIX, '') || '/';
723
+ const method = (req.method ?? 'GET').toUpperCase();
724
+
725
+ // 观看页
726
+ if (path === '/' || path === '/viewer' || path === '/viewer.html') {
727
+ return sendFile(res, join(ASSETS, 'viewer.html'), MIME['.html']);
728
+ }
729
+
730
+ // 内置静态资源
731
+ if (path.startsWith('/assets/')) {
732
+ const file = safeJoin(ASSETS, path.slice('/assets/'.length));
733
+ return file ? sendFile(res, file) : send(res, 403, 'forbidden');
734
+ }
735
+
736
+ // 自带的 Q 版表情包
737
+ if (path.startsWith('/ownmeme/')) {
738
+ const file = safeJoin(OWN_MEME_DIR, decodeURIComponent(path.slice('/ownmeme/'.length)));
739
+ return file ? sendFile(res, file) : send(res, 403, 'forbidden');
740
+ }
741
+
742
+ // 表情图库
743
+ if (path.startsWith('/media/')) {
744
+ const file = safeJoin(MEMES_ROOT, decodeURIComponent(path.slice('/media/'.length)));
745
+ return file ? sendFile(res, file) : send(res, 403, 'forbidden');
746
+ }
747
+
748
+ // 模型文件
749
+ if (path.startsWith('/model/')) {
750
+ const rest = path.slice('/model/'.length);
751
+ const slash = rest.indexOf('/');
752
+ if (slash < 0) return send(res, 404, 'not found');
753
+ const id = decodeURIComponent(rest.slice(0, slash));
754
+ const file = safeJoin(join(MODELS, id), decodeURIComponent(rest.slice(slash + 1)));
755
+ return file ? sendFile(res, file) : send(res, 403, 'forbidden');
756
+ }
757
+
758
+ // 跟她说话:不进聊天记录,直接问模型
759
+ if (path === '/api/chat' && method === 'POST') {
760
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
761
+ const text = String(body.text ?? '').trim().slice(0, 1000);
762
+ if (!text) return sendJson(res, 400, { error: 'empty' });
763
+ const cfg = readConfig();
764
+ const id = String(body.id || cfg.selected || '');
765
+ const meta = id ? readMeta(id) : null;
766
+ if (!meta) return sendJson(res, 404, { error: 'model not found' });
767
+
768
+ const slot = modelSlot(ctx);
769
+ if (!slot) return sendJson(res, 503, { error: '模型接口不可用' });
770
+
771
+ const st = settingsOf(readConfig());
772
+ let store = st.memory ? readChatStore(id) : { messages: [], summary: '' };
773
+ if (st.memory && store.messages.length > 30) {
774
+ try {
775
+ store = await compactChat(slot, store);
776
+ writeChat(id, store.messages, store.summary, store.facts);
777
+ } catch {}
778
+ }
779
+ const facts = st.memory ? pickFacts(store.facts, text) : [];
780
+ const samples = pickCorpusSamples(readCorpus(id), text);
781
+ const hist = store.messages;
782
+ const lines = hist
783
+ .slice(-8)
784
+ .map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
785
+ .join('\n');
786
+ const prompt = [
787
+ personaOf(meta),
788
+ meta.isolation === false ? '' : ISOLATION,
789
+ '现在是 ' + nowText() + '。',
790
+ store.summary ? '之前聊过的,你还记得:\n' + store.summary : '',
791
+ facts.length ? '关于他,你记得这些:\n' + facts.map((f) => '- ' + f).join('\n') : '',
792
+ samples.length ? '这个人平时这么说话,只参考语气,别照抄,也别拿这些去回答:\n' + samples.join('\n') : '',
793
+ lines ? '刚才的对话:\n' + lines : '',
794
+ '他现在说:' + text,
795
+ '先接住他这句话:同意、拒绝、反问、解释、或者提一件和他这句有关的事。别答非所问,别自顾自说别的。',
796
+ '一到两句,四十字以内。不总结他,不讲道理,不说“我理解你”“希望帮到你”“还需要什么”;他没问就别建议。接不住就说短的,不要编。',
797
+ '只输出这句话本身:不要引号,不要旁白,不要括号里的动作。',
798
+ '说话密度参考(别照抄内容):\n' + TALK_SAMPLES,
799
+ st.choices ? '需要他挑的话,最后另起一行写 ||选项一|选项二,最多三项。' : '',
800
+ st.memesMode === 'own' || st.memesMode === 'both'
801
+ ? '想附一张贴图,就在最后另起一行写 ||图:名字。能挑的:' +
802
+ readOwnMemes()
803
+ .map((it) => it.label)
804
+ .filter(Boolean)
805
+ .join('、') +
806
+ '。不想配就别写。'
807
+ : '',
808
+ ]
809
+ .filter(Boolean)
810
+ .join('\n\n');
811
+
812
+ let reply = '';
813
+ try {
814
+ reply = await generate(slot, prompt);
815
+ } catch (e) {
816
+ return sendJson(res, 502, { error: '生成失败:' + (e instanceof Error ? e.message : String(e)) });
817
+ }
818
+ LAST_ACTIVE.at = Date.now();
819
+ reply = humanize(reply);
820
+ const parsed = splitMeta(reply || '');
821
+ let line = parsed.text;
822
+ let choices = st.choices ? parsed.choices : [];
823
+ if (line.length > 240) line = line.slice(0, 240);
824
+ if (!line && !choices.length) return sendJson(res, 200, { ok: true, reply: '', id });
825
+ const moodNow = guessMood(line);
826
+ const image =
827
+ st.memesMode === 'off'
828
+ ? null
829
+ : parsed.imageLabel
830
+ ? pickImage(st.memesMode, moodNow, parsed.imageLabel)
831
+ : Math.random() < 0.35
832
+ ? pickImage(st.memesMode, moodNow)
833
+ : null;
834
+
835
+ if (st.memory) {
836
+ writeChat(
837
+ id,
838
+ hist.concat([
839
+ { role: 'user', text, at: Date.now() },
840
+ { role: 'assistant', text: line, at: Date.now() },
841
+ ]),
842
+ store.summary,
843
+ store.facts,
844
+ );
845
+ }
846
+ return sendJson(res, 200, { ok: true, reply: line, choices, image: image || undefined, id });
847
+ }
848
+
849
+ // 人设卡
850
+ if (path === '/api/persona' && method === 'GET') {
851
+ const id = String(url.searchParams.get('id') ?? '');
852
+ const meta = id ? readMeta(id) : null;
853
+ return sendJson(res, 200, {
854
+ id,
855
+ persona: personaOf(meta),
856
+ custom: typeof meta?.persona === 'string' && !!meta.persona.trim(),
857
+ isolation: meta?.isolation !== false,
858
+ });
859
+ }
860
+
861
+ if (path === '/api/persona' && method === 'POST') {
862
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
863
+ const id = String(body.id ?? '');
864
+ const meta = id ? readMeta(id) : null;
865
+ if (!meta) return sendJson(res, 404, { error: 'model not found' });
866
+ const persona = typeof body.persona === 'string' ? body.persona : meta.persona;
867
+ const isolation = typeof body.isolation === 'boolean' ? body.isolation : meta.isolation !== false;
868
+ writeFileSync(join(MODELS, id, 'meta.json'), JSON.stringify({ ...meta, persona, isolation }, null, 2), 'utf8');
869
+ return sendJson(res, 200, { ok: true, persona: personaOf({ persona }), isolation });
870
+ }
871
+
872
+ // 开关:哪些功能开着
873
+ if (path === '/api/settings' && method === 'GET') {
874
+ return sendJson(res, 200, { settings: settingsOf(readConfig()) });
875
+ }
876
+
877
+ if (path === '/api/settings' && method === 'POST') {
878
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
879
+ const cfg = readConfig();
880
+ const cur = settingsOf(cfg);
881
+ const next = { ...cur };
882
+ const patch = body && typeof body.settings === 'object' ? body.settings : body;
883
+ for (const k of Object.keys(DEFAULT_SETTINGS)) {
884
+ if (typeof patch?.[k] === 'boolean') next[k] = patch[k];
885
+ }
886
+ if (typeof patch?.proactiveInterval === 'number' && Number.isFinite(patch.proactiveInterval)) {
887
+ next.proactiveInterval = Math.max(0, Math.min(720, Math.round(patch.proactiveInterval)));
888
+ }
889
+ if (typeof patch?.memesMode === 'string' && MEMES_MODES.includes(patch.memesMode)) {
890
+ next.memesMode = patch.memesMode;
891
+ }
892
+ writeConfig({ ...cfg, settings: next });
893
+ return sendJson(res, 200, { ok: true, settings: next });
894
+ }
895
+
896
+ // 出口:别的插件、im 机器人、脚本都能把她要说的话推进来
897
+ if (path === '/api/push' && method === 'POST') {
898
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
899
+ const text = String(body.text ?? '').trim().slice(0, 2000);
900
+ const image = String(body.image ?? '').trim().slice(0, 2000);
901
+ if (!text && !image) return sendJson(res, 400, { error: 'empty' });
902
+ const seq = pushOut({
903
+ text,
904
+ image: image || undefined,
905
+ mood: body.mood ? String(body.mood) : undefined,
906
+ from: body.from ? String(body.from).slice(0, 40) : 'external',
907
+ });
908
+ LAST_ACTIVE.at = Date.now();
909
+ return sendJson(res, 200, { ok: true, seq });
910
+ }
911
+
912
+ // 让她现在说一句:不看间隔,方便看效果
913
+ if (path === '/api/talk' && method === 'POST') {
914
+ const line = await maybeTalk(ctx, { force: true });
915
+ return sendJson(res, 200, { ok: !!line, line: line || '' });
916
+ }
917
+
918
+ // 客户端轮询:拿到她要说的话
919
+ if (path === '/api/poll' && method === 'GET') {
920
+ const since = Number(url.searchParams.get('since') || 0);
921
+ return sendJson(res, 200, { seq: outSeq, items: OUTBOX.filter((m) => m.seq > since) });
922
+ }
923
+
924
+ // 手机上一页够用:她的脸 + 一个输入框
925
+ if (path === '/chat' || path === '/mobile') {
926
+ return sendFile(res, join(ASSETS, 'mobile.html'), MIME['.html']);
927
+ }
928
+
929
+ // 语料页
930
+ if (path === '/corpus' || path === '/corpus.html') {
931
+ return sendFile(res, join(ASSETS, 'corpus.html'), MIME['.html']);
932
+ }
933
+
934
+ // 人设卡页
935
+ if (path === '/persona' || path === '/persona.html') {
936
+ return sendFile(res, join(ASSETS, 'persona.html'), MIME['.html']);
937
+ }
938
+
939
+ // 音色调音页
940
+ if (path === '/tone' || path === '/tone.html') {
941
+ return sendFile(res, join(ASSETS, 'tone.html'), MIME['.html']);
942
+ }
943
+
944
+ // API
945
+
946
+ // 语料
947
+ if (path === '/api/corpus' && method === 'GET') {
948
+ const id = String(url.searchParams.get('id') ?? '');
949
+ if (id && !/^[\w.-]+$/.test(id)) return sendJson(res, 400, { error: 'bad id' });
950
+ const c = id ? readCorpus(id) : { lines: [], pairs: [] };
951
+ return sendJson(res, 200, { id, lines: c.lines, pairs: c.pairs, total: c.lines.length + c.pairs.length });
952
+ }
953
+
954
+ if (path === '/api/corpus' && method === 'POST') {
955
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
956
+ const id = String(body.id ?? '');
957
+ if (!/^[\w.-]+$/.test(id)) return sendJson(res, 400, { error: 'bad id' });
958
+ const meta = readMeta(id);
959
+ if (!meta) return sendJson(res, 404, { error: 'model not found' });
960
+ const corpus = {
961
+ lines: Array.isArray(body.lines) ? body.lines.filter((x) => typeof x === 'string' && x.trim()) : [],
962
+ pairs: Array.isArray(body.pairs)
963
+ ? body.pairs.filter((p) => p && typeof p.assistant === 'string' && p.assistant.trim())
964
+ : [],
965
+ };
966
+ writeCorpus(id, corpus);
967
+ return sendJson(res, 200, { ok: true, total: corpus.lines.length + corpus.pairs.length });
968
+ }
969
+ if (path === '/api/voice' && method === 'GET') {
970
+ const id = String(url.searchParams.get('id') ?? '');
971
+ const meta = id ? readMeta(id) : null;
972
+ return sendJson(res, 200, { voice: { ...DEFAULT_VOICE, ...(meta?.voice || {}) }, id });
973
+ }
974
+
975
+ if (path === '/api/voice' && method === 'POST') {
976
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
977
+ const id = String(body.id ?? '');
978
+ const meta = id ? readMeta(id) : null;
979
+ if (!meta) return sendJson(res, 404, { error: 'model not found' });
980
+ const v = body.voice && typeof body.voice === 'object' ? body.voice : {};
981
+ const voice = {
982
+ preset: String(v.preset || 'soft'),
983
+ type: ['triangle', 'square', 'sawtooth', 'sine'].includes(v.type) ? v.type : 'triangle',
984
+ base: Math.max(80, Math.min(1200, Number(v.base) || DEFAULT_VOICE.base)),
985
+ jit: Math.max(0, Math.min(50, Number(v.jit) || 0)),
986
+ dur: Math.max(10, Math.min(200, Number(v.dur) || DEFAULT_VOICE.dur)),
987
+ vol: Math.max(0, Math.min(100, Number(v.vol) || 0)),
988
+ spd: Math.max(20, Math.min(200, Number(v.spd) || DEFAULT_VOICE.spd)),
989
+ };
990
+ writeFileSync(join(MODELS, id, 'meta.json'), JSON.stringify({ ...meta, voice }, null, 2), 'utf8');
991
+ return sendJson(res, 200, { ok: true, voice });
992
+ }
993
+
994
+ if (path === '/api/models' && method === 'GET') {
995
+ const cfg = readConfig();
996
+ return sendJson(res, 200, { models: listModels(), selected: cfg.selected ?? null });
997
+ }
998
+
999
+ if (path === '/api/select' && method === 'POST') {
1000
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
1001
+ writeConfig({ ...readConfig(), selected: body.id ?? null });
1002
+ return sendJson(res, 200, { ok: true });
1003
+ }
1004
+
1005
+ if (path === '/api/import' && method === 'POST') {
1006
+ const id = String(url.searchParams.get('model') ?? '').replace(/[^\w.-]/g, '_');
1007
+ const rel = url.searchParams.get('path') ?? '';
1008
+ if (!id || !rel) return sendJson(res, 400, { error: 'model/path required' });
1009
+ const dest = safeJoin(join(MODELS, id), rel);
1010
+ if (!dest) return sendJson(res, 403, { error: 'bad path' });
1011
+ mkdirSync(resolve(dest, '..'), { recursive: true });
1012
+ writeFileSync(dest, await readBody(req));
1013
+ return sendJson(res, 200, { ok: true });
1014
+ }
1015
+
1016
+ if (path === '/api/commit' && method === 'POST') {
1017
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
1018
+ const id = String(body.id ?? '');
1019
+ const dir = join(MODELS, id);
1020
+ const model3 = findModel3(dir);
1021
+ if (!model3) return sendJson(res, 400, { error: '没有找到 .model3.json' });
1022
+ const meta = { name: body.name || id, model3, expressions: findFiles(dir, '.exp3.json') };
1023
+ writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8');
1024
+ writeConfig({ ...readConfig(), selected: id });
1025
+ return sendJson(res, 200, { ok: true, id, model3 });
1026
+ }
1027
+
1028
+ if (path === '/api/delete' && method === 'POST') {
1029
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
1030
+ const id = String(body.id ?? '');
1031
+ const dir = safeJoin(MODELS, id);
1032
+ if (dir && existsSync(dir)) {
1033
+ const { rmSync } = await import('node:fs');
1034
+ rmSync(dir, { recursive: true, force: true });
1035
+ const cfg = readConfig();
1036
+ if (cfg.selected === id) writeConfig({ ...cfg, selected: listModels()[0]?.id ?? null });
1037
+ }
1038
+ return sendJson(res, 200, { ok: true });
1039
+ }
1040
+
1041
+ return send(res, 404, 'not found');
1042
+ }
1043
+
1044
+ export function apply(ctx) {
1045
+ ensureDirs();
1046
+ ctx.effect(
1047
+ () =>
1048
+ ctx.webServer.register({
1049
+ kind: 'prefix',
1050
+ path: PREFIX,
1051
+ handler: async (req, res) => {
1052
+ try {
1053
+ await handle(req, res, ctx);
1054
+ } catch (e) {
1055
+ if (!res.headersSent) sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) });
1056
+ else res.end();
1057
+ }
1058
+ },
1059
+ }),
1060
+ 'dsh-live2d: /dsh-live2d 路由',
1061
+ );
1062
+ ctx.effect(() => {
1063
+ const timer = setInterval(() => {
1064
+ maybeTalk(ctx).catch(() => {});
1065
+ }, 60 * 1000);
1066
+ timer.unref?.();
1067
+ return () => clearInterval(timer);
1068
+ }, 'dsh-live2d: 主动开口');
1069
+ }