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 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
- let sel;
218
- let llm;
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
- for await (const chunk of llm.stream({
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.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",