dsh-live2dwaifu 0.4.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,6 +23,12 @@
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` 字段,没写就用默认那份(若妍)。
@@ -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
@@ -99,7 +99,13 @@
99
99
  if (!frameWin) break;
100
100
  try {
101
101
  frameWin.postMessage(
102
- { type: 'dsh-live2d/say', text: items[i].text || '', mood: items[i].mood, image: items[i].image },
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
+ },
103
109
  '*',
104
110
  );
105
111
  } catch {}
@@ -184,9 +190,12 @@
184
190
  })
185
191
  .then((r) => r.json())
186
192
  .then((d) => {
187
- if (d.reply && frameWin) {
193
+ if ((d.reply || d.image) && frameWin) {
188
194
  try {
189
- 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
+ );
190
199
  } catch {}
191
200
  }
192
201
  })
@@ -291,7 +300,13 @@
291
300
  (props && props.node && (props.node.role || props.node.kind)) ||
292
301
  (props && props.message && props.message.role) ||
293
302
  '';
294
- 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));
295
310
  react.useEffect(() => {
296
311
  if (skip) return;
297
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 };
@@ -96,7 +176,7 @@ let lastAutoAt = 0;
96
176
 
97
177
  async function maybeTalk(ctx) {
98
178
  const cfg = readConfig();
99
- if (cfg.proactive === false) return;
179
+ if (!settingsOf(cfg).proactive) return;
100
180
  const now = Date.now();
101
181
  if (now - LAST_ACTIVE.at < IDLE_MS) return;
102
182
  if (now - lastAutoAt < IDLE_MS) return;
@@ -139,6 +219,7 @@ const MIME = {
139
219
  '.jpg': 'image/jpeg',
140
220
  '.jpeg': 'image/jpeg',
141
221
  '.webp': 'image/webp',
222
+ '.gif': 'image/gif',
142
223
  '.wasm': 'application/wasm',
143
224
  '.moc3': 'application/octet-stream',
144
225
  };
@@ -285,6 +366,12 @@ async function handle(req, res, ctx) {
285
366
  return file ? sendFile(res, file) : send(res, 403, 'forbidden');
286
367
  }
287
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
+
288
375
  // 模型文件
289
376
  if (path.startsWith('/model/')) {
290
377
  const rest = path.slice('/model/'.length);
@@ -308,7 +395,8 @@ async function handle(req, res, ctx) {
308
395
  const slot = modelSlot(ctx);
309
396
  if (!slot) return sendJson(res, 503, { error: '模型接口不可用' });
310
397
 
311
- const hist = readChat(id);
398
+ const st = settingsOf(readConfig());
399
+ const hist = st.memory ? readChat(id) : [];
312
400
  const lines = hist
313
401
  .slice(-8)
314
402
  .map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
@@ -319,6 +407,7 @@ async function handle(req, res, ctx) {
319
407
  lines ? '你们之前说的:\n' + lines : '',
320
408
  '他说:' + text,
321
409
  '回他一句。只输出这句话本身:一到三句,短,口语,像发消息。不要解释,不要加引号,不要旁白,不要括号里的动作。',
410
+ st.choices ? '如果你想让他挑,可以在最后另起一行写 ||选项一|选项二,最多三项。' : '',
322
411
  ]
323
412
  .filter(Boolean)
324
413
  .join('\n\n');
@@ -330,14 +419,18 @@ async function handle(req, res, ctx) {
330
419
  return sendJson(res, 502, { error: '生成失败:' + (e instanceof Error ? e.message : String(e)) });
331
420
  }
332
421
  LAST_ACTIVE.at = Date.now();
333
- if (!reply) return sendJson(res, 200, { ok: true, reply: '', id });
334
- if (reply.length > 240) reply = reply.slice(0, 240);
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;
335
428
 
336
429
  writeChat(id, hist.concat([
337
430
  { role: 'user', text, at: Date.now() },
338
- { role: 'assistant', text: reply, at: Date.now() },
431
+ { role: 'assistant', text: line, at: Date.now() },
339
432
  ]));
340
- return sendJson(res, 200, { ok: true, reply, id });
433
+ return sendJson(res, 200, { ok: true, reply: line, choices, image: image || undefined, id });
341
434
  }
342
435
 
343
436
  // 人设卡
@@ -357,6 +450,24 @@ async function handle(req, res, ctx) {
357
450
  return sendJson(res, 200, { ok: true, persona: personaOf({ persona }) });
358
451
  }
359
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
+
360
471
  // 出口:别的插件、im 机器人、脚本都能把她要说的话推进来
361
472
  if (path === '/api/push' && method === 'POST') {
362
473
  const body = JSON.parse((await readBody(req)).toString('utf8') || '{}');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-live2dwaifu",
3
- "version": "0.4.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",