dsh-live2dwaifu 0.3.0 → 0.4.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/README.md +17 -0
- package/lib/client.js +34 -0
- package/lib/index.js +125 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,6 +27,23 @@
|
|
|
27
27
|
|
|
28
28
|
**也能直接跟她说**:卡片底下有个小框,打一句回车,她回你。这条线**不进你的聊天记录**——后台直接问模型,往来只存在 `models/<id>/chat.json` 里。她说的话按人设卡现场写,不是复读;人设卡就是 `models/<id>/meta.json` 里的 `persona` 字段,没写就用默认那份(若妍)。
|
|
29
29
|
|
|
30
|
+
**她会自己开口**:十几分钟没人理她,她就找个话头说一句。嫌吵就把 `~/.dsh/dsh-live2d/config.json` 里的 `proactive` 设成 `false`。
|
|
31
|
+
|
|
32
|
+
**给别人留的口子**:im 机器人、别的插件、随手写的脚本,都能让她说话。
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# 她说一句(image 可省,传了就在气泡里贴出来)
|
|
36
|
+
curl -X POST http://127.0.0.1:<端口>/dsh-live2d/api/push \
|
|
37
|
+
-H 'content-type: application/json' \
|
|
38
|
+
-d '{"text":"锅里有汤。","mood":"平静","from":"im"}'
|
|
39
|
+
|
|
40
|
+
# 她说一句并要回话
|
|
41
|
+
curl -X POST http://127.0.0.1:<端口>/dsh-live2d/api/chat \
|
|
42
|
+
-H 'content-type: application/json' -d '{"text":"今天累不累"}'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
卡片每 2.5 秒拉一次 `/dsh-live2d/api/poll`,所以外面推进来的话,几秒内就演出来。
|
|
46
|
+
|
|
30
47
|
## 三十秒上手
|
|
31
48
|
|
|
32
49
|
```bash
|
package/lib/client.js
CHANGED
|
@@ -80,6 +80,40 @@
|
|
|
80
80
|
react.useEffect(() => {
|
|
81
81
|
frameWin = (ref.current && ref.current.contentWindow) || null;
|
|
82
82
|
}, [nonce, open]);
|
|
83
|
+
react.useEffect(() => {
|
|
84
|
+
let seq = null;
|
|
85
|
+
let stop = false;
|
|
86
|
+
const tick = () => {
|
|
87
|
+
if (stop) return;
|
|
88
|
+
fetch(BASE + '/api/poll?since=' + (seq === null ? 0 : seq))
|
|
89
|
+
.then((r) => r.json())
|
|
90
|
+
.then((d) => {
|
|
91
|
+
if (stop) return;
|
|
92
|
+
if (seq === null) {
|
|
93
|
+
seq = Number(d.seq) || 0;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
seq = Number(d.seq) || seq;
|
|
97
|
+
const items = d.items || [];
|
|
98
|
+
for (let i = 0; i < items.length; i++) {
|
|
99
|
+
if (!frameWin) break;
|
|
100
|
+
try {
|
|
101
|
+
frameWin.postMessage(
|
|
102
|
+
{ type: 'dsh-live2d/say', text: items[i].text || '', mood: items[i].mood, image: items[i].image },
|
|
103
|
+
'*',
|
|
104
|
+
);
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
.catch(() => {});
|
|
109
|
+
};
|
|
110
|
+
tick();
|
|
111
|
+
const id = setInterval(tick, 2500);
|
|
112
|
+
return () => {
|
|
113
|
+
stop = true;
|
|
114
|
+
clearInterval(id);
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
83
117
|
react.useEffect(() => {
|
|
84
118
|
if (ref.current && ref.current.contentWindow) {
|
|
85
119
|
ref.current.contentWindow.postMessage({ type: 'dsh-live2d/bg', bg }, '*');
|
package/lib/index.js
CHANGED
|
@@ -40,6 +40,97 @@ function writeChat(id, messages) {
|
|
|
40
40
|
} catch {}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/* ── 出口队列:谁能说话,都往这儿扔 ───────────────────────── */
|
|
44
|
+
const OUTBOX = [];
|
|
45
|
+
const LAST_ACTIVE = { at: Date.now() };
|
|
46
|
+
let outSeq = 0;
|
|
47
|
+
|
|
48
|
+
function pushOut(msg) {
|
|
49
|
+
outSeq += 1;
|
|
50
|
+
OUTBOX.push({ seq: outSeq, at: Date.now(), ...msg });
|
|
51
|
+
if (OUTBOX.length > 64) OUTBOX.splice(0, OUTBOX.length - 64);
|
|
52
|
+
return outSeq;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nowText() { try {
|
|
56
|
+
return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
|
57
|
+
} catch {
|
|
58
|
+
return new Date().toISOString();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 拿默认模型,缺了就返回 null */
|
|
63
|
+
function modelSlot(ctx) {
|
|
64
|
+
try {
|
|
65
|
+
const sel = ctx?.get?.('agentDefaultModel')?.currentSelection?.();
|
|
66
|
+
const llm = ctx?.get?.('llm');
|
|
67
|
+
if (!llm || typeof llm.stream !== 'function' || !sel?.provider || !sel?.model) return null;
|
|
68
|
+
return { llm, provider: sel.provider, model: sel.model };
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 一次生成,只要文字 */
|
|
75
|
+
async function generate(slot, prompt, limit = 900) {
|
|
76
|
+
let text = '';
|
|
77
|
+
for await (const chunk of slot.llm.stream({
|
|
78
|
+
provider: slot.provider,
|
|
79
|
+
model: slot.model,
|
|
80
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
81
|
+
})) {
|
|
82
|
+
if (chunk?.type === 'text-delta') text += chunk.text ?? '';
|
|
83
|
+
else if (chunk?.type === 'finish') break;
|
|
84
|
+
if (text.length > limit) break;
|
|
85
|
+
}
|
|
86
|
+
return text
|
|
87
|
+
.replace(/\r?\n+/g, ' ')
|
|
88
|
+
.trim()
|
|
89
|
+
.replace(/^["“「『']+|["”」』']+$/g, '')
|
|
90
|
+
.trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ── 主动开口:没人说话,她自己开一句 ─────────────────────── */
|
|
94
|
+
const IDLE_MS = 12 * 60 * 1000;
|
|
95
|
+
let lastAutoAt = 0;
|
|
96
|
+
|
|
97
|
+
async function maybeTalk(ctx) {
|
|
98
|
+
const cfg = readConfig();
|
|
99
|
+
if (cfg.proactive === false) return;
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
if (now - LAST_ACTIVE.at < IDLE_MS) return;
|
|
102
|
+
if (now - lastAutoAt < IDLE_MS) return;
|
|
103
|
+
const id = String(cfg.selected || '');
|
|
104
|
+
const meta = id ? readMeta(id) : null;
|
|
105
|
+
if (!meta) return;
|
|
106
|
+
const slot = modelSlot(ctx);
|
|
107
|
+
if (!slot) return;
|
|
108
|
+
lastAutoAt = now;
|
|
109
|
+
const hist = readChat(id)
|
|
110
|
+
.slice(-6)
|
|
111
|
+
.map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
|
|
112
|
+
.join('\n');
|
|
113
|
+
const prompt = [
|
|
114
|
+
personaOf(meta),
|
|
115
|
+
'现在是 ' + nowText() + '。',
|
|
116
|
+
hist ? '你们之前说的:\n' + hist : '',
|
|
117
|
+
'有一阵子没人说话了。你想开口,说一句给他。',
|
|
118
|
+
'只输出这一句本身:一到两句,短,口语。不要解释,不要引号,不要旁白,不要括号里的动作。',
|
|
119
|
+
]
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.join('\n\n');
|
|
122
|
+
let text = '';
|
|
123
|
+
try {
|
|
124
|
+
text = await generate(slot, prompt, 400);
|
|
125
|
+
} catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!text) return;
|
|
129
|
+
const line = text.length > 120 ? text.slice(0, 120) : text;
|
|
130
|
+
pushOut({ text: line, from: 'proactive' });
|
|
131
|
+
writeChat(id, readChat(id).concat([{ role: 'assistant', text: line, at: Date.now() }]));
|
|
132
|
+
}
|
|
133
|
+
|
|
43
134
|
const MIME = {
|
|
44
135
|
'.html': 'text/html; charset=utf-8',
|
|
45
136
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -214,18 +305,8 @@ async function handle(req, res, ctx) {
|
|
|
214
305
|
const meta = id ? readMeta(id) : null;
|
|
215
306
|
if (!meta) return sendJson(res, 404, { error: 'model not found' });
|
|
216
307
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
try {
|
|
220
|
-
sel = ctx?.get?.('agentDefaultModel')?.currentSelection?.();
|
|
221
|
-
llm = ctx?.get?.('llm');
|
|
222
|
-
} catch {
|
|
223
|
-
sel = undefined;
|
|
224
|
-
llm = undefined;
|
|
225
|
-
}
|
|
226
|
-
if (!llm || typeof llm.stream !== 'function' || !sel?.provider || !sel?.model) {
|
|
227
|
-
return sendJson(res, 503, { error: '模型接口不可用' });
|
|
228
|
-
}
|
|
308
|
+
const slot = modelSlot(ctx);
|
|
309
|
+
if (!slot) return sendJson(res, 503, { error: '模型接口不可用' });
|
|
229
310
|
|
|
230
311
|
const hist = readChat(id);
|
|
231
312
|
const lines = hist
|
|
@@ -234,6 +315,7 @@ async function handle(req, res, ctx) {
|
|
|
234
315
|
.join('\n');
|
|
235
316
|
const prompt = [
|
|
236
317
|
personaOf(meta),
|
|
318
|
+
'现在是 ' + nowText() + '。',
|
|
237
319
|
lines ? '你们之前说的:\n' + lines : '',
|
|
238
320
|
'他说:' + text,
|
|
239
321
|
'回他一句。只输出这句话本身:一到三句,短,口语,像发消息。不要解释,不要加引号,不要旁白,不要括号里的动作。',
|
|
@@ -243,24 +325,11 @@ async function handle(req, res, ctx) {
|
|
|
243
325
|
|
|
244
326
|
let reply = '';
|
|
245
327
|
try {
|
|
246
|
-
|
|
247
|
-
provider: sel.provider,
|
|
248
|
-
model: sel.model,
|
|
249
|
-
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
250
|
-
})) {
|
|
251
|
-
if (chunk?.type === 'text-delta') reply += chunk.text ?? '';
|
|
252
|
-
else if (chunk?.type === 'finish') break;
|
|
253
|
-
if (reply.length > 900) break;
|
|
254
|
-
}
|
|
328
|
+
reply = await generate(slot, prompt);
|
|
255
329
|
} catch (e) {
|
|
256
330
|
return sendJson(res, 502, { error: '生成失败:' + (e instanceof Error ? e.message : String(e)) });
|
|
257
331
|
}
|
|
258
|
-
|
|
259
|
-
reply = reply
|
|
260
|
-
.replace(/\r?\n+/g, ' ')
|
|
261
|
-
.trim()
|
|
262
|
-
.replace(/^["“「『']+|["”」』']+$/g, '')
|
|
263
|
-
.trim();
|
|
332
|
+
LAST_ACTIVE.at = Date.now();
|
|
264
333
|
if (!reply) return sendJson(res, 200, { ok: true, reply: '', id });
|
|
265
334
|
if (reply.length > 240) reply = reply.slice(0, 240);
|
|
266
335
|
|
|
@@ -288,6 +357,28 @@ async function handle(req, res, ctx) {
|
|
|
288
357
|
return sendJson(res, 200, { ok: true, persona: personaOf({ persona }) });
|
|
289
358
|
}
|
|
290
359
|
|
|
360
|
+
// 出口:别的插件、im 机器人、脚本都能把她要说的话推进来
|
|
361
|
+
if (path === '/api/push' && method === 'POST') {
|
|
362
|
+
const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
|
|
363
|
+
const text = String(body.text ?? '').trim().slice(0, 2000);
|
|
364
|
+
const image = String(body.image ?? '').trim().slice(0, 2000);
|
|
365
|
+
if (!text && !image) return sendJson(res, 400, { error: 'empty' });
|
|
366
|
+
const seq = pushOut({
|
|
367
|
+
text,
|
|
368
|
+
image: image || undefined,
|
|
369
|
+
mood: body.mood ? String(body.mood) : undefined,
|
|
370
|
+
from: body.from ? String(body.from).slice(0, 40) : 'external',
|
|
371
|
+
});
|
|
372
|
+
LAST_ACTIVE.at = Date.now();
|
|
373
|
+
return sendJson(res, 200, { ok: true, seq });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 客户端轮询:拿到她要说的话
|
|
377
|
+
if (path === '/api/poll' && method === 'GET') {
|
|
378
|
+
const since = Number(url.searchParams.get('since') || 0);
|
|
379
|
+
return sendJson(res, 200, { seq: outSeq, items: OUTBOX.filter((m) => m.seq > since) });
|
|
380
|
+
}
|
|
381
|
+
|
|
291
382
|
// 音色调音页
|
|
292
383
|
if (path === '/tone' || path === '/tone.html') {
|
|
293
384
|
return sendFile(res, join(ASSETS, 'tone.html'), MIME['.html']);
|
|
@@ -387,4 +478,11 @@ export function apply(ctx) {
|
|
|
387
478
|
}),
|
|
388
479
|
'dsh-live2d: /dsh-live2d 路由',
|
|
389
480
|
);
|
|
481
|
+
ctx.effect(() => {
|
|
482
|
+
const timer = setInterval(() => {
|
|
483
|
+
maybeTalk(ctx).catch(() => {});
|
|
484
|
+
}, 60 * 1000);
|
|
485
|
+
timer.unref?.();
|
|
486
|
+
return () => clearInterval(timer);
|
|
487
|
+
}, 'dsh-live2d: 主动开口');
|
|
390
488
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-live2dwaifu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、视线跟着鼠标、点她有反应、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹;支持自定义 .exp3.json 表情和 .motion3.json 动作,卡片能拖能缩,背景可换。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|