dsh-live2dwaifu 0.7.1 → 0.8.1

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,14 @@
33
33
 
34
34
  **也能直接跟她说**:卡片底下有个小框,打一句回车,她回你。这条线**不进你的聊天记录**——后台直接问模型,往来只存在 `models/<id>/chat.json` 里。她说的话按人设卡现场写,不是复读;人设卡就是 `models/<id>/meta.json` 里的 `persona` 字段,没写就用默认那份(若妍)。
35
35
 
36
+ **她记得住,而且分两层记**:一层是最近二十来句原文,一层是攒出来的「关于你的事实」——名字、作息、口味、忌讳、答应过的事,聊得越久越攒得住,不用你重新讲一遍。攒满三十句就把前面压成小结,不丢。
37
+
38
+ **她说的话会过一道筛子**:套话会被删掉——“好的”“我理解你的感受”“希望帮到你”“还有什么需要”这些,出来就没。最多留两三句,允许她只回两个字。
39
+
40
+ **她知道几点、也知道自己此刻在干什么**:提示词里会让她按人设推断这个时间点的处境——早上在实验室、半夜还没睡——话里自然带着那个味道,不用你提醒。
41
+
42
+ **手机上一页就够**:手机连同一个地址,打开 `/dsh-live2d/chat`,就是她的脸加一个输入框,不用看整个 DSH。
43
+
36
44
  **她会自己开口**:十几分钟没人理她,她就找个话头说一句。嫌吵就把 `~/.dsh/dsh-live2d/config.json` 里的 `proactive` 设成 `false`。
37
45
 
38
46
  **给别人留的口子**:im 机器人、别的插件、随手写的脚本,都能让她说话。
@@ -102,6 +110,24 @@ frame.contentWindow.postMessage({ type: 'dsh-live2d/manage' }, '*');
102
110
  | POST | `/dsh-live2d/api/commit` | `{id,name}` 扫一遍 model3 并选中 |
103
111
  | POST | `/dsh-live2d/api/delete` | `{id}` 删掉 |
104
112
 
113
+ ## 写人设卡,请写细一点
114
+
115
+ 她回得像不像人,全看那段字。别只写“一个可爱的女孩”——那样只会得到一段没味道的话。
116
+
117
+ 值得写进去的:她是谁、几岁、做什么的、说话什么调子(短句还是长句、用不用标点)、有没有口头禅、怕什么、喜欢什么、跟你是什么关系、平时怎么称呼你、这个时间点通常在干嘛。
118
+
119
+ `models/<id>/meta.json` 里的 `persona` 想写多长写多长,写不写都行,不写就是默认那份。也可以点标题栏的 **✎** 在网页里改。
120
+
121
+ ## 把她的表情包给 dsh-meme 用
122
+
123
+ 自带那二十张是标准图库格式,导一次就能丢给 `dsh-meme`:
124
+
125
+ ```bash
126
+ python tools/export-dsh-meme.py
127
+ ```
128
+
129
+ 产物在 `dsh-live2d/dsh-meme-pack/xialing-001/`(含 `manifest.json`、`memes/<tag>/`、`index.db`)。整个目录丢进 `~/.dsh/profiles/desktop/node_modules/dsh-meme/memes/` 下面就认了。
130
+
105
131
  ## 东西都放哪了
106
132
 
107
133
  ```
@@ -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
@@ -185,21 +185,118 @@ function personaOf(meta) {
185
185
  return p || DEFAULT_PERSONA;
186
186
  }
187
187
 
188
- function readChat(id) {
188
+ function readChatStore(id) {
189
189
  try {
190
190
  const d = JSON.parse(readFileSync(join(MODELS, id, 'chat.json'), 'utf8'));
191
- 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
+ facts: Array.isArray(d.facts) ? d.facts : [],
195
+ };
192
196
  } catch {
193
- return [];
197
+ return { messages: [], summary: '', facts: [] };
194
198
  }
195
199
  }
196
200
 
197
- function writeChat(id, messages) {
201
+ function readChat(id) {
202
+ return readChatStore(id).messages;
203
+ }
204
+
205
+ function writeChat(id, messages, summary, facts) {
198
206
  try {
199
- writeFileSync(join(MODELS, id, 'chat.json'), JSON.stringify({ messages: messages.slice(-24) }, null, 2), 'utf8');
207
+ const prev = readChatStore(id);
208
+ const data = {
209
+ messages: messages.slice(-40),
210
+ summary: summary === undefined ? prev.summary : summary,
211
+ facts: (facts === undefined ? prev.facts : facts).slice(-60),
212
+ };
213
+ writeFileSync(join(MODELS, id, 'chat.json'), JSON.stringify(data, null, 2), 'utf8');
200
214
  } catch {}
201
215
  }
202
216
 
217
+ /** 把压缩出来的两段拆开:小结 + 关于他的事实 */
218
+ function parseDigest(raw) {
219
+ let text = String(raw || '').trim();
220
+ let summary = text;
221
+ const facts = [];
222
+ const at = text.search(/事实\s*[::]/);
223
+ if (at >= 0) {
224
+ summary = text.slice(0, at).replace(/^小结\s*[::]?\s*/, '').trim();
225
+ const body = text.slice(at).replace(/^事实\s*[::]\s*/, '');
226
+ for (const line of body.split(/\n+/)) {
227
+ const item = line.replace(/^[-*·\s]+/, '').trim();
228
+ if (item.length >= 2 && item.length <= 60) facts.push(item);
229
+ }
230
+ }
231
+ summary = summary.replace(/^小结\s*[::]?\s*/, '').trim();
232
+ return { summary, facts: facts.slice(0, 12) };
233
+ }
234
+
235
+ /** 攒够了就把前二十句压成小结 + 事实,人物记得住更早的事 */
236
+ async function compactChat(slot, store) {
237
+ const head = store.messages.slice(0, 20);
238
+ const tail = store.messages.slice(20);
239
+ if (!head.length) return store;
240
+ const text = head.map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? '')).join('\n');
241
+ const prompt =
242
+ '下面是一段聊天记录。请用两段输出:\n' +
243
+ '第一段以「小结:」开头,把这段聊了什么压成不超过一百字的第三人称概述,只留事实和情绪走向。\n' +
244
+ '第二段以「事实:」开头,逐行列出从对话里能确定的、关于「他」的稳定信息(名字、作息、口味、忌讳、答应过的事、反复提到的人或物),' +
245
+ '每条一行、以短横线开头、不超过二十字;不确定的不要编。没有就写「事实:无」。\n\n' + text;
246
+ let raw = '';
247
+ try {
248
+ raw = await generate(slot, prompt, 900);
249
+ } catch {
250
+ raw = '';
251
+ }
252
+ const digest = parseDigest(raw);
253
+ const summary = [store.summary, digest.summary].filter(Boolean).join('\n').slice(-1200);
254
+ const merged = [...(store.facts || [])];
255
+ for (const f of digest.facts) {
256
+ if (f === '无') continue;
257
+ if (!merged.some((x) => x === f)) merged.push(f);
258
+ }
259
+ return { messages: tail, summary, facts: merged.slice(-60) };
260
+ }
261
+
262
+ /** 跟他此刻说的话最贴的几条事实 */
263
+ function pickFacts(facts, text) {
264
+ if (!facts || !facts.length) return [];
265
+ const chars = new Set(String(text || '').replace(/[\s,。!?、,.!?]/g, ''));
266
+ const scored = facts.map((f) => {
267
+ let hit = 0;
268
+ for (const ch of String(f)) if (chars.has(ch)) hit += 1;
269
+ return { f, hit };
270
+ });
271
+ scored.sort((a, b) => b.hit - a.hit);
272
+ const top = scored.filter((s) => s.hit > 0).slice(0, 6).map((s) => s.f);
273
+ return top.length ? top : facts.slice(-6);
274
+ }
275
+
276
+ /** 去 AI 味:删套话、砍尾巴、压重复 */
277
+ const AI_WORDS = [
278
+ /^(好的|当然|没问题|明白了|收到)[,,。!!、\s]+/,
279
+ /(希望|但愿)[^。!?]{0,12}帮(到|上)你[^。!?]*[。!!]?/g,
280
+ /还有什么(需要|想要|问题|可以帮|能帮)[^。!?]*[。!?]?/g,
281
+ /(总之|总的来说|综上|先说结论|首先|其次|另外|值得一提的是)[,,::]?/g,
282
+ /作为(一个)?(AI|人工智能|语言模型)[^。!?]*[。!?]?/g,
283
+ /(?:^|[。!?])(我理解你的感受|我很抱歉听到|这一定很不容易)[^。!?]*[。!?]?/g,
284
+ ];
285
+
286
+ function humanize(input) {
287
+ let t = String(input || '');
288
+ for (const re of AI_WORDS) t = t.replace(re, '');
289
+ t = t
290
+ .replace(/[ \t]+/g, ' ')
291
+ .replace(/[。!?]{2,}/g, (m) => m[0])
292
+ .replace(/…{3,}/g, '……')
293
+ .replace(/[,,]\s*$/g, '')
294
+ .trim();
295
+ const parts = t.split(/(?<=[。!?!?~])/).filter((s) => s.trim());
296
+ if (parts.length > 3) t = parts.slice(0, 3).join('');
297
+ return t.trim();
298
+ }
299
+
203
300
  /* ── 出口队列:谁能说话,都往这儿扔 ───────────────────────── */
204
301
  const OUTBOX = [];
205
302
  const LAST_ACTIVE = { at: Date.now() };
@@ -286,7 +383,7 @@ async function maybeTalk(ctx) {
286
383
  return;
287
384
  }
288
385
  if (!text) return;
289
- const line = text.length > 120 ? text.slice(0, 120) : text;
386
+ const line = humanize(text).slice(0, 120);
290
387
  pushOut({ text: line, from: 'proactive' });
291
388
  writeChat(id, readChat(id).concat([{ role: 'assistant', text: line, at: Date.now() }]));
292
389
  }
@@ -482,7 +579,15 @@ async function handle(req, res, ctx) {
482
579
  if (!slot) return sendJson(res, 503, { error: '模型接口不可用' });
483
580
 
484
581
  const st = settingsOf(readConfig());
485
- const hist = st.memory ? readChat(id) : [];
582
+ let store = st.memory ? readChatStore(id) : { messages: [], summary: '' };
583
+ if (st.memory && store.messages.length > 30) {
584
+ try {
585
+ store = await compactChat(slot, store);
586
+ writeChat(id, store.messages, store.summary, store.facts);
587
+ } catch {}
588
+ }
589
+ const facts = st.memory ? pickFacts(store.facts, text) : [];
590
+ const hist = store.messages;
486
591
  const lines = hist
487
592
  .slice(-8)
488
593
  .map((m) => (m.role === 'user' ? '他:' : '你:') + String(m.text ?? ''))
@@ -490,10 +595,14 @@ async function handle(req, res, ctx) {
490
595
  const prompt = [
491
596
  personaOf(meta),
492
597
  meta.isolation === false ? '' : ISOLATION,
493
- '现在是 ' + nowText() + '。',
598
+ '现在是 ' + nowText() + '。按你这个人设,这个时间点你多半在做什么?让这句话带上那个处境,但不要说破。',
599
+ store.summary ? '之前聊过的,你还记得:\n' + store.summary : '',
600
+ facts.length ? '关于他,你记得这些:\n' + facts.map((f) => '- ' + f).join('\n') : '',
494
601
  lines ? '你们之前说的:\n' + lines : '',
495
602
  '他说:' + text,
496
- '回他一句。只输出这句话本身:一到三句,短,口语,像发消息。不要解释,不要加引号,不要旁白,不要括号里的动作。',
603
+ '像人发消息那样回他:一到两句,短;不总结、不分点、不讲道理、不解释心情;' +
604
+ '不说“我理解你”“希望能帮到你”“还有什么需要”这类话;他没问就别给建议;可以只回两个字。' +
605
+ '只输出这句话本身:不要引号,不要旁白,不要括号里的动作。',
497
606
  st.choices ? '如果你想让他挑,可以在最后另起一行写 ||选项一|选项二,最多三项。' : '',
498
607
  st.memesMode === 'own' || st.memesMode === 'both'
499
608
  ? '如果你想附一张贴图,就在最后另起一行写 ||图:名字。能挑的名字有:' +
@@ -514,6 +623,7 @@ async function handle(req, res, ctx) {
514
623
  return sendJson(res, 502, { error: '生成失败:' + (e instanceof Error ? e.message : String(e)) });
515
624
  }
516
625
  LAST_ACTIVE.at = Date.now();
626
+ reply = humanize(reply);
517
627
  const parsed = splitMeta(reply || '');
518
628
  let line = parsed.text;
519
629
  let choices = st.choices ? parsed.choices : [];
@@ -535,7 +645,7 @@ async function handle(req, res, ctx) {
535
645
  writeChat(id, hist.concat([
536
646
  { role: 'user', text, at: Date.now() },
537
647
  { role: 'assistant', text: line, at: Date.now() },
538
- ]));
648
+ ]), store.summary, store.facts);
539
649
  return sendJson(res, 200, { ok: true, reply: line, choices, image: image || undefined, id });
540
650
  }
541
651
 
@@ -605,6 +715,11 @@ async function handle(req, res, ctx) {
605
715
  return sendJson(res, 200, { seq: outSeq, items: OUTBOX.filter((m) => m.seq > since) });
606
716
  }
607
717
 
718
+ // 手机上一页够用:她的脸 + 一个输入框
719
+ if (path === '/chat' || path === '/mobile') {
720
+ return sendFile(res, join(ASSETS, 'mobile.html'), MIME['.html']);
721
+ }
722
+
608
723
  // 人设卡页
609
724
  if (path === '/persona' || path === '/persona.html') {
610
725
  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.1",
3
+ "version": "0.8.1",
4
4
  "description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、视线跟着鼠标、点她有反应、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹;支持自定义 .exp3.json 表情和 .motion3.json 动作,卡片能拖能缩,背景可换。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",