dsh-live2dwaifu 0.3.0 → 0.5.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
@@ -23,10 +23,33 @@
23
23
 
24
24
  **会出声**:一个字一声很短的电音,标点停一拍,句尾沉一下——不是朗读,是“哔哔”那种。标题栏的 **♪** 打开调音页,放个真模型在旁边,边调边听;每只模型各存一份音色,换人换声。音量拉到 0 就安静,只剩气泡和口型。
25
25
 
26
+ **她也会甩图**:图库直接吃 `dsh-meme` 那套,说话时按情绪挑一张,贴在气泡里。
27
+
28
+ **她还会问你要选项**:话说到了需要你挑的地方,气泡下面会冒出两三个按钮,点一个就等于你回了她。
29
+
30
+ **开关都在 ⚙ 里**:抽屉底下分两栏——「这台机器上」管眼睛跟随、点她、自己播动作、出声、跟着聊天念;「她那边」管没人理就自己开口、甩表情包、给选项、记性。前几项存本机,后几项存 `~/.dsh/dsh-live2d/config.json`。
31
+
26
32
  **跟着聊**:她挂在聊天流上(`conversation.chat.node` 插槽),助手每说一句她就念一句,情绪按话里的词换。
27
33
 
28
34
  **也能直接跟她说**:卡片底下有个小框,打一句回车,她回你。这条线**不进你的聊天记录**——后台直接问模型,往来只存在 `models/<id>/chat.json` 里。她说的话按人设卡现场写,不是复读;人设卡就是 `models/<id>/meta.json` 里的 `persona` 字段,没写就用默认那份(若妍)。
29
35
 
36
+ **她会自己开口**:十几分钟没人理她,她就找个话头说一句。嫌吵就把 `~/.dsh/dsh-live2d/config.json` 里的 `proactive` 设成 `false`。
37
+
38
+ **给别人留的口子**:im 机器人、别的插件、随手写的脚本,都能让她说话。
39
+
40
+ ```bash
41
+ # 她说一句(image 可省,传了就在气泡里贴出来)
42
+ curl -X POST http://127.0.0.1:<端口>/dsh-live2d/api/push \
43
+ -H 'content-type: application/json' \
44
+ -d '{"text":"锅里有汤。","mood":"平静","from":"im"}'
45
+
46
+ # 她说一句并要回话
47
+ curl -X POST http://127.0.0.1:<端口>/dsh-live2d/api/chat \
48
+ -H 'content-type: application/json' -d '{"text":"今天累不累"}'
49
+ ```
50
+
51
+ 卡片每 2.5 秒拉一次 `/dsh-live2d/api/poll`,所以外面推进来的话,几秒内就演出来。
52
+
30
53
  ## 三十秒上手
31
54
 
32
55
  ```bash
@@ -33,6 +33,16 @@
33
33
  border-radius: 6px; padding: 3px 8px; cursor: pointer; font: inherit; }
34
34
  #exps button.on { background: #3a5f8a; border-color: #4c7bb0; color: #eaf2ff; }
35
35
  #exps .tipx { color: #6f7783; font-size: 11px; align-self: center; }
36
+ #choices { display: none; position: absolute; left: 12px; right: 12px; bottom: 96px; flex-wrap: wrap; gap: 6px; }
37
+ #choices.show { display: flex; }
38
+ #choices button { background: rgba(38,48,62,.96); border: 1px solid #4a6a92; border-radius: 14px;
39
+ color: #dfe9f6; font: inherit; font-size: 12px; padding: 5px 11px; cursor: pointer; }
40
+ #choices button:hover { background: #2f4a68; }
41
+ #settings { max-height: 42%; overflow-y: auto; margin-bottom: 8px; }
42
+ #settings h3 { font-size: 10px; letter-spacing: .6px; color: #6f7783; font-weight: 600; margin: 8px 0 4px; }
43
+ .opt { display: flex; align-items: center; gap: 8px; padding: 3px 2px; font-size: 12px; color: #c3c9d3; }
44
+ .opt input { accent-color: #6aa9ff; }
45
+ .bub-img { display: block; max-width: 100%; max-height: 120px; border-radius: 8px; margin-bottom: 6px; object-fit: contain; }
36
46
  #actions { display: flex; gap: 6px; }
37
47
  #actions button { flex: 1; background: #2f343d; color: #cfd4dd; border: 1px solid #3c424c;
38
48
  border-radius: 8px; padding: 6px; cursor: pointer; font: inherit; }
@@ -43,11 +53,13 @@
43
53
  <body>
44
54
  <canvas id="stage"></canvas>
45
55
  <div id="bubble"></div>
56
+ <div id="choices"></div>
46
57
  <div id="hint"><div>还没有模型。<br />点下面的「导入模型」,选中你的 Live2D 文件夹(含 .model3.json 那一层)。</div></div>
47
58
  <div id="drawer">
48
59
  <h2>模型</h2>
49
60
  <div id="list"></div>
50
61
  <div id="exps"></div>
62
+ <div id="settings"></div>
51
63
  <div id="actions">
52
64
  <button id="import">导入模型</button>
53
65
  <button id="close">关掉</button>
package/assets/viewer.js CHANGED
@@ -700,8 +700,8 @@
700
700
  }
701
701
  return base2;
702
702
  }
703
- function digitToBasic(digit, flag) {
704
- return digit + 22 + 75 * (digit < 26) - ((flag != 0) << 5);
703
+ function digitToBasic(digit, flag2) {
704
+ return digit + 22 + 75 * (digit < 26) - ((flag2 != 0) << 5);
705
705
  }
706
706
  function adapt(delta, numPoints, firstTime) {
707
707
  var k2 = 0;
@@ -9999,16 +9999,16 @@ void main(void)
9999
9999
  if (!this.bitmap && (this.process(), !this.bitmap))
10000
10000
  return false;
10001
10001
  if (super.upload(renderer, baseTexture, glTexture, this.bitmap), !this.preserveBitmap) {
10002
- let flag = true;
10002
+ let flag2 = true;
10003
10003
  const glTextures = baseTexture._glTextures;
10004
10004
  for (const key in glTextures) {
10005
10005
  const otherTex = glTextures[key];
10006
10006
  if (otherTex !== glTexture && otherTex.dirtyId !== baseTexture.dirtyId) {
10007
- flag = false;
10007
+ flag2 = false;
10008
10008
  break;
10009
10009
  }
10010
10010
  }
10011
- flag && (this.bitmap.close && this.bitmap.close(), this.bitmap = null);
10011
+ flag2 && (this.bitmap.close && this.bitmap.close(), this.bitmap = null);
10012
10012
  }
10013
10013
  return true;
10014
10014
  }
@@ -31603,16 +31603,16 @@ void main(void)\r
31603
31603
  }
31604
31604
  _super.prototype.upload.call(this, renderer, baseTexture, glTexture, this.bitmap);
31605
31605
  if (!this.preserveBitmap) {
31606
- var flag = true;
31606
+ var flag2 = true;
31607
31607
  var glTextures = baseTexture._glTextures;
31608
31608
  for (var key in glTextures) {
31609
31609
  var otherTex = glTextures[key];
31610
31610
  if (otherTex !== glTexture && otherTex.dirtyId !== baseTexture.dirtyId) {
31611
- flag = false;
31611
+ flag2 = false;
31612
31612
  break;
31613
31613
  }
31614
31614
  }
31615
- if (flag) {
31615
+ if (flag2) {
31616
31616
  if (this.bitmap.close) {
31617
31617
  this.bitmap.close();
31618
31618
  }
@@ -44321,6 +44321,110 @@ void main(void)\r
44321
44321
  var POKE_HEAD = ["\u5514\u3002", "\u522B\u6478\u3002", "\u5934\u53D1\u4E71\u4E86\u3002", "\u55EF\u2026\u2026", "\u4F60\u624B\u51C9\u3002"];
44322
44322
  var POKE_BODY = ["\u770B\u4EC0\u4E48\u770B\u3002", "\u6233\u4EC0\u4E48\u3002", "\u54FC\u3002", "\u522B\u95F9\u3002"];
44323
44323
  var POKE_LOW = ["\u5728\u5462\u3002", "\u6709\u4E8B\uFF1F", "\u600E\u4E48\u4E86\u3002"];
44324
+ var flags = {};
44325
+ try {
44326
+ flags = JSON.parse(localStorage.getItem("dsh-l2d-flags") || "{}");
44327
+ } catch {
44328
+ flags = {};
44329
+ }
44330
+ function flag(k2) {
44331
+ return flags[k2] === void 0 ? true : !!flags[k2];
44332
+ }
44333
+ function setFlag(k2, v2) {
44334
+ flags[k2] = !!v2;
44335
+ try {
44336
+ localStorage.setItem("dsh-l2d-flags", JSON.stringify(flags));
44337
+ } catch {
44338
+ }
44339
+ }
44340
+ var LOCAL_OPTS = [
44341
+ ["gaze", "\u773C\u775B\u8DDF\u7740\u9F20\u6807"],
44342
+ ["poke", "\u70B9\u5979\u6709\u53CD\u5E94"],
44343
+ ["motion", "\u81EA\u5DF1\u64AD\u52A8\u4F5C"],
44344
+ ["sound", "\u51FA\u58F0"],
44345
+ ["follow", "\u8DDF\u7740\u804A\u5929\u5FF5"]
44346
+ ];
44347
+ var SERVER_OPTS = [
44348
+ ["proactive", "\u6CA1\u4EBA\u7406\u5C31\u81EA\u5DF1\u5F00\u53E3"],
44349
+ ["memes", "\u4F1A\u7529\u8868\u60C5\u5305"],
44350
+ ["choices", "\u7ED9\u9009\u9879\u8BA9\u4ED6\u6311"],
44351
+ ["memory", "\u8BB0\u4F4F\u4E4B\u524D\u8BF4\u7684"]
44352
+ ];
44353
+ var serverSettings = {};
44354
+ function renderSettings() {
44355
+ const box = $2("settings");
44356
+ if (!box) return;
44357
+ box.innerHTML = "";
44358
+ const h1 = document.createElement("h3");
44359
+ h1.textContent = "\u8FD9\u53F0\u673A\u5668\u4E0A";
44360
+ box.appendChild(h1);
44361
+ for (const [k2, label] of LOCAL_OPTS) {
44362
+ const row = document.createElement("label");
44363
+ row.className = "opt";
44364
+ const cb = document.createElement("input");
44365
+ cb.type = "checkbox";
44366
+ cb.checked = flag(k2);
44367
+ cb.onchange = () => setFlag(k2, cb.checked);
44368
+ row.appendChild(cb);
44369
+ row.appendChild(document.createTextNode(label));
44370
+ box.appendChild(row);
44371
+ }
44372
+ const h2 = document.createElement("h3");
44373
+ h2.textContent = "\u5979\u90A3\u8FB9";
44374
+ box.appendChild(h2);
44375
+ for (const [k2, label] of SERVER_OPTS) {
44376
+ const row = document.createElement("label");
44377
+ row.className = "opt";
44378
+ const cb = document.createElement("input");
44379
+ cb.type = "checkbox";
44380
+ cb.checked = serverSettings[k2] !== false;
44381
+ cb.onchange = () => {
44382
+ serverSettings[k2] = cb.checked;
44383
+ fetch(BASE + "/api/settings", {
44384
+ method: "POST",
44385
+ headers: { "content-type": "application/json" },
44386
+ body: JSON.stringify({ settings: { [k2]: cb.checked } })
44387
+ }).catch(() => {
44388
+ });
44389
+ };
44390
+ row.appendChild(cb);
44391
+ row.appendChild(document.createTextNode(label));
44392
+ box.appendChild(row);
44393
+ }
44394
+ }
44395
+ function loadSettings() {
44396
+ fetch(BASE + "/api/settings").then((r2) => r2.json()).then((d2) => {
44397
+ serverSettings = d2.settings || {};
44398
+ renderSettings();
44399
+ }).catch(() => renderSettings());
44400
+ }
44401
+ function showChoices(list) {
44402
+ const box = $2("choices");
44403
+ if (!box) return;
44404
+ box.innerHTML = "";
44405
+ if (!list || !list.length) {
44406
+ box.classList.remove("show");
44407
+ return;
44408
+ }
44409
+ for (const c2 of list) {
44410
+ const b2 = document.createElement("button");
44411
+ b2.textContent = c2;
44412
+ b2.onclick = () => {
44413
+ box.classList.remove("show");
44414
+ fetch(BASE + "/api/chat", {
44415
+ method: "POST",
44416
+ headers: { "content-type": "application/json" },
44417
+ body: JSON.stringify({ text: c2 })
44418
+ }).then((r2) => r2.json()).then((d2) => {
44419
+ if (d2.reply || d2.image) say(d2.reply || "", d2.mood, d2.image);
44420
+ showChoices(d2.choices || []);
44421
+ }).catch(() => {
44422
+ });
44423
+ };
44424
+ box.appendChild(b2);
44425
+ }
44426
+ box.classList.add("show");
44427
+ }
44324
44428
  var tone = { type: "triangle", base: 400, jit: 10, dur: 60, vol: 32, spd: 62 };
44325
44429
  var ac = null;
44326
44430
  function audioCtx() {
@@ -44337,7 +44441,7 @@ void main(void)\r
44337
44441
  }
44338
44442
  function blip(freq, durMs, type) {
44339
44443
  const a2 = audioCtx();
44340
- if (!a2 || !(tone.vol > 0)) return;
44444
+ if (!a2 || !flag("sound") || !(tone.vol > 0)) return;
44341
44445
  const t0 = a2.currentTime;
44342
44446
  const osc = a2.createOscillator();
44343
44447
  const g2 = a2.createGain();
@@ -44485,7 +44589,7 @@ void main(void)\r
44485
44589
  await refresh(true);
44486
44590
  $2("drawer").classList.remove("show");
44487
44591
  }
44488
- function say(text, m2) {
44592
+ function say(text, m2, image) {
44489
44593
  if (m2) applyMood(m2);
44490
44594
  const bubble = $2("bubble");
44491
44595
  let s2 = String(text == null ? "" : text).replace(/\s+/g, " ").trim();
@@ -44493,6 +44597,14 @@ void main(void)\r
44493
44597
  bubble.textContent = "";
44494
44598
  bubble.style.opacity = "1";
44495
44599
  clearTimeout(sayFade);
44600
+ if (image) {
44601
+ const img = document.createElement("img");
44602
+ img.className = "bub-img";
44603
+ img.src = String(image);
44604
+ bubble.appendChild(img);
44605
+ }
44606
+ const holder = document.createElement("div");
44607
+ bubble.appendChild(holder);
44496
44608
  const chars = Array.from(s2);
44497
44609
  let i2 = 0;
44498
44610
  const step = Math.max(20, Number(tone.spd) || 62);
@@ -44506,7 +44618,7 @@ void main(void)\r
44506
44618
  return;
44507
44619
  }
44508
44620
  const ch = chars[i2++];
44509
- bubble.textContent += ch;
44621
+ holder.textContent += ch;
44510
44622
  mouth = 0.14;
44511
44623
  if (ch.trim() && PAUSE_CH.indexOf(ch) < 0) {
44512
44624
  const f2 = tone.base * (1 + (Math.random() - 0.5) * 2 * (Number(tone.jit) / 100));
@@ -44522,7 +44634,10 @@ void main(void)\r
44522
44634
  window.addEventListener("message", (e2) => {
44523
44635
  const d2 = e2.data || {};
44524
44636
  if (d2.type === "dsh-live2d/manage") $2("drawer").classList.toggle("show");
44525
- if (d2.type === "dsh-live2d/say") say(String(d2.text || ""), d2.mood);
44637
+ if (d2.type === "dsh-live2d/say") {
44638
+ say(String(d2.text || ""), d2.mood, d2.image);
44639
+ if (Array.isArray(d2.choices)) showChoices(d2.choices);
44640
+ }
44526
44641
  if (d2.type === "dsh-live2d/mood") applyMood(String(d2.mood || "\u5E73\u9759"));
44527
44642
  if (d2.type === "dsh-live2d/tone" && d2.voice && typeof d2.voice === "object") Object.assign(tone, d2.voice);
44528
44643
  });
@@ -44550,8 +44665,8 @@ void main(void)\r
44550
44665
  }
44551
44666
  }
44552
44667
  setP("ParamBreath", (Math.sin(t2 * 1.1) + 1) / 2);
44553
- const gx = Math.max(-1, Math.min(1, gazeX));
44554
- const gy = Math.max(-1, Math.min(1, gazeY));
44668
+ const gx = flag("gaze") ? Math.max(-1, Math.min(1, gazeX)) : 0;
44669
+ const gy = flag("gaze") ? Math.max(-1, Math.min(1, gazeY)) : 0;
44555
44670
  setP("ParamEyeBallX", gx * 0.9);
44556
44671
  setP("ParamEyeBallY", -gy * 0.65);
44557
44672
  if (!window.__holdHead) {
@@ -44567,7 +44682,7 @@ void main(void)\r
44567
44682
  core.update();
44568
44683
  });
44569
44684
  function maybeMotion() {
44570
- if (!model || !motionGroups.length) return;
44685
+ if (!model || !motionGroups.length || !flag("motion")) return;
44571
44686
  const now = performance.now();
44572
44687
  if (now < nextMotionAt) return;
44573
44688
  nextMotionAt = now + 12e3 + Math.random() * 16e3;
@@ -44578,6 +44693,7 @@ void main(void)\r
44578
44693
  }
44579
44694
  }
44580
44695
  window.addEventListener("resize", () => setTimeout(fit, 60));
44696
+ loadSettings();
44581
44697
  refresh(true).then(() => {
44582
44698
  if (state.models.length) setTimeout(idleTick, 2500);
44583
44699
  setInterval(() => {
@@ -44658,6 +44774,7 @@ void main(void)\r
44658
44774
  $2("stage").addEventListener("pointerdown", (e2) => {
44659
44775
  if (!core) return;
44660
44776
  audioCtx();
44777
+ if (!flag("poke")) return;
44661
44778
  const now = Date.now();
44662
44779
  if (now - pokeAt < 800) return;
44663
44780
  pokeAt = now;
package/lib/client.js CHANGED
@@ -80,6 +80,46 @@
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
+ {
103
+ type: 'dsh-live2d/say',
104
+ text: items[i].text || '',
105
+ mood: items[i].mood,
106
+ image: items[i].image,
107
+ choices: items[i].choices,
108
+ },
109
+ '*',
110
+ );
111
+ } catch {}
112
+ }
113
+ })
114
+ .catch(() => {});
115
+ };
116
+ tick();
117
+ const id = setInterval(tick, 2500);
118
+ return () => {
119
+ stop = true;
120
+ clearInterval(id);
121
+ };
122
+ }, []);
83
123
  react.useEffect(() => {
84
124
  if (ref.current && ref.current.contentWindow) {
85
125
  ref.current.contentWindow.postMessage({ type: 'dsh-live2d/bg', bg }, '*');
@@ -150,9 +190,12 @@
150
190
  })
151
191
  .then((r) => r.json())
152
192
  .then((d) => {
153
- if (d.reply && frameWin) {
193
+ if ((d.reply || d.image) && frameWin) {
154
194
  try {
155
- frameWin.postMessage({ type: 'dsh-live2d/say', text: d.reply }, '*');
195
+ frameWin.postMessage(
196
+ { type: 'dsh-live2d/say', text: d.reply || '', image: d.image, choices: d.choices },
197
+ '*',
198
+ );
156
199
  } catch {}
157
200
  }
158
201
  })
@@ -257,7 +300,13 @@
257
300
  (props && props.node && (props.node.role || props.node.kind)) ||
258
301
  (props && props.message && props.message.role) ||
259
302
  '';
260
- const skip = /user|human|prompt/i.test(String(role));
303
+ let followOn = true;
304
+ try {
305
+ followOn = JSON.parse(localStorage.getItem('dsh-l2d-flags') || '{}').follow !== false;
306
+ } catch {
307
+ followOn = true;
308
+ }
309
+ const skip = !followOn || /user|human|prompt/i.test(String(role));
261
310
  react.useEffect(() => {
262
311
  if (skip) return;
263
312
  const el = anchor.current;
package/lib/index.js CHANGED
@@ -9,6 +9,86 @@ const ROOT = join(homedir(), '.dsh', 'dsh-live2d');
9
9
  const MODELS = join(ROOT, 'models');
10
10
  const CONFIG = join(ROOT, 'config.json');
11
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
+ proactive: true,
28
+ memes: true,
29
+ choices: true,
30
+ memory: true,
31
+ follow: true,
32
+ gaze: true,
33
+ poke: true,
34
+ motion: true,
35
+ sound: true,
36
+ };
37
+
38
+ function settingsOf(cfg) {
39
+ return { ...DEFAULT_SETTINGS, ...(cfg?.settings || {}) };
40
+ }
41
+
42
+ /** 从表情图库按情绪挑一张,返回相对路径 */
43
+ function pickMeme(mood) {
44
+ try {
45
+ const tags = MEME_TAGS[mood] || MEME_TAGS['平静'];
46
+ const galleries = readdirSync(MEMES_ROOT, { withFileTypes: true }).filter((e) => e.isDirectory());
47
+ const pool = [];
48
+ for (const g of galleries) {
49
+ for (const tag of tags) {
50
+ const dir = join(MEMES_ROOT, g.name, 'memes', tag);
51
+ let files = [];
52
+ try {
53
+ files = readdirSync(dir);
54
+ } catch {
55
+ continue;
56
+ }
57
+ for (const f of files) {
58
+ if (/\.(png|jpe?g|webp|gif)$/i.test(f)) pool.push(g.name + '/memes/' + tag + '/' + f);
59
+ }
60
+ }
61
+ }
62
+ if (!pool.length) return null;
63
+ return pool[Math.floor(Math.random() * pool.length)];
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** 从回复里摘出「||选项1|选项2」这种尾巴 */
70
+ function splitChoices(raw) {
71
+ const m = /\|\|\s*([^|]+(?:\|[^|]+)*)\s*$/.exec(raw);
72
+ if (!m) return { text: raw.trim(), choices: [] };
73
+ const choices = m[1]
74
+ .split('|')
75
+ .map((s) => s.trim())
76
+ .filter(Boolean)
77
+ .slice(0, 4);
78
+ return { text: raw.slice(0, m.index).trim(), choices };
79
+ }
80
+
81
+ /** 粗判情绪,配图用 */
82
+ function guessMood(text) {
83
+ const s = String(text || '');
84
+ if (/[气烦讨厌]/.test(s)) return '生气';
85
+ if (/[委屈难过哭]/.test(s)) return '委屈';
86
+ if (/[困睡熬夜]/.test(s)) return '困';
87
+ if (/[唔害羞]/.test(s)) return '害羞';
88
+ if (/[无语算了]/.test(s)) return '无语';
89
+ if (/[好谢喜欢]/.test(s)) return '开心';
90
+ return '平静';
91
+ }
12
92
 
13
93
  /** 默认音色:一个字一声短音 */
14
94
  const DEFAULT_VOICE = { preset: 'soft', type: 'triangle', base: 400, jit: 10, dur: 60, vol: 32, spd: 62 };
@@ -40,6 +120,97 @@ function writeChat(id, messages) {
40
120
  } catch {}
41
121
  }
42
122
 
123
+ /* ── 出口队列:谁能说话,都往这儿扔 ───────────────────────── */
124
+ const OUTBOX = [];
125
+ const LAST_ACTIVE = { at: Date.now() };
126
+ let outSeq = 0;
127
+
128
+ function pushOut(msg) {
129
+ outSeq += 1;
130
+ OUTBOX.push({ seq: outSeq, at: Date.now(), ...msg });
131
+ if (OUTBOX.length > 64) OUTBOX.splice(0, OUTBOX.length - 64);
132
+ return outSeq;
133
+ }
134
+
135
+ function nowText() { try {
136
+ return new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
137
+ } catch {
138
+ return new Date().toISOString();
139
+ }
140
+ }
141
+
142
+ /** 拿默认模型,缺了就返回 null */
143
+ function modelSlot(ctx) {
144
+ try {
145
+ const sel = ctx?.get?.('agentDefaultModel')?.currentSelection?.();
146
+ const llm = ctx?.get?.('llm');
147
+ if (!llm || typeof llm.stream !== 'function' || !sel?.provider || !sel?.model) return null;
148
+ return { llm, provider: sel.provider, model: sel.model };
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /** 一次生成,只要文字 */
155
+ async function generate(slot, prompt, limit = 900) {
156
+ let text = '';
157
+ for await (const chunk of slot.llm.stream({
158
+ provider: slot.provider,
159
+ model: slot.model,
160
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
161
+ })) {
162
+ if (chunk?.type === 'text-delta') text += chunk.text ?? '';
163
+ else if (chunk?.type === 'finish') break;
164
+ if (text.length > limit) break;
165
+ }
166
+ return text
167
+ .replace(/\r?\n+/g, ' ')
168
+ .trim()
169
+ .replace(/^["“「『']+|["”」』']+$/g, '')
170
+ .trim();
171
+ }
172
+
173
+ /* ── 主动开口:没人说话,她自己开一句 ─────────────────────── */
174
+ const IDLE_MS = 12 * 60 * 1000;
175
+ let lastAutoAt = 0;
176
+
177
+ async function maybeTalk(ctx) {
178
+ const cfg = readConfig();
179
+ if (!settingsOf(cfg).proactive) return;
180
+ const now = Date.now();
181
+ if (now - LAST_ACTIVE.at < IDLE_MS) return;
182
+ if (now - lastAutoAt < IDLE_MS) return;
183
+ const id = String(cfg.selected || '');
184
+ const meta = id ? readMeta(id) : null;
185
+ if (!meta) return;
186
+ const slot = modelSlot(ctx);
187
+ if (!slot) return;
188
+ lastAutoAt = now;
189
+ const hist = readChat(id)
190
+ .slice(-6)
191
+ .map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
192
+ .join('\n');
193
+ const prompt = [
194
+ personaOf(meta),
195
+ '现在是 ' + nowText() + '。',
196
+ hist ? '你们之前说的:\n' + hist : '',
197
+ '有一阵子没人说话了。你想开口,说一句给他。',
198
+ '只输出这一句本身:一到两句,短,口语。不要解释,不要引号,不要旁白,不要括号里的动作。',
199
+ ]
200
+ .filter(Boolean)
201
+ .join('\n\n');
202
+ let text = '';
203
+ try {
204
+ text = await generate(slot, prompt, 400);
205
+ } catch {
206
+ return;
207
+ }
208
+ if (!text) return;
209
+ const line = text.length > 120 ? text.slice(0, 120) : text;
210
+ pushOut({ text: line, from: 'proactive' });
211
+ writeChat(id, readChat(id).concat([{ role: 'assistant', text: line, at: Date.now() }]));
212
+ }
213
+
43
214
  const MIME = {
44
215
  '.html': 'text/html; charset=utf-8',
45
216
  '.js': 'text/javascript; charset=utf-8',
@@ -48,6 +219,7 @@ const MIME = {
48
219
  '.jpg': 'image/jpeg',
49
220
  '.jpeg': 'image/jpeg',
50
221
  '.webp': 'image/webp',
222
+ '.gif': 'image/gif',
51
223
  '.wasm': 'application/wasm',
52
224
  '.moc3': 'application/octet-stream',
53
225
  };
@@ -194,6 +366,12 @@ async function handle(req, res, ctx) {
194
366
  return file ? sendFile(res, file) : send(res, 403, 'forbidden');
195
367
  }
196
368
 
369
+ // 表情图库
370
+ if (path.startsWith('/media/')) {
371
+ const file = safeJoin(MEMES_ROOT, decodeURIComponent(path.slice('/media/'.length)));
372
+ return file ? sendFile(res, file) : send(res, 403, 'forbidden');
373
+ }
374
+
197
375
  // 模型文件
198
376
  if (path.startsWith('/model/')) {
199
377
  const rest = path.slice('/model/'.length);
@@ -214,61 +392,45 @@ async function handle(req, res, ctx) {
214
392
  const meta = id ? readMeta(id) : null;
215
393
  if (!meta) return sendJson(res, 404, { error: 'model not found' });
216
394
 
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
- }
395
+ const slot = modelSlot(ctx);
396
+ if (!slot) return sendJson(res, 503, { error: '模型接口不可用' });
229
397
 
230
- const hist = readChat(id);
398
+ const st = settingsOf(readConfig());
399
+ const hist = st.memory ? readChat(id) : [];
231
400
  const lines = hist
232
401
  .slice(-8)
233
402
  .map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
234
403
  .join('\n');
235
404
  const prompt = [
236
405
  personaOf(meta),
406
+ '现在是 ' + nowText() + '。',
237
407
  lines ? '你们之前说的:\n' + lines : '',
238
408
  '他说:' + text,
239
409
  '回他一句。只输出这句话本身:一到三句,短,口语,像发消息。不要解释,不要加引号,不要旁白,不要括号里的动作。',
410
+ st.choices ? '如果你想让他挑,可以在最后另起一行写 ||选项一|选项二,最多三项。' : '',
240
411
  ]
241
412
  .filter(Boolean)
242
413
  .join('\n\n');
243
414
 
244
415
  let reply = '';
245
416
  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
- }
417
+ reply = await generate(slot, prompt);
255
418
  } catch (e) {
256
419
  return sendJson(res, 502, { error: '生成失败:' + (e instanceof Error ? e.message : String(e)) });
257
420
  }
258
-
259
- reply = reply
260
- .replace(/\r?\n+/g, ' ')
261
- .trim()
262
- .replace(/^["“「『']+|["”」』']+$/g, '')
263
- .trim();
264
- if (!reply) return sendJson(res, 200, { ok: true, reply: '', id });
265
- if (reply.length > 240) reply = reply.slice(0, 240);
421
+ LAST_ACTIVE.at = Date.now();
422
+ const parsed = splitChoices(reply || '');
423
+ let line = parsed.text;
424
+ const choices = st.choices ? parsed.choices : [];
425
+ if (line.length > 240) line = line.slice(0, 240);
426
+ if (!line && !choices.length) return sendJson(res, 200, { ok: true, reply: '', id });
427
+ const image = st.memes && guessMood(line) && Math.random() < 0.35 ? pickMeme(guessMood(line)) : null;
266
428
 
267
429
  writeChat(id, hist.concat([
268
430
  { role: 'user', text, at: Date.now() },
269
- { role: 'assistant', text: reply, at: Date.now() },
431
+ { role: 'assistant', text: line, at: Date.now() },
270
432
  ]));
271
- return sendJson(res, 200, { ok: true, reply, id });
433
+ return sendJson(res, 200, { ok: true, reply: line, choices, image: image || undefined, id });
272
434
  }
273
435
 
274
436
  // 人设卡
@@ -288,6 +450,46 @@ async function handle(req, res, ctx) {
288
450
  return sendJson(res, 200, { ok: true, persona: personaOf({ persona }) });
289
451
  }
290
452
 
453
+ // 开关:哪些功能开着
454
+ if (path === '/api/settings' && method === 'GET') {
455
+ return sendJson(res, 200, { settings: settingsOf(readConfig()) });
456
+ }
457
+
458
+ if (path === '/api/settings' && method === 'POST') {
459
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
460
+ const cfg = readConfig();
461
+ const cur = settingsOf(cfg);
462
+ const next = { ...cur };
463
+ const patch = body && typeof body.settings === 'object' ? body.settings : body;
464
+ for (const k of Object.keys(DEFAULT_SETTINGS)) {
465
+ if (typeof patch?.[k] === 'boolean') next[k] = patch[k];
466
+ }
467
+ writeConfig({ ...cfg, settings: next });
468
+ return sendJson(res, 200, { ok: true, settings: next });
469
+ }
470
+
471
+ // 出口:别的插件、im 机器人、脚本都能把她要说的话推进来
472
+ if (path === '/api/push' && method === 'POST') {
473
+ const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
474
+ const text = String(body.text ?? '').trim().slice(0, 2000);
475
+ const image = String(body.image ?? '').trim().slice(0, 2000);
476
+ if (!text && !image) return sendJson(res, 400, { error: 'empty' });
477
+ const seq = pushOut({
478
+ text,
479
+ image: image || undefined,
480
+ mood: body.mood ? String(body.mood) : undefined,
481
+ from: body.from ? String(body.from).slice(0, 40) : 'external',
482
+ });
483
+ LAST_ACTIVE.at = Date.now();
484
+ return sendJson(res, 200, { ok: true, seq });
485
+ }
486
+
487
+ // 客户端轮询:拿到她要说的话
488
+ if (path === '/api/poll' && method === 'GET') {
489
+ const since = Number(url.searchParams.get('since') || 0);
490
+ return sendJson(res, 200, { seq: outSeq, items: OUTBOX.filter((m) => m.seq > since) });
491
+ }
492
+
291
493
  // 音色调音页
292
494
  if (path === '/tone' || path === '/tone.html') {
293
495
  return sendFile(res, join(ASSETS, 'tone.html'), MIME['.html']);
@@ -387,4 +589,11 @@ export function apply(ctx) {
387
589
  }),
388
590
  'dsh-live2d: /dsh-live2d 路由',
389
591
  );
592
+ ctx.effect(() => {
593
+ const timer = setInterval(() => {
594
+ maybeTalk(ctx).catch(() => {});
595
+ }, 60 * 1000);
596
+ timer.unref?.();
597
+ return () => clearInterval(timer);
598
+ }, 'dsh-live2d: 主动开口');
390
599
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-live2dwaifu",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、视线跟着鼠标、点她有反应、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹;支持自定义 .exp3.json 表情和 .motion3.json 动作,卡片能拖能缩,背景可换。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",