dsh-live2dwaifu 0.7.0 → 0.8.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
@@ -33,6 +33,12 @@
33
33
 
34
34
  **也能直接跟她说**:卡片底下有个小框,打一句回车,她回你。这条线**不进你的聊天记录**——后台直接问模型,往来只存在 `models/<id>/chat.json` 里。她说的话按人设卡现场写,不是复读;人设卡就是 `models/<id>/meta.json` 里的 `persona` 字段,没写就用默认那份(若妍)。
35
35
 
36
+ **她记得住**:聊够三十句,前面二十句会被压成一小段小结挂在记忆里,不会一过二十句就把你说过的话全忘掉。嫌费 token 就在 ⚙ 里把「记住之前说的」关掉。
37
+
38
+ **她知道几点、也知道自己此刻在干什么**:提示词里会让她按人设推断这个时间点的处境——早上在实验室、半夜还没睡——话里自然带着那个味道,不用你提醒。
39
+
40
+ **手机上一页就够**:手机连同一个地址,打开 `/dsh-live2d/chat`,就是她的脸加一个输入框,不用看整个 DSH。
41
+
36
42
  **她会自己开口**:十几分钟没人理她,她就找个话头说一句。嫌吵就把 `~/.dsh/dsh-live2d/config.json` 里的 `proactive` 设成 `false`。
37
43
 
38
44
  **给别人留的口子**:im 机器人、别的插件、随手写的脚本,都能让她说话。
@@ -102,6 +108,24 @@ frame.contentWindow.postMessage({ type: 'dsh-live2d/manage' }, '*');
102
108
  | POST | `/dsh-live2d/api/commit` | `{id,name}` 扫一遍 model3 并选中 |
103
109
  | POST | `/dsh-live2d/api/delete` | `{id}` 删掉 |
104
110
 
111
+ ## 写人设卡,请写细一点
112
+
113
+ 她回得像不像人,全看那段字。别只写“一个可爱的女孩”——那样只会得到一段没味道的话。
114
+
115
+ 值得写进去的:她是谁、几岁、做什么的、说话什么调子(短句还是长句、用不用标点)、有没有口头禅、怕什么、喜欢什么、跟你是什么关系、平时怎么称呼你、这个时间点通常在干嘛。
116
+
117
+ `models/<id>/meta.json` 里的 `persona` 想写多长写多长,写不写都行,不写就是默认那份。也可以点标题栏的 **✎** 在网页里改。
118
+
119
+ ## 把她的表情包给 dsh-meme 用
120
+
121
+ 自带那二十张是标准图库格式,导一次就能丢给 `dsh-meme`:
122
+
123
+ ```bash
124
+ python tools/export-dsh-meme.py
125
+ ```
126
+
127
+ 产物在 `dsh-live2d/dsh-meme-pack/xialing-001/`(含 `manifest.json`、`memes/<tag>/`、`index.db`)。整个目录丢进 `~/.dsh/profiles/desktop/node_modules/dsh-meme/memes/` 下面就认了。
128
+
105
129
  ## 东西都放哪了
106
130
 
107
131
  ```
@@ -0,0 +1,86 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#12141a" />
7
+ <title>夏铃</title>
8
+ <style>
9
+ :root { --bg:#12141a; --line:#2c313a; --txt:#dfe4ec; --dim:#7d858f; --acc:#6aa9ff; }
10
+ * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
11
+ html, body { margin:0; height:100%; overflow:hidden; background:var(--bg); color:var(--txt);
12
+ font:14px/1.5 "Microsoft YaHei", system-ui, sans-serif; }
13
+ #app { display:flex; flex-direction:column; height:100%; height:100dvh; }
14
+ #stage { flex:1; border:0; width:100%; background:transparent; }
15
+ #bar { flex:none; display:flex; gap:8px; padding:8px 10px calc(8px + env(safe-area-inset-bottom));
16
+ border-top:1px solid var(--line); background:#15181e; }
17
+ #bar input { flex:1; min-width:0; padding:11px 13px; border-radius:12px; border:1px solid #333842;
18
+ background:#1b1f27; color:var(--txt); font:inherit; outline:none; }
19
+ #bar input:focus { border-color:#3f4a5c; }
20
+ #bar button { flex:none; padding:0 16px; border-radius:12px; border:1px solid #3e6b9e;
21
+ background:#27405f; color:#eaf2ff; font:inherit; cursor:pointer; }
22
+ #bar button:active { filter:brightness(1.15); }
23
+ #hint { flex:none; text-align:center; font-size:11px; color:var(--dim); padding:0 0 6px; background:#15181e; }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <div id="app">
28
+ <iframe id="stage" src="/dsh-live2d/viewer?embed=1&bg=dark"></iframe>
29
+ <div id="hint">说一句,她回你。不进聊天记录。</div>
30
+ <div id="bar">
31
+ <input id="text" placeholder="跟她说一句" autocomplete="off" enterkeyhint="send" />
32
+ <button id="send">说</button>
33
+ </div>
34
+ </div>
35
+ <script>
36
+ (function () {
37
+ var frame = document.getElementById('stage');
38
+ var box = document.getElementById('text');
39
+ var btn = document.getElementById('send');
40
+ var busy = false;
41
+
42
+ function say(text, image, choices) {
43
+ try {
44
+ frame.contentWindow.postMessage({ type: 'dsh-live2d/say', text: text || '', image: image, choices: choices }, '*');
45
+ } catch (e) {}
46
+ }
47
+
48
+ function send() {
49
+ var t = box.value.trim();
50
+ if (!t || busy) return;
51
+ busy = true;
52
+ box.value = '';
53
+ fetch('/dsh-live2d/api/chat', {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/json' },
56
+ body: JSON.stringify({ text: t })
57
+ })
58
+ .then(function (r) { return r.json(); })
59
+ .then(function (d) {
60
+ if (d.reply || d.image) say(d.reply, d.image, d.choices);
61
+ })
62
+ .catch(function () {})
63
+ .finally(function () { busy = false; });
64
+ }
65
+
66
+ btn.addEventListener('click', send);
67
+ box.addEventListener('keydown', function (e) {
68
+ if (e.key === 'Enter') send();
69
+ });
70
+
71
+ // 主动开口也收着
72
+ var seq = null;
73
+ setInterval(function () {
74
+ fetch('/dsh-live2d/api/poll?since=' + (seq === null ? 0 : seq))
75
+ .then(function (r) { return r.json(); })
76
+ .then(function (d) {
77
+ if (seq === null) { seq = d.seq || 0; return; }
78
+ seq = d.seq || seq;
79
+ (d.items || []).forEach(function (it) { say(it.text, it.image, it.choices); });
80
+ })
81
+ .catch(function () {});
82
+ }, 3000);
83
+ })();
84
+ </script>
85
+ </body>
86
+ </html>
package/lib/index.js CHANGED
@@ -59,12 +59,12 @@ function pickOwnMeme(mood) {
59
59
  }
60
60
 
61
61
  /** 按模式挑一张配图,返回可直接用的 URL */
62
- function pickImage(mode, mood) {
62
+ function pickImage(mode, mood, label) {
63
63
  const m = MEMES_MODES.includes(mode) ? mode : 'off';
64
64
  if (m === 'off') return null;
65
65
  const pool = [];
66
66
  if (m === 'own' || m === 'both') {
67
- const f = pickOwnMeme(mood);
67
+ const f = (label && pickOwnMemeByLabel(label)) || pickOwnMeme(mood);
68
68
  if (f) pool.push('/dsh-live2d/ownmeme/' + encodeURIComponent(f));
69
69
  }
70
70
  if (m === 'external' || m === 'both') {
@@ -118,7 +118,30 @@ function splitChoices(raw) {
118
118
  return { text: raw.slice(0, m.index).trim(), choices };
119
119
  }
120
120
 
121
- /** 粗判情绪,配图用 */
121
+ /** 先摘「||图:名字」,再摘「||选项1|选项2」 */
122
+ function splitMeta(raw) {
123
+ let text = String(raw || '');
124
+ let imageLabel = '';
125
+ const imgMatch = /\|\|\s*图\s*[::]\s*([^\n|]+)\s*$/m.exec(text);
126
+ if (imgMatch) {
127
+ imageLabel = imgMatch[1].trim().replace(/^["“「『']+|["”」』']+$/g, '');
128
+ text = (text.slice(0, imgMatch.index) + text.slice(imgMatch.index + imgMatch[0].length)).trim();
129
+ }
130
+ const parsed = splitChoices(text);
131
+ return { text: parsed.text, choices: parsed.choices, imageLabel };
132
+ }
133
+
134
+ /** 按名字取自带那张 */
135
+ function pickOwnMemeByLabel(label) {
136
+ const items = readOwnMemes();
137
+ if (!items.length || !label) return null;
138
+ const key = String(label).trim();
139
+ const hit =
140
+ items.find((it) => it.label === key) ||
141
+ items.find((it) => String(it.file).replace(/\.\w+$/, '') === key) ||
142
+ items.find((it) => key && String(it.label).includes(key));
143
+ return hit ? hit.file : null;
144
+ }
122
145
  function guessMood(text) {
123
146
  const s = String(text || '');
124
147
  if (/[气烦讨厌]/.test(s)) return '生气';
@@ -162,21 +185,51 @@ function personaOf(meta) {
162
185
  return p || DEFAULT_PERSONA;
163
186
  }
164
187
 
165
- function readChat(id) {
188
+ function readChatStore(id) {
166
189
  try {
167
190
  const d = JSON.parse(readFileSync(join(MODELS, id, 'chat.json'), 'utf8'));
168
- return Array.isArray(d.messages) ? d.messages : [];
191
+ return {
192
+ messages: Array.isArray(d.messages) ? d.messages : [],
193
+ summary: typeof d.summary === 'string' ? d.summary : '',
194
+ };
169
195
  } catch {
170
- return [];
196
+ return { messages: [], summary: '' };
171
197
  }
172
198
  }
173
199
 
174
- function writeChat(id, messages) {
200
+ function readChat(id) {
201
+ return readChatStore(id).messages;
202
+ }
203
+
204
+ function writeChat(id, messages, summary) {
175
205
  try {
176
- writeFileSync(join(MODELS, id, 'chat.json'), JSON.stringify({ messages: messages.slice(-24) }, null, 2), 'utf8');
206
+ const prev = readChatStore(id);
207
+ const data = {
208
+ messages: messages.slice(-40),
209
+ summary: summary === undefined ? prev.summary : summary,
210
+ };
211
+ writeFileSync(join(MODELS, id, 'chat.json'), JSON.stringify(data, null, 2), 'utf8');
177
212
  } catch {}
178
213
  }
179
214
 
215
+ /** 攒够了就把前二十句压成一段小结,人物记得住更早的事 */
216
+ async function compactChat(slot, store) {
217
+ const head = store.messages.slice(0, 20);
218
+ const tail = store.messages.slice(20);
219
+ if (!head.length) return store;
220
+ const text = head.map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? '')).join('\n');
221
+ const prompt =
222
+ '把下面这段对话压成一段不超过一百二十字的小结,只留事实和情绪走向,用第三人称写,不要引号,不要分点:\n' + text;
223
+ let line = '';
224
+ try {
225
+ line = await generate(slot, prompt, 600);
226
+ } catch {
227
+ line = '';
228
+ }
229
+ const summary = [store.summary, line].filter(Boolean).join('\n').slice(-1200);
230
+ return { messages: tail, summary };
231
+ }
232
+
180
233
  /* ── 出口队列:谁能说话,都往这儿扔 ───────────────────────── */
181
234
  const OUTBOX = [];
182
235
  const LAST_ACTIVE = { at: Date.now() };
@@ -459,7 +512,14 @@ async function handle(req, res, ctx) {
459
512
  if (!slot) return sendJson(res, 503, { error: '模型接口不可用' });
460
513
 
461
514
  const st = settingsOf(readConfig());
462
- const hist = st.memory ? readChat(id) : [];
515
+ let store = st.memory ? readChatStore(id) : { messages: [], summary: '' };
516
+ if (st.memory && store.messages.length > 30) {
517
+ try {
518
+ store = await compactChat(slot, store);
519
+ writeChat(id, store.messages, store.summary);
520
+ } catch {}
521
+ }
522
+ const hist = store.messages;
463
523
  const lines = hist
464
524
  .slice(-8)
465
525
  .map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
@@ -467,11 +527,20 @@ async function handle(req, res, ctx) {
467
527
  const prompt = [
468
528
  personaOf(meta),
469
529
  meta.isolation === false ? '' : ISOLATION,
470
- '现在是 ' + nowText() + '。',
530
+ '现在是 ' + nowText() + '。按你这个人设,这个时间点你多半在做什么?让这句话带上那个处境,但不要说破。',
531
+ store.summary ? '之前聊过的,你还记得:\n' + store.summary : '',
471
532
  lines ? '你们之前说的:\n' + lines : '',
472
533
  '他说:' + text,
473
534
  '回他一句。只输出这句话本身:一到三句,短,口语,像发消息。不要解释,不要加引号,不要旁白,不要括号里的动作。',
474
535
  st.choices ? '如果你想让他挑,可以在最后另起一行写 ||选项一|选项二,最多三项。' : '',
536
+ st.memesMode === 'own' || st.memesMode === 'both'
537
+ ? '如果你想附一张贴图,就在最后另起一行写 ||图:名字。能挑的名字有:' +
538
+ readOwnMemes()
539
+ .map((it) => it.label)
540
+ .filter(Boolean)
541
+ .join('、') +
542
+ '。不想配就别写。'
543
+ : '',
475
544
  ]
476
545
  .filter(Boolean)
477
546
  .join('\n\n');
@@ -483,7 +552,7 @@ async function handle(req, res, ctx) {
483
552
  return sendJson(res, 502, { error: '生成失败:' + (e instanceof Error ? e.message : String(e)) });
484
553
  }
485
554
  LAST_ACTIVE.at = Date.now();
486
- const parsed = splitChoices(reply || '');
555
+ const parsed = splitMeta(reply || '');
487
556
  let line = parsed.text;
488
557
  let choices = st.choices ? parsed.choices : [];
489
558
  if (line.length > 240) line = line.slice(0, 240);
@@ -492,12 +561,19 @@ async function handle(req, res, ctx) {
492
561
  }
493
562
  if (!line && !choices.length) return sendJson(res, 200, { ok: true, reply: '', id });
494
563
  const moodNow = guessMood(line);
495
- const image = st.memesMode !== 'off' && Math.random() < 0.35 ? pickImage(st.memesMode, moodNow) : null;
564
+ const image =
565
+ st.memesMode === 'off'
566
+ ? null
567
+ : parsed.imageLabel
568
+ ? pickImage(st.memesMode, moodNow, parsed.imageLabel)
569
+ : Math.random() < 0.35
570
+ ? pickImage(st.memesMode, moodNow)
571
+ : null;
496
572
 
497
573
  writeChat(id, hist.concat([
498
574
  { role: 'user', text, at: Date.now() },
499
575
  { role: 'assistant', text: line, at: Date.now() },
500
- ]));
576
+ ]), store.summary);
501
577
  return sendJson(res, 200, { ok: true, reply: line, choices, image: image || undefined, id });
502
578
  }
503
579
 
@@ -567,6 +643,11 @@ async function handle(req, res, ctx) {
567
643
  return sendJson(res, 200, { seq: outSeq, items: OUTBOX.filter((m) => m.seq > since) });
568
644
  }
569
645
 
646
+ // 手机上一页够用:她的脸 + 一个输入框
647
+ if (path === '/chat' || path === '/mobile') {
648
+ return sendFile(res, join(ASSETS, 'mobile.html'), MIME['.html']);
649
+ }
650
+
570
651
  // 人设卡页
571
652
  if (path === '/persona' || path === '/persona.html') {
572
653
  return sendFile(res, join(ASSETS, 'persona.html'), MIME['.html']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-live2dwaifu",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、视线跟着鼠标、点她有反应、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹;支持自定义 .exp3.json 表情和 .motion3.json 动作,卡片能拖能缩,背景可换。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",