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 +23 -0
- package/assets/viewer.html +12 -0
- package/assets/viewer.js +132 -15
- package/lib/client.js +52 -3
- package/lib/index.js +241 -32
- package/package.json +1 -1
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
|
package/assets/viewer.html
CHANGED
|
@@ -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,
|
|
704
|
-
return digit + 22 + 75 * (digit < 26) - ((
|
|
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
|
|
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
|
-
|
|
10007
|
+
flag2 = false;
|
|
10008
10008
|
break;
|
|
10009
10009
|
}
|
|
10010
10010
|
}
|
|
10011
|
-
|
|
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
|
|
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
|
-
|
|
31611
|
+
flag2 = false;
|
|
31612
31612
|
break;
|
|
31613
31613
|
}
|
|
31614
31614
|
}
|
|
31615
|
-
if (
|
|
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
|
-
|
|
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")
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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:
|
|
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
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "给 DSH 网页右下角请一个 Live2D 角色:会眨眼、会呼吸、视线跟着鼠标、点她有反应、说话时嘴动、心情变了眉毛跟着动。模型不用内置——谁装谁放自己的文件夹;支持自定义 .exp3.json 表情和 .motion3.json 动作,卡片能拖能缩,背景可换。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|