dsh-chubby-whale 1.1.59 → 1.1.61
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/CHANGELOG.md +51 -0
- package/dist/client.js +95 -37
- package/dist/host/body.js +10 -1
- package/dist/host/data/cloud.js +57 -1
- package/dist/host/data/local.js +75 -1
- package/dist/host/http.js +36 -10
- package/dist/host/respond.js +18 -0
- package/dist/host/routes/culture.js +29 -5
- package/dist/host/routes/poem-titles.js +46 -10
- package/dist/host/routes/story.js +3 -2
- package/dist/index.js +1 -1
- package/dist/shared/sse.js +10 -3
- package/docs//345/217/221/345/270/203/350/247/204/350/214/203.md +7 -7
- package/docs//345/235/221.md +26 -1
- package/docs//345/271/266/345/217/221/347/272/252/345/276/213.md +6 -1
- package/docs//345/276/205/351/207/215/345/220/257/347/224/237/346/225/210.md +23 -0
- package/docs//350/267/250/345/271/263/345/217/260/344/270/216/345/210/206/345/267/245.md +7 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,57 @@
|
|
|
4
4
|
|
|
5
5
|
## [未发布]
|
|
6
6
|
|
|
7
|
+
## [1.1.61] - 2026-11-07
|
|
8
|
+
|
|
9
|
+
### 修复(B89 宿主 HTTP 层分身)
|
|
10
|
+
- **413 永远送不到客户端**:`readBody` 发现超限先 `abortRequest()`→`destroy()` 再抛错,连接被主动断掉,`sendTooLarge` 写的 413 根本没机会发出(真实 socket 探针:声明超限与 chunked 超限**全部** `ECONNRESET`、0 个 413)。现在区分两种语义:声明超限=不读流不断连,流式超限=1MB 预算内 drain 后再判
|
|
11
|
+
- **UTF-8 BOM 让合法 JSON 变成空请求**:`readJsonBody` 未剥 BOM,`JSON.parse` 必抛 → `body={}`;带 BOM 的备份包原本被判 400 `BAD_BODY`
|
|
12
|
+
- **二次写响应抛 `ERR_HTTP_HEADERS_SENT`**:宿主 webserver 在 `headersSent` 后只 `destroy()` → 客户端丢掉已写好的响应;四个 writer 统一加短路
|
|
13
|
+
|
|
14
|
+
### 修复(B89 客户端网络层分身)
|
|
15
|
+
- **非对象 SSE 帧进 events**:`data: null` 会让 `chat.ts` 读 `obj.error` 抛 TypeError → 白回退一轮 `/chat`(白烧一次模型);现在只收对象帧,其余计 `invalid`
|
|
16
|
+
- **恢复快捷键抢别的键**:`keyCode=87` 兜底不看 `key`,`Ctrl+Shift+Z` 会被当成恢复快捷键
|
|
17
|
+
|
|
18
|
+
### 修复(B89 纯函数工具层分身)
|
|
19
|
+
- **切句器卡死**:增量以句末标点开头时(`。你好。世界。`)既不吐也不前进,后面整段攒到 `maxLen` 才出
|
|
20
|
+
- **书名劫持普通陈述**:「我在看三国」「三国好看」被判成说书点播
|
|
21
|
+
- **否定句反着做**:「不要讲故事」→ 真的讲起来了;「讲个故事吗。」不认提问
|
|
22
|
+
- **脏 fallback 透传 TTS**:`poemRate('胡说')` 原样进 TTS rate
|
|
23
|
+
- **`lock(0)` 闭麦一分钟**:空文本估算 0 被 `|| 60000` 兜成 60s;且 `expire()` 与 `active()` 差 1ms 界线(`t===until` 时不释放)
|
|
24
|
+
- **对仗分类静默错档**:`pickCouplet(null, ' 春联')` 会落「书院」
|
|
25
|
+
|
|
26
|
+
### 修复(主代理)
|
|
27
|
+
- **`/chat` 超时被报成普通失败**:只认 `AbortError`/20/2,`AbortSignal.timeout()` 的 `TimeoutError`(code 23)漏判(`story.ts` 早已补)
|
|
28
|
+
- **endpoint 切换重复写盘**:先自己写一次再让实例 setter 写一次,setter 抛错时落盘值与实例值分叉;改为有实例时只走 setter
|
|
29
|
+
- **多文件拖入静默丢弃**:只取第一张,其余文件没有任何提示;现在先提示「一次一张(共 N 个)」再处理第一张
|
|
30
|
+
- 文档:坑 49(读体超限的两种语义、新路由必须用 `sendTooLarge` 收尾);`story.ts` 的超限注释改为新语义
|
|
31
|
+
|
|
32
|
+
### 测试
|
|
33
|
+
- 新增 `test-host-http.mjs`(32)、`test-net-client.mjs`(122+4 变异体)、`test-utils-pure.mjs`(153)、`test-multidrop.mjs`(10),均接入 `npm test`
|
|
34
|
+
- 全链 **3258 项 0 失败**(`node tools/test-count.mjs`:45 个套件,失败 0,未识别 0)
|
|
35
|
+
|
|
36
|
+
## [1.1.60] - 2026-11-07
|
|
37
|
+
|
|
38
|
+
### 修复(B87 数据端口收口:路由不再直读 data/)
|
|
39
|
+
- **`/poem-titles` 与 `/melodies`、`/melody` 直读 `data/`**:违反 ADR 0002「路由只经端口取数据」,且 `poem-titles` 的 handler 连 `dataPort` 参数都没有。现在 `DataPort` 增**可选**方法 `readPoemTitles()` / `readMelodies()`:本地带 mtime 缓存 + BOM 容错 + 坏 JSON 返回 null 并留痕;云端声明就拉、未声明/网络坏时回退本地
|
|
40
|
+
- 路由口径:**端口存在即权威**(返回 null 也不回读文件),只有端口缺方法时才走文件回退(回退分支注释写明移除条件);`/melody` 响应字段完全未变
|
|
41
|
+
- 装配漏注入:`index.ts` 给 `poem-titles` 补上 `dataPort`(此前不注入等于永远走回退)
|
|
42
|
+
- 过时文案:`test-data-layer` 端口面断言 6 项→8 项;`test-content-routes` 的「已知缺口」改为「已收口」
|
|
43
|
+
|
|
44
|
+
### 修复(B88 念诗回调与待命出声)
|
|
45
|
+
- **同一使用方连念两首,旧诗的 onDone 会清新诗的锁**:`client.ts` 的 `narrateOwner` 是**按使用方**分的,菜单「念首诗」两次共用同一句柄、ownerId 不变,挡不住。现在 `culture.ts` 加 per-call 令牌 `poemGen`:只有最新一首的 onDone 能停进度、清锁、说「念完啦」,且同一次收尾只放行一次
|
|
46
|
+
- **`sayLine` 用 `bridge.get()`**:待命关着刷新后 `get()` 为 null,开场问候/情绪台词只有气泡没声(而念诗/说书/提醒走 `ensure()` 一直有声)。改为 `ensure()`,与其它朗读路径一致;真正管静音的是「语音朗读」开关(关掉后 ensure/spoken 都不动)
|
|
47
|
+
- 念诗 `speak` 同步抛错时补 `release()`(与 `actions/content.ts` 同口径),不再等 5 分钟叙述锁自愈
|
|
48
|
+
- 语音实例的卸载回收不再依赖 `earOn`:`ensure()` 可能在待命关着时建实例,原来那种情况 SPA 卸载不会 dispose
|
|
49
|
+
|
|
50
|
+
### 变更(B86 主代理)
|
|
51
|
+
- `VoiceStats.asrBusyDropped`:ASR「忙则丢」的丢段次数计入统计(原来只在 diag 流里可见)
|
|
52
|
+
- 文档:《坑》补坑 47(叙述锁只有一份 / 持有者令牌 / `narrateOverdue` 读法)、坑 48(ASR 无单飞无纪元与取舍);《并发纪律》更正「纪元只覆盖对话」→ 覆盖对话与 ASR,规则 5 补 `clearNarrate` 只认持有者
|
|
53
|
+
|
|
54
|
+
### 测试
|
|
55
|
+
- 新增 `test-dataport-poem.mjs`(56)、`test-poem-narration.mjs`(34),均接入 `npm test`
|
|
56
|
+
- 全链 **2936 项 0 失败**(`node tools/test-count.mjs`:41 个套件,失败 0,未识别 0)
|
|
57
|
+
|
|
7
58
|
## [1.1.59] - 2026-11-07
|
|
8
59
|
|
|
9
60
|
### 修复(B82 装配层分身:云端 TTS 从未接线)
|
package/dist/client.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
// src/client/version.ts
|
|
17
|
-
var VERSION = "1.1.
|
|
17
|
+
var VERSION = "1.1.61";
|
|
18
18
|
|
|
19
19
|
// src/client/styles.ts
|
|
20
20
|
var W = 240;
|
|
@@ -352,10 +352,14 @@
|
|
|
352
352
|
var DEFAULT_CHAT_RATE = "-20%";
|
|
353
353
|
var DEFAULT_STORY_RATE = "-35%";
|
|
354
354
|
var DEFAULT_POEM_RATE = "-25%";
|
|
355
|
+
function validPercent(v) {
|
|
356
|
+
const s = String(v == null ? "" : v).trim();
|
|
357
|
+
return /^-(?:[1-9]\d?)%$/.test(s) ? s : "";
|
|
358
|
+
}
|
|
355
359
|
function readPercentRate(key, fallback) {
|
|
356
360
|
try {
|
|
357
|
-
const s =
|
|
358
|
-
if (
|
|
361
|
+
const s = validPercent(localStorage.getItem(key));
|
|
362
|
+
if (s) return s;
|
|
359
363
|
} catch (e) {
|
|
360
364
|
}
|
|
361
365
|
return fallback;
|
|
@@ -364,7 +368,7 @@
|
|
|
364
368
|
return readPercentRate("chubby-story-rate", DEFAULT_STORY_RATE);
|
|
365
369
|
}
|
|
366
370
|
function poemRate(fallback = DEFAULT_POEM_RATE) {
|
|
367
|
-
return readPercentRate("chubby-poem-rate", fallback);
|
|
371
|
+
return readPercentRate("chubby-poem-rate", validPercent(fallback) || DEFAULT_POEM_RATE);
|
|
368
372
|
}
|
|
369
373
|
function chatRate() {
|
|
370
374
|
try {
|
|
@@ -603,6 +607,7 @@
|
|
|
603
607
|
function createSentenceBuffer(maxLen = 40) {
|
|
604
608
|
const limit = Math.max(2, Number(maxLen) || 40);
|
|
605
609
|
let tail = "";
|
|
610
|
+
let lead = "";
|
|
606
611
|
function take(force) {
|
|
607
612
|
const out = [];
|
|
608
613
|
for (; ; ) {
|
|
@@ -611,18 +616,25 @@
|
|
|
611
616
|
if (!tail) break;
|
|
612
617
|
const m = tail.match(/[\s\S]*?[。!?!?;;\n]/);
|
|
613
618
|
if (m && m[0].length >= 2) {
|
|
614
|
-
const seg = m[0].trim();
|
|
619
|
+
const seg = (lead + m[0]).trim();
|
|
620
|
+
lead = "";
|
|
615
621
|
tail = tail.slice(m[0].length);
|
|
616
622
|
if (seg) out.push(seg);
|
|
617
623
|
continue;
|
|
618
624
|
}
|
|
619
|
-
if (
|
|
620
|
-
|
|
621
|
-
tail =
|
|
622
|
-
|
|
625
|
+
if (m) {
|
|
626
|
+
lead += m[0];
|
|
627
|
+
tail = tail.slice(m[0].length);
|
|
628
|
+
continue;
|
|
623
629
|
}
|
|
624
630
|
break;
|
|
625
631
|
}
|
|
632
|
+
if (force || tail.length + lead.length >= limit) {
|
|
633
|
+
const seg = (lead + tail).trim();
|
|
634
|
+
lead = "";
|
|
635
|
+
tail = "";
|
|
636
|
+
if (seg) out.push(seg);
|
|
637
|
+
}
|
|
626
638
|
return out;
|
|
627
639
|
}
|
|
628
640
|
return {
|
|
@@ -1256,6 +1268,7 @@
|
|
|
1256
1268
|
skippedSilent: 0,
|
|
1257
1269
|
uploaded: 0,
|
|
1258
1270
|
asrFail: 0,
|
|
1271
|
+
asrBusyDropped: 0,
|
|
1259
1272
|
asrRetry: 0,
|
|
1260
1273
|
asrTexts: 0,
|
|
1261
1274
|
heard: 0,
|
|
@@ -1770,6 +1783,10 @@
|
|
|
1770
1783
|
return;
|
|
1771
1784
|
}
|
|
1772
1785
|
if (asrBusy) {
|
|
1786
|
+
try {
|
|
1787
|
+
stats.asrBusyDropped = (stats.asrBusyDropped || 0) + 1;
|
|
1788
|
+
} catch (e) {
|
|
1789
|
+
}
|
|
1773
1790
|
try {
|
|
1774
1791
|
opts.onDiag && opts.onDiag({ stage: "asr-busy-drop", hint: "上一段还在识别,这一段丢弃(不排队)" });
|
|
1775
1792
|
} catch (e) {
|
|
@@ -4418,7 +4435,8 @@
|
|
|
4418
4435
|
var COUPLET_CATS = ["书院", "格言", "谐趣", "名胜", "书房", "春联", "寿联", "景观", "中药", "叠字", "数字"];
|
|
4419
4436
|
deepFreeze(COUPLET_CATS);
|
|
4420
4437
|
function pickCouplet(excludeUpper, cat) {
|
|
4421
|
-
const
|
|
4438
|
+
const key = cat == null ? "" : String(cat).trim();
|
|
4439
|
+
const byCat = key ? COUPLETS.filter((c) => c.cat === key) : COUPLETS;
|
|
4422
4440
|
const base = byCat.length > 0 ? byCat : COUPLETS;
|
|
4423
4441
|
const pool = base.length > 1 && excludeUpper ? base.filter((c) => c.upper !== excludeUpper) : base;
|
|
4424
4442
|
const src = pool.length > 0 ? pool : base;
|
|
@@ -4434,6 +4452,7 @@
|
|
|
4434
4452
|
recentStarts.set(key, now);
|
|
4435
4453
|
return true;
|
|
4436
4454
|
}
|
|
4455
|
+
var poemGen = 0;
|
|
4437
4456
|
function recitePoem(host, poem) {
|
|
4438
4457
|
if (!beginOnce("poem")) {
|
|
4439
4458
|
host.bubble("正在念呢,稍等~", 2e3);
|
|
@@ -4451,21 +4470,28 @@
|
|
|
4451
4470
|
host.bubble("念诗·" + poem.title + "(" + author + ")" + (totalLines > 1 ? "· 共 " + totalLines + " 句" : ""), 15e3);
|
|
4452
4471
|
host.talkFor(6e3);
|
|
4453
4472
|
const speech = intro + collapsePoemBody(body);
|
|
4473
|
+
const my = ++poemGen;
|
|
4454
4474
|
host.narrate(estimateSpeakMs(speech));
|
|
4455
4475
|
host.say(host.persona.line("poemStart"), 4e3);
|
|
4476
|
+
let released = false;
|
|
4477
|
+
const release = () => {
|
|
4478
|
+
if (released) return;
|
|
4479
|
+
released = true;
|
|
4480
|
+
if (my !== poemGen) return;
|
|
4481
|
+
try {
|
|
4482
|
+
stopProgress();
|
|
4483
|
+
} catch (e) {
|
|
4484
|
+
}
|
|
4485
|
+
host.clearNarrate();
|
|
4486
|
+
host.say(host.persona.line("poemDone"), 6e3, { rate: "-8%", pitch: "+15Hz" });
|
|
4487
|
+
};
|
|
4456
4488
|
try {
|
|
4457
4489
|
host.speak(speech, {
|
|
4458
4490
|
rate: poemRate(host.narrationRate()),
|
|
4459
|
-
onDone:
|
|
4460
|
-
try {
|
|
4461
|
-
stopProgress();
|
|
4462
|
-
} catch (e) {
|
|
4463
|
-
}
|
|
4464
|
-
host.clearNarrate();
|
|
4465
|
-
host.say(host.persona.line("poemDone"), 6e3, { rate: "-8%", pitch: "+15Hz" });
|
|
4466
|
-
}
|
|
4491
|
+
onDone: release
|
|
4467
4492
|
});
|
|
4468
4493
|
} catch (e) {
|
|
4494
|
+
release();
|
|
4469
4495
|
}
|
|
4470
4496
|
}
|
|
4471
4497
|
function finishSpeech(host, doneText) {
|
|
@@ -5552,8 +5578,8 @@
|
|
|
5552
5578
|
const v = host.voice();
|
|
5553
5579
|
const cur = v && v.endpointInfo ? v.endpointInfo().mode : readEndpointMode();
|
|
5554
5580
|
const next = cur === "ape" ? "classic" : "ape";
|
|
5555
|
-
writeEndpointMode(next);
|
|
5556
5581
|
if (v && v.setEndpointMode) v.setEndpointMode(next);
|
|
5582
|
+
else writeEndpointMode(next);
|
|
5557
5583
|
const real = v && v.endpointInfo ? v.endpointInfo().mode : next;
|
|
5558
5584
|
host.bubble(real === "ape" ? "端点检测:自适应(试用)" : "端点检测:经典(稳定)", 4e3);
|
|
5559
5585
|
} catch (e) {
|
|
@@ -6162,7 +6188,8 @@
|
|
|
6162
6188
|
let until = 0;
|
|
6163
6189
|
return {
|
|
6164
6190
|
lock(ms2) {
|
|
6165
|
-
const
|
|
6191
|
+
const n = ms2 == null ? NaN : Number(ms2);
|
|
6192
|
+
const dur = Math.max(NARRATE_MIN_MS, Math.min(Number.isNaN(n) ? 6e4 : n, NARRATE_MAX_MS));
|
|
6166
6193
|
until = Date.now() + dur;
|
|
6167
6194
|
},
|
|
6168
6195
|
clear() {
|
|
@@ -6178,7 +6205,7 @@
|
|
|
6178
6205
|
},
|
|
6179
6206
|
expire(now) {
|
|
6180
6207
|
const t = typeof now === "number" ? now : Date.now();
|
|
6181
|
-
if (!until || t
|
|
6208
|
+
if (!until || t < until) return false;
|
|
6182
6209
|
until = 0;
|
|
6183
6210
|
return true;
|
|
6184
6211
|
}
|
|
@@ -6858,6 +6885,16 @@
|
|
|
6858
6885
|
];
|
|
6859
6886
|
deepFreeze(BOOK_ALIAS);
|
|
6860
6887
|
var BARE_STRIP = /[\s。,!?、,.!?…~~]/g;
|
|
6888
|
+
var BARE_TAIL_PARTICLES = "吧啊呀呢么嘛哈哦嗯啦";
|
|
6889
|
+
function isBareBookName(bare, book, alias) {
|
|
6890
|
+
if (!bare) return false;
|
|
6891
|
+
if (bare === book) return true;
|
|
6892
|
+
const core = BARE_TAIL_PARTICLES.indexOf(bare.slice(-1)) >= 0 ? bare.slice(0, -1) : bare;
|
|
6893
|
+
if (core === book) return true;
|
|
6894
|
+
const m = alias.exec(core);
|
|
6895
|
+
return !!m && m.index === 0 && core.length <= m[0].length + 1;
|
|
6896
|
+
}
|
|
6897
|
+
var NEGATED_ASK = /(不|别|甭|勿|无需)(要|用|想|再|许|必)?\s*(讲|说|念|背|来|放|听|唱)/;
|
|
6861
6898
|
function matchPoemTitle(text) {
|
|
6862
6899
|
const t = String(text || "");
|
|
6863
6900
|
if (!t) return "";
|
|
@@ -6867,12 +6904,13 @@
|
|
|
6867
6904
|
return "";
|
|
6868
6905
|
}
|
|
6869
6906
|
function isQuestion(text) {
|
|
6870
|
-
return /[??]|为什么|怎么|如何|什么|谁|哪[儿里]
|
|
6907
|
+
return /[??]|为什么|怎么|如何|什么|谁|哪[儿里]?|多少|几点|几号|是不是|能不能|要不要|吗[。..!!…~~]*\s*$|呢[。..!!…~~]*\s*$/.test(String(text || ""));
|
|
6871
6908
|
}
|
|
6872
6909
|
function detectIntent(rawText) {
|
|
6873
6910
|
const t = String(rawText || "").trim();
|
|
6874
6911
|
if (!t || t.length > 30) return null;
|
|
6875
6912
|
if (isQuestion(t)) return null;
|
|
6913
|
+
if (NEGATED_ASK.test(t)) return null;
|
|
6876
6914
|
if (poemTitles().length === 0) ensurePoemTitles();
|
|
6877
6915
|
const byTitle = matchPoemTitle(t);
|
|
6878
6916
|
if (byTitle) {
|
|
@@ -6885,17 +6923,17 @@
|
|
|
6885
6923
|
const asksCulture = /讲|说|来|听|教|学|点|考/.test(t);
|
|
6886
6924
|
if (asksCulture) return { kind: "culture", sub: /成语/.test(t) ? "idiom" : "chengyu" };
|
|
6887
6925
|
}
|
|
6888
|
-
let
|
|
6889
|
-
for (const
|
|
6890
|
-
if (
|
|
6891
|
-
|
|
6926
|
+
let hit = null;
|
|
6927
|
+
for (const pair of BOOK_ALIAS) {
|
|
6928
|
+
if (pair[0].test(t)) {
|
|
6929
|
+
hit = pair;
|
|
6892
6930
|
break;
|
|
6893
6931
|
}
|
|
6894
6932
|
}
|
|
6895
|
-
if (
|
|
6933
|
+
if (hit) {
|
|
6896
6934
|
const bare = t.replace(BARE_STRIP, "");
|
|
6897
|
-
const asksBook = /讲|说书|听|来(一)?段|来(一)?回/.test(t) || bare
|
|
6898
|
-
if (asksBook) return { kind: "story", book };
|
|
6935
|
+
const asksBook = /讲|说书|听|来(一)?段|来(一)?回/.test(t) || isBareBookName(bare, hit[1], hit[0]);
|
|
6936
|
+
if (asksBook) return { kind: "story", book: hit[1] };
|
|
6899
6937
|
}
|
|
6900
6938
|
if (/讲故事|讲个故事|说书|来段|讲一段/.test(t)) return { kind: "story", book: "" };
|
|
6901
6939
|
return null;
|
|
@@ -7003,7 +7041,9 @@
|
|
|
7003
7041
|
const payload = line.slice(5).trim();
|
|
7004
7042
|
if (!payload || payload === "[DONE]") continue;
|
|
7005
7043
|
try {
|
|
7006
|
-
|
|
7044
|
+
const v = JSON.parse(payload);
|
|
7045
|
+
if (v && typeof v === "object") events.push(v);
|
|
7046
|
+
else invalid++;
|
|
7007
7047
|
} catch (e) {
|
|
7008
7048
|
invalid++;
|
|
7009
7049
|
}
|
|
@@ -7375,7 +7415,7 @@
|
|
|
7375
7415
|
if (data && data.upgraded) host.bubble("本鲸又长大一点啦!", 4e3);
|
|
7376
7416
|
} catch (err) {
|
|
7377
7417
|
if (!host.epoch.isCurrent(myEpoch)) return;
|
|
7378
|
-
const timedOut = !!err && (err.name === "AbortError" || err.code === 20 || err.code === 2);
|
|
7418
|
+
const timedOut = !!err && (err.name === "AbortError" || err.name === "TimeoutError" || err.code === 20 || err.code === 2 || err.code === 23);
|
|
7379
7419
|
const tip = timedOut ? "主人,本鲸等太久啦(网络慢),再问一次?" : host.persona.line("error");
|
|
7380
7420
|
if (d1) d1.lastChat = { stage: timedOut ? "timeout" : "error", error: String(err && err.message || err).slice(0, 60), at: Date.now() };
|
|
7381
7421
|
fishTip(host, tip, 5e3);
|
|
@@ -7709,6 +7749,7 @@
|
|
|
7709
7749
|
if (!ctrl || !shift) return false;
|
|
7710
7750
|
const key = str(e.key).toLowerCase();
|
|
7711
7751
|
if (key === "w") return true;
|
|
7752
|
+
if (key && key !== "unidentified") return false;
|
|
7712
7753
|
const code = Number(e.keyCode != null ? e.keyCode : e.which);
|
|
7713
7754
|
return code === 87;
|
|
7714
7755
|
}
|
|
@@ -7812,7 +7853,12 @@
|
|
|
7812
7853
|
e.preventDefault();
|
|
7813
7854
|
e.stopPropagation();
|
|
7814
7855
|
const dt = e.dataTransfer;
|
|
7815
|
-
const
|
|
7856
|
+
const files = dt && dt.files ? dt.files : null;
|
|
7857
|
+
const file = files && files[0];
|
|
7858
|
+
try {
|
|
7859
|
+
if (files && files.length > 1 && typeof opts.onMany === "function") opts.onMany(files.length);
|
|
7860
|
+
} catch (e2) {
|
|
7861
|
+
}
|
|
7816
7862
|
try {
|
|
7817
7863
|
opts.onFile(file);
|
|
7818
7864
|
} catch (e2) {
|
|
@@ -8683,6 +8729,12 @@
|
|
|
8683
8729
|
});
|
|
8684
8730
|
} catch (e2) {
|
|
8685
8731
|
}
|
|
8732
|
+
},
|
|
8733
|
+
onMany: (n) => {
|
|
8734
|
+
try {
|
|
8735
|
+
showBubble("一次先看一张哦~先看第一张(共 " + n + " 个)", 3200);
|
|
8736
|
+
} catch (e2) {
|
|
8737
|
+
}
|
|
8686
8738
|
}
|
|
8687
8739
|
});
|
|
8688
8740
|
}, []);
|
|
@@ -9352,11 +9404,17 @@
|
|
|
9352
9404
|
return reminderPoller.start();
|
|
9353
9405
|
}, []);
|
|
9354
9406
|
R.useEffect(() => {
|
|
9355
|
-
|
|
9356
|
-
|
|
9357
|
-
|
|
9358
|
-
|
|
9407
|
+
const cleanup = () => {
|
|
9408
|
+
try {
|
|
9409
|
+
bridge.dispose();
|
|
9410
|
+
} catch (e) {
|
|
9411
|
+
}
|
|
9359
9412
|
};
|
|
9413
|
+
if (earOnRef.current) {
|
|
9414
|
+
void bridge.start();
|
|
9415
|
+
return cleanup;
|
|
9416
|
+
}
|
|
9417
|
+
return cleanup;
|
|
9360
9418
|
}, []);
|
|
9361
9419
|
function speakText(text, o) {
|
|
9362
9420
|
try {
|
|
@@ -9397,7 +9455,7 @@
|
|
|
9397
9455
|
}
|
|
9398
9456
|
try {
|
|
9399
9457
|
if (localStorage.getItem("chubby-voice") === "off") return;
|
|
9400
|
-
const v = bridge.
|
|
9458
|
+
const v = bridge.ensure();
|
|
9401
9459
|
if (v && v.speak && !v.speaking()) v.speak(text, { rate: voiceOpts && voiceOpts.rate || chatRate(), pitch: voiceOpts && voiceOpts.pitch || "" });
|
|
9402
9460
|
} catch (e) {
|
|
9403
9461
|
}
|
package/dist/host/body.js
CHANGED
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
* 这里只统一「读 + 判 + 解析」;日志标签、413 响应由调用方决定(保持原有各路由的日志文案)。
|
|
7
7
|
*/
|
|
8
8
|
import { isBodyTooLarge, readBody } from './http.js';
|
|
9
|
+
/**
|
|
10
|
+
* 去掉文本开头的 UTF-8 BOM(U+FEFF)。
|
|
11
|
+
* Node 的 Buffer.toString('utf8') 不会再吞 BOM,而 JSON.parse 不容忍它(坑 15 / 坑 37 的同一个坑):
|
|
12
|
+
* 带 BOM 的 `{"a":1}` 会落到「解析失败 → body={}」,客户端拿到的是「空请求」而不是自己的数据。
|
|
13
|
+
* 这里只在解析前剥一次,`raw` 仍是原始字节,调用方要原样留证不受影响。
|
|
14
|
+
*/
|
|
15
|
+
function stripBom(text) {
|
|
16
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
17
|
+
}
|
|
9
18
|
/**
|
|
10
19
|
* 带上限读请求体并解析 JSON。
|
|
11
20
|
* - 超过 maxBytes:返回 {ok:false,tooLarge:true}(不抛),调用方回 413;
|
|
@@ -22,7 +31,7 @@ export async function readJsonBody(req, maxBytes) {
|
|
|
22
31
|
return { ok: false, tooLarge: true };
|
|
23
32
|
throw err;
|
|
24
33
|
}
|
|
25
|
-
const text = raw.toString('utf8');
|
|
34
|
+
const text = stripBom(raw.toString('utf8'));
|
|
26
35
|
let body = {};
|
|
27
36
|
try {
|
|
28
37
|
body = text ? JSON.parse(text) : {};
|
package/dist/host/data/cloud.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* culture/chengyu.json、culture/idioms.json、culture/minsu-00..09.json、
|
|
7
7
|
* story/index.json、story/<书名>.json。
|
|
8
8
|
*
|
|
9
|
+
* 诗名索引 / 曲库(2026-11-07 端口收口新增)也按同一口径处理:云端清单里声明了
|
|
10
|
+
* poem-titles.json / melodies.json 就按需拉取,没声明就回退本地实现(包内只读文件)。
|
|
11
|
+
*
|
|
9
12
|
* 策略:
|
|
10
13
|
* · 按需拉:用到哪本小说/哪类文化/哪片诗才拉哪个文件;
|
|
11
14
|
* · 内存缓存:拉过的文件留在进程内;清单每 30 秒最多检查一次,
|
|
@@ -353,5 +356,58 @@ export function createCloudDataPort(opts) {
|
|
|
353
356
|
return out;
|
|
354
357
|
}, () => local.readDaily());
|
|
355
358
|
}
|
|
356
|
-
|
|
359
|
+
/**
|
|
360
|
+
* 回退到本地实现的可选端口方法:老宿主 / 测试假端口可能没实现 readPoemTitles /
|
|
361
|
+
* readMelodies(DataPort 上它们是可选方法)。缺失或抛错都按「本地也没数据」(null)
|
|
362
|
+
* 处理,绝不把异常带出数据层——路由拿到的不是数据就是 null。
|
|
363
|
+
*/
|
|
364
|
+
async function localOrNull(read) {
|
|
365
|
+
if (typeof read !== 'function')
|
|
366
|
+
return null;
|
|
367
|
+
try {
|
|
368
|
+
return await read.call(local);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* 诗名索引:云端清单声明了 poem-titles.json 就取云端,否则回退本地包内文件。
|
|
376
|
+
* 清单没声明属于「数据缺失」(DataUnavailable):静默回退、不进 60 秒冷却;
|
|
377
|
+
* 坏 JSON / 网络失败走链路失败分支:记 CLOUD-DATA-FAIL 后回退本地,绝不抛。
|
|
378
|
+
*/
|
|
379
|
+
async function readPoemTitles() {
|
|
380
|
+
return withFallback('readPoemTitles', async () => {
|
|
381
|
+
const raw = await readFile('poem-titles.json');
|
|
382
|
+
const titles = [];
|
|
383
|
+
if (raw && Array.isArray(raw.titles)) {
|
|
384
|
+
for (const item of raw.titles)
|
|
385
|
+
if (typeof item === 'string' && item)
|
|
386
|
+
titles.push(item);
|
|
387
|
+
}
|
|
388
|
+
if (titles.length === 0)
|
|
389
|
+
dataUnavailable('poem titles empty');
|
|
390
|
+
return { titles };
|
|
391
|
+
}, () => localOrNull(local.readPoemTitles));
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* 曲库:云端清单声明了 melodies.json 就取云端,否则回退本地。
|
|
395
|
+
* 返回顶层浅拷贝 + 新 songs 数组:fileCache 里的原始 parse 结果不直接递给路由
|
|
396
|
+
* (与 normalize 系列同一条所有权纪律)。非对象结构按数据缺失处理。
|
|
397
|
+
*/
|
|
398
|
+
async function readMelodies() {
|
|
399
|
+
return withFallback('readMelodies', async () => {
|
|
400
|
+
const raw = await readFile('melodies.json');
|
|
401
|
+
if (!raw || typeof raw !== 'object')
|
|
402
|
+
dataUnavailable('melodies bad structure');
|
|
403
|
+
const songs = Array.isArray(raw.songs)
|
|
404
|
+
? raw.songs.filter((s) => s && typeof s === 'object').map((s) => ({ ...s }))
|
|
405
|
+
: [];
|
|
406
|
+
return { ...raw, songs };
|
|
407
|
+
}, () => localOrNull(local.readMelodies));
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
readStoryIndex, readStoryBook, readCulture, readPoemsChunk, readDuilian, readDaily,
|
|
411
|
+
readPoemTitles, readMelodies,
|
|
412
|
+
};
|
|
357
413
|
}
|
package/dist/host/data/local.js
CHANGED
|
@@ -35,10 +35,15 @@ export function createLocalDataPort(opts) {
|
|
|
35
35
|
const POEMS_FILE = join(opts.dataDir, 'poems.json');
|
|
36
36
|
const DUILIAN_FILE = join(opts.dataDir, 'duilian.json');
|
|
37
37
|
const DAILY_FILE = join(opts.dataDir, 'daily.json');
|
|
38
|
+
/** 诗名索引 / 曲库:随包只读数据文件(路由经 DataPort 的同名可选方法取,见 ports.ts) */
|
|
39
|
+
const POEM_TITLES_FILE = join(opts.dataDir, 'poem-titles.json');
|
|
40
|
+
const MELODIES_FILE = join(opts.dataDir, 'melodies.json');
|
|
38
41
|
const cultureCache = new Map();
|
|
39
42
|
let poemsCache = null;
|
|
40
43
|
let duilianCache = null;
|
|
41
44
|
let dailyCache = null;
|
|
45
|
+
let poemTitlesCache = null;
|
|
46
|
+
let melodiesCache = null;
|
|
42
47
|
/**
|
|
43
48
|
* 文件最后修改时间(读不到返回 0);缓存据此失效,改数据不必重启。
|
|
44
49
|
* mtimeMs 在 NTFS / ext4 / APFS 上可靠;FAT / exFAT 只有 2 秒精度,
|
|
@@ -85,6 +90,31 @@ export function createLocalDataPort(opts) {
|
|
|
85
90
|
out.note = d.note;
|
|
86
91
|
return out;
|
|
87
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* 诗名索引:只认 {titles:[...]},过滤空串/非字符串(与改造前 poem-titles 路由的过滤口径一致);
|
|
95
|
+
* 空表按「没数据」处理(调用方返回 null,路由走既有 ok:false 兜底)。
|
|
96
|
+
*/
|
|
97
|
+
function pickTitles(raw) {
|
|
98
|
+
const list = raw && Array.isArray(raw.titles) ? raw.titles : [];
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const item of list)
|
|
101
|
+
if (typeof item === 'string' && item)
|
|
102
|
+
out.push(item);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 曲库:保留整份对象(路由自己取 songs / notes / lyrics),songs 条目浅拷贝后返回,
|
|
107
|
+
* 缓存本体不交给调用方(与其它读口同一条缓存所有权纪律)。
|
|
108
|
+
* 合法 JSON 但不是对象(例如字面量 null / 数字)按不可用处理,路由走既有失败兜底。
|
|
109
|
+
*/
|
|
110
|
+
function cloneMelodies(raw) {
|
|
111
|
+
if (!raw || typeof raw !== 'object')
|
|
112
|
+
return null;
|
|
113
|
+
const songs = Array.isArray(raw.songs)
|
|
114
|
+
? raw.songs.filter((s) => s && typeof s === 'object').map((s) => ({ ...s }))
|
|
115
|
+
: [];
|
|
116
|
+
return { ...raw, songs };
|
|
117
|
+
}
|
|
88
118
|
async function readStoryIndex() {
|
|
89
119
|
try {
|
|
90
120
|
const books = await opts.story.listBooks();
|
|
@@ -166,5 +196,49 @@ export function createLocalDataPort(opts) {
|
|
|
166
196
|
dailyCache = { value: out, mtimeMs: mt };
|
|
167
197
|
return cloneItems(out);
|
|
168
198
|
}
|
|
169
|
-
|
|
199
|
+
/**
|
|
200
|
+
* 诗名索引:缺文件 / 坏 JSON / 空表一律 null(不抛),路由按既有 ok:false 兜底。
|
|
201
|
+
* 缓存口径与其它读口一致:按 mtime 失效,坏 JSON 这类确定性失败可以记空缓存,
|
|
202
|
+
* 瞬时读错误不写缓存(否则一次文件占用会把诗名永久锁成空)。
|
|
203
|
+
*/
|
|
204
|
+
async function readPoemTitles() {
|
|
205
|
+
const mt = fileMtime(POEM_TITLES_FILE);
|
|
206
|
+
if (poemTitlesCache && mt > 0 && poemTitlesCache.mtimeMs === mt) {
|
|
207
|
+
const hit = poemTitlesCache.value;
|
|
208
|
+
return hit ? { titles: hit.titles.slice() } : null;
|
|
209
|
+
}
|
|
210
|
+
const loaded = await loadJsonFile(POEM_TITLES_FILE, 'POEM-TITLES-LOAD-FAIL');
|
|
211
|
+
let out = null;
|
|
212
|
+
if (loaded.ok) {
|
|
213
|
+
const titles = pickTitles(loaded.raw);
|
|
214
|
+
if (titles.length > 0)
|
|
215
|
+
out = { titles };
|
|
216
|
+
// 合法 JSON 但结构不对/空表:留痕,与「文件坏了」同口径按不可用处理
|
|
217
|
+
else
|
|
218
|
+
console.error('POEM-TITLES-LOAD-FAIL file=' + POEM_TITLES_FILE + ' err=empty or bad structure');
|
|
219
|
+
}
|
|
220
|
+
if (!loaded.cacheable)
|
|
221
|
+
return out;
|
|
222
|
+
poemTitlesCache = { value: out, mtimeMs: mt };
|
|
223
|
+
return out ? { titles: out.titles.slice() } : null;
|
|
224
|
+
}
|
|
225
|
+
/** 曲库:缺文件 / 坏 JSON / 非对象一律 null(不抛);返回副本,缓存本体不外泄 */
|
|
226
|
+
async function readMelodies() {
|
|
227
|
+
const mt = fileMtime(MELODIES_FILE);
|
|
228
|
+
if (melodiesCache && mt > 0 && melodiesCache.mtimeMs === mt) {
|
|
229
|
+
return melodiesCache.value === null ? null : cloneMelodies(melodiesCache.value);
|
|
230
|
+
}
|
|
231
|
+
const loaded = await loadJsonFile(MELODIES_FILE, 'MELODIES-LOAD-FAIL');
|
|
232
|
+
const out = loaded.ok ? cloneMelodies(loaded.raw) : null;
|
|
233
|
+
if (loaded.ok && !out)
|
|
234
|
+
console.error('MELODIES-LOAD-FAIL file=' + MELODIES_FILE + ' err=bad structure');
|
|
235
|
+
if (!loaded.cacheable)
|
|
236
|
+
return out;
|
|
237
|
+
melodiesCache = { value: out, mtimeMs: mt };
|
|
238
|
+
return out ? cloneMelodies(out) : null;
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
readStoryIndex, readStoryBook, readCulture, readPoemsChunk, readDuilian, readDaily,
|
|
242
|
+
readPoemTitles, readMelodies,
|
|
243
|
+
};
|
|
170
244
|
}
|
package/dist/host/http.js
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
/** 请求体超限的错误码(readBody 抛出,路由据此回 413) */
|
|
6
6
|
const BODY_TOO_LARGE = 'BODY_TOO_LARGE';
|
|
7
|
+
/**
|
|
8
|
+
* 超限后还愿意「读掉并丢弃」的字节数上限。
|
|
9
|
+
* 为什么需要:readBody 抛错后路由要写 413,但如果先 destroy 了连接,413 永远送不到客户端
|
|
10
|
+
* (客户端只看得到 ECONNRESET,实测见 scripts/test-host-http.mjs)。超一点点是常态
|
|
11
|
+
* (16KB 上限的请求多发了 1KB、2MB 的备份包多打了 200KB),把这点尾巴读空再抛,
|
|
12
|
+
* 413 才真的能回出去;超过预算(对方还在猛发)仍然立刻断连,不拿内存和带宽陪跑。
|
|
13
|
+
*/
|
|
14
|
+
const DRAIN_GRACE = 1024 * 1024;
|
|
7
15
|
function bodyTooLarge() {
|
|
8
16
|
const e = new Error('body too large');
|
|
9
17
|
e.code = BODY_TOO_LARGE;
|
|
@@ -54,10 +62,12 @@ export function sendTooLarge(res) {
|
|
|
54
62
|
catch { }
|
|
55
63
|
}
|
|
56
64
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
65
|
+
* 超限且「丢弃预算」也用完时把连接断掉。
|
|
66
|
+
* 为什么此时必须断:只从 for-await 里 throw 出去只是「不再消费」,客户端还在发的 body 会继续占着
|
|
59
67
|
* socket,HTTP 服务也走不到 keep-alive 复用,等于每个超大请求漏一条连接。
|
|
60
68
|
* destroy 优先(客户端立刻收到 RST,剩余数据丢弃);没有 destroy 就 resume 把剩下的读空丢掉。
|
|
69
|
+
* 注意:这条路径客户端收不到 413(连接先断了),所以只在明显超大时才用——
|
|
70
|
+
* 小额超限走 readBody 里的有限 drain,让 413 能送达。
|
|
61
71
|
*/
|
|
62
72
|
function abortRequest(req) {
|
|
63
73
|
// destroy/半截读取都可能触发 error(EPIPE/ECONNRESET),先挂空监听,避免冒成未捕获异常
|
|
@@ -80,26 +90,42 @@ function abortRequest(req) {
|
|
|
80
90
|
catch { }
|
|
81
91
|
}
|
|
82
92
|
/**
|
|
83
|
-
* 读请求体,超过 maxBytes
|
|
84
|
-
*
|
|
85
|
-
*
|
|
93
|
+
* 读请求体,超过 maxBytes 判超限并抛 BODY_TOO_LARGE。
|
|
94
|
+
* 两道保险:
|
|
95
|
+
* · 先看 content-length:声明值已超限就**连读都不读**直接拒(既有语义,调用方据此“不读流、不落盘”);
|
|
96
|
+
* · 读的过程中累计超限(无 content-length / 声明偏小)也当场判超限,不把整份大包收进内存。
|
|
97
|
+
*
|
|
98
|
+
* 超限后不立刻 destroy:路由要写 413,先断连接的话客户端只看到 ECONNRESET(413 永远送不到)。
|
|
99
|
+
* 声明超限时函数直接返回、一个字节都不读,连接由 sendTooLarge 的 `connection: close` 收掉;
|
|
100
|
+
* 流式超限时先在 maxBytes + DRAIN_GRACE 的预算内把剩余数据读掉并丢弃,再把错误抛给路由;
|
|
101
|
+
* 预算用尽还不结束才 abortRequest 断连,避免超大/慢速的体占住连接与内存。
|
|
86
102
|
*/
|
|
87
103
|
export async function readBody(req, maxBytes) {
|
|
88
104
|
const declared = Number(req && req.headers && req.headers['content-length']);
|
|
89
105
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
90
|
-
|
|
91
|
-
throw bodyTooLarge();
|
|
106
|
+
throw bodyTooLarge(); // 不读、不断连:让路由的 413 送得出去
|
|
92
107
|
}
|
|
93
108
|
const chunks = [];
|
|
94
109
|
let total = 0;
|
|
110
|
+
let overflow = false;
|
|
95
111
|
for await (const chunk of req) {
|
|
96
112
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
97
113
|
total += buf.length;
|
|
98
|
-
if (
|
|
99
|
-
|
|
114
|
+
if (!overflow) {
|
|
115
|
+
if (total <= maxBytes) {
|
|
116
|
+
chunks.push(buf);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// 刚越线:半份数据对调用方没意义,先放掉,别继续涨内存
|
|
120
|
+
overflow = true;
|
|
121
|
+
chunks.length = 0;
|
|
122
|
+
}
|
|
123
|
+
if (total > maxBytes + DRAIN_GRACE) {
|
|
124
|
+
abortRequest(req); // 丢弃预算也用完:不陪跑,直接断连
|
|
100
125
|
throw bodyTooLarge();
|
|
101
126
|
}
|
|
102
|
-
chunks.push(buf);
|
|
103
127
|
}
|
|
128
|
+
if (overflow)
|
|
129
|
+
throw bodyTooLarge(); // 已经读空,路由这时写 413 能送到客户端
|
|
104
130
|
return Buffer.concat(chunks);
|
|
105
131
|
}
|
package/dist/host/respond.js
CHANGED
|
@@ -7,8 +7,20 @@
|
|
|
7
7
|
* 注意:本插件大量使用「HTTP 200 承载业务失败(ok:false)」的既有约定,sendError 默认沿用;
|
|
8
8
|
* 需要真状态码(400/405/500)时显式传 status。
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* 响应是否已经写过:已发头或已 end 过就别再写。
|
|
12
|
+
* Node 的 ServerResponse 二次 writeHead 会抛 ERR_HTTP_HEADERS_SENT;而本插件大量
|
|
13
|
+
* 「catch 里无条件 sendError」的写法(backup / culture / misc / tts 等)一旦在回包之后再抛,
|
|
14
|
+
* 这个异常会从 catch 里冒出去,宿主 webserver 只能把连接 destroy 掉——客户端反而丢掉已经
|
|
15
|
+
* 写好的那份响应。这里统一改成「已经回过就不动」:保留第一份响应,也不再往外抛。
|
|
16
|
+
*/
|
|
17
|
+
function alreadyResponded(res) {
|
|
18
|
+
return Boolean(res && (res.headersSent || res.writableEnded));
|
|
19
|
+
}
|
|
10
20
|
/** 返回 JSON 响应;status 缺省 200(与既有路由一致) */
|
|
11
21
|
export function sendJson(res, payload, status = 200) {
|
|
22
|
+
if (alreadyResponded(res))
|
|
23
|
+
return;
|
|
12
24
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
13
25
|
res.end(JSON.stringify(payload));
|
|
14
26
|
}
|
|
@@ -19,16 +31,22 @@ export function sendError(res, error, extra, status = 200) {
|
|
|
19
31
|
}
|
|
20
32
|
/** 纯文本响应(图片缺失等非 JSON 场景) */
|
|
21
33
|
export function sendText(res, status, text, contentType = 'text/plain; charset=utf-8') {
|
|
34
|
+
if (alreadyResponded(res))
|
|
35
|
+
return;
|
|
22
36
|
res.writeHead(status, { 'content-type': contentType });
|
|
23
37
|
res.end(text);
|
|
24
38
|
}
|
|
25
39
|
/** 二进制响应(音频等);额外响应头由调用方给,保持各接口原有缓存策略 */
|
|
26
40
|
export function sendBuffer(res, buffer, contentType, headers = {}) {
|
|
41
|
+
if (alreadyResponded(res))
|
|
42
|
+
return;
|
|
27
43
|
res.writeHead(200, Object.assign({ 'content-type': contentType }, headers));
|
|
28
44
|
res.end(buffer);
|
|
29
45
|
}
|
|
30
46
|
/** SSE 响应头(流式聊天);不再被缓冲的 x-accel-buffering 是给反向代理看的 */
|
|
31
47
|
export function sendSseHeaders(res) {
|
|
48
|
+
if (alreadyResponded(res))
|
|
49
|
+
return;
|
|
32
50
|
res.writeHead(200, {
|
|
33
51
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
34
52
|
'cache-control': 'no-store',
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* 读到旧格式 {"chengyu:名称": true} 时自动迁移成新结构。
|
|
7
7
|
* 一轮讲完只清空同一类别的记录,别的类别进度保留;一轮重来时先排除上一则,
|
|
8
8
|
* 避免「刚讲完又立刻同一则」(与念诗同口径,见 poem.ts 的同名处理)。
|
|
9
|
-
* 数据经 DataPort 取(本地/云端由 index.ts
|
|
9
|
+
* 数据经 DataPort 取(本地/云端由 index.ts 注入;曲库走 readMelodies,见下方回退注释);
|
|
10
|
+
* 数据源读不到只记 CULTURE-LOAD-FAIL,按空池处理,绝不整类报错。
|
|
10
11
|
* 原子写/坏文件隔离走 host/atomic.ts,JSON 响应走 host/respond.ts。
|
|
11
12
|
*/
|
|
12
13
|
import { readFile } from 'node:fs/promises';
|
|
@@ -30,6 +31,26 @@ export function createCultureHandlers(opts) {
|
|
|
30
31
|
return [];
|
|
31
32
|
}
|
|
32
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* 曲库数据:优先经 DataPort.readMelodies(ADR 0002「路由只经端口取数据」)。
|
|
36
|
+
* 端口存在该方法就只认端口:返回 null 由调用方按既有 ok:false 语义兜底,不再回读文件。
|
|
37
|
+
*/
|
|
38
|
+
async function loadMelodies() {
|
|
39
|
+
const port = opts.dataPort;
|
|
40
|
+
if (port && typeof port.readMelodies === 'function') {
|
|
41
|
+
return await port.readMelodies();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 过渡回退(2026-11-07,B86 数据端口收口):`DataPort.readMelodies` 是本次新增的
|
|
45
|
+
* 可选方法。离线单测、旧宿主、以及还没升级装配层的假端口可能没有它;直接删掉
|
|
46
|
+
* 文件读取会让这些环境的曲库整体消失。端口实现存在时本分支不会执行。
|
|
47
|
+
*
|
|
48
|
+
* 移除条件:`readMelodies` 改为必选、且所有装配都注入实现后,删掉本分支、
|
|
49
|
+
* 以及本文件顶部 `readFile`(node:fs/promises)这个 import。
|
|
50
|
+
* 注意 `join` 仍被 SEEN_FILE 使用,不能一起删。
|
|
51
|
+
*/
|
|
52
|
+
return JSON.parse(await readFile(join(opts.dataDir, 'melodies.json'), 'utf8'));
|
|
53
|
+
}
|
|
33
54
|
// 新格式字段 ← 内部前缀;民间故事写 tales + minsu 两份,让 C# 也能读
|
|
34
55
|
function splitKey(k) {
|
|
35
56
|
const i = k.indexOf(':');
|
|
@@ -199,8 +220,10 @@ export function createCultureHandlers(opts) {
|
|
|
199
220
|
}
|
|
200
221
|
async function handleMelodies(_req, res) {
|
|
201
222
|
try {
|
|
202
|
-
const
|
|
203
|
-
|
|
223
|
+
const data = await loadMelodies();
|
|
224
|
+
// 端口/文件都给不出数据(null)时沿用既有失败语义:ok:false + songs:[]
|
|
225
|
+
if (data == null)
|
|
226
|
+
throw new Error('melodies unavailable');
|
|
204
227
|
const songs = (data?.songs || []).map((s) => ({ name: s.name, bpm: s.bpm }));
|
|
205
228
|
sendJson(res, { ok: true, songs });
|
|
206
229
|
}
|
|
@@ -212,8 +235,9 @@ export function createCultureHandlers(opts) {
|
|
|
212
235
|
try {
|
|
213
236
|
const url = new URL(req.url, 'http://x');
|
|
214
237
|
const name = url.searchParams.get('name') || '';
|
|
215
|
-
const
|
|
216
|
-
|
|
238
|
+
const data = await loadMelodies();
|
|
239
|
+
if (data == null)
|
|
240
|
+
throw new Error('melodies unavailable');
|
|
217
241
|
const song = (data?.songs || []).find((s) => s.name === name);
|
|
218
242
|
if (!song) {
|
|
219
243
|
sendJson(res, { ok: false, error: 'song not found' });
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
*
|
|
9
9
|
* 为什么单独一条路由:634 个标题约 11 KB,原先内联在 client bundle 里,
|
|
10
10
|
* 改成按需下发后只有真正点诗的人付这份体积。
|
|
11
|
-
*
|
|
11
|
+
*
|
|
12
|
+
* 取数口径(ADR 0002「路由只经端口取数据」):
|
|
13
|
+
* · 优先 `opts.dataPort.readPoemTitles()`(DataPort 上的可选方法,本地/云端各自实现)。
|
|
14
|
+
* 端口存在就**只认端口**:返回 null / 空表都按既有 ok:false 兜底,不再回读文件;
|
|
15
|
+
* · 只有端口**根本没有该能力**(旧宿主装配 / 离线单测的假端口)时才回退读包内文件,
|
|
16
|
+
* 原因与移除条件见 loadTitlesFromFile 上方注释。
|
|
12
17
|
*/
|
|
13
18
|
import { readFileSync } from 'node:fs';
|
|
14
19
|
import { join } from 'node:path';
|
|
@@ -16,31 +21,62 @@ import { sendJson } from '../respond.js';
|
|
|
16
21
|
export function createPoemTitlesHandler(opts) {
|
|
17
22
|
// 生产装配(index.ts)总会传 DATA_DIR;这里兜底用 cwd/data(与 client seedFromDisk 的查找出发点一致)
|
|
18
23
|
const FILE = join(opts.dataDir || join(process.cwd(), 'data'), 'poem-titles.json');
|
|
19
|
-
/**
|
|
24
|
+
/** 成功读到的标题(只缓存成功结果:文件后来补上时不用重启就能恢复)——仅文件回退分支使用 */
|
|
20
25
|
let cached = null;
|
|
21
|
-
|
|
26
|
+
/** 过滤空串/非字符串:端口与文件两条路径共用同一口径,响应字段不变 */
|
|
27
|
+
function pickTitles(list) {
|
|
28
|
+
const out = [];
|
|
29
|
+
if (!Array.isArray(list))
|
|
30
|
+
return out;
|
|
31
|
+
for (const item of list)
|
|
32
|
+
if (typeof item === 'string' && item)
|
|
33
|
+
out.push(item);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 过渡回退:从包内 data/poem-titles.json 读(改造前的唯一取数路径)。
|
|
38
|
+
*
|
|
39
|
+
* 为什么还留着:`DataPort.readPoemTitles` 是 2026-11-07(B86 数据端口收口)才加的
|
|
40
|
+
* 可选方法。离线环境、测试里的最小假端口、以及还没升级装配层的旧宿主都可能没有
|
|
41
|
+
* 这个方法;若把它当必选,这些宿主会直接拿不到诗名。端口能力存在时本函数**不会被调用**
|
|
42
|
+
* (见 handlePoemTitles 的 fromPort 判断),所以不影响新装配。
|
|
43
|
+
*
|
|
44
|
+
* 移除条件:`DataPort.readPoemTitles` 改为必选、且装配层已注入端口
|
|
45
|
+
* (index.ts 的 `createPoemTitlesHandler({ dataDir: DATA_DIR, dataPort })`)之后,
|
|
46
|
+
* 删掉本函数、FILE、cached 以及 node:fs / node:path 两个 import。
|
|
47
|
+
*/
|
|
48
|
+
function loadTitlesFromFile() {
|
|
22
49
|
if (cached)
|
|
23
50
|
return cached;
|
|
24
51
|
const raw = readFileSync(FILE, 'utf8');
|
|
25
52
|
const data = JSON.parse(String(raw).replace(/^\uFEFF/, ''));
|
|
26
|
-
const
|
|
27
|
-
const titles = [];
|
|
28
|
-
for (const item of list)
|
|
29
|
-
if (typeof item === 'string' && item)
|
|
30
|
-
titles.push(item);
|
|
53
|
+
const titles = pickTitles(data && data.titles);
|
|
31
54
|
if (titles.length === 0)
|
|
32
55
|
throw new Error('empty titles');
|
|
33
56
|
cached = titles;
|
|
34
57
|
return titles;
|
|
35
58
|
}
|
|
36
59
|
async function handlePoemTitles(_req, res) {
|
|
60
|
+
// 只看「端口有没有这个方法」:有就只认端口(null 也算端口给出的结论),绝不再碰文件
|
|
61
|
+
const fromPort = typeof (opts.dataPort && opts.dataPort.readPoemTitles) === 'function';
|
|
37
62
|
try {
|
|
38
|
-
|
|
63
|
+
let titles;
|
|
64
|
+
if (fromPort) {
|
|
65
|
+
// 端口实现承诺不抛(坏 JSON/缺文件 → null);空表按没数据走既有失败兜底
|
|
66
|
+
const data = await opts.dataPort.readPoemTitles();
|
|
67
|
+
titles = pickTitles(data && data.titles);
|
|
68
|
+
if (titles.length === 0)
|
|
69
|
+
throw new Error('empty titles');
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
titles = loadTitlesFromFile();
|
|
73
|
+
}
|
|
39
74
|
sendJson(res, { ok: true, count: titles.length, titles });
|
|
40
75
|
}
|
|
41
76
|
catch (err) {
|
|
42
77
|
// 读不到就明确报失败:客户端会降级为「不识别具体诗名」,其他意图照常
|
|
43
|
-
|
|
78
|
+
const src = fromPort ? ' source=dataPort' : ' file=' + FILE;
|
|
79
|
+
console.error('POEM-TITLES-LOAD-FAIL' + src + ' err=' + String((err && err.message) || err));
|
|
44
80
|
sendJson(res, { ok: false, titles: [], error: String((err && err.message) || err) });
|
|
45
81
|
}
|
|
46
82
|
}
|
|
@@ -142,8 +142,9 @@ export function createStoryHandlers(opts) {
|
|
|
142
142
|
}
|
|
143
143
|
try {
|
|
144
144
|
const url = new URL(req.url, 'http://x');
|
|
145
|
-
// 先按 content-length
|
|
146
|
-
//
|
|
145
|
+
// 先按 content-length 直接拒:能省掉白读一个大包(也避免慢客户端长时间占连接)。
|
|
146
|
+
// 2026-11-07 起 readBody 对「声明超限」不再断连(不读流、直接抛,由 sendTooLarge 的
|
|
147
|
+
// connection: close 收尾),所以下面那层流式读取是兜底而不是唯一出路。
|
|
147
148
|
const declared = Number(req?.headers?.['content-length']);
|
|
148
149
|
if (Number.isFinite(declared) && declared > RESET_MAX_BODY) {
|
|
149
150
|
sendTooLarge(res);
|
package/dist/index.js
CHANGED
|
@@ -275,7 +275,7 @@ export function apply(ctx, rawConfig) {
|
|
|
275
275
|
const culture = createCultureHandlers({ dataDir: DATA_DIR, runtimeDir: RUNTIME_DIR, dataPort });
|
|
276
276
|
const poem = createPoemHandler({ runtimeDir: RUNTIME_DIR, dataPort });
|
|
277
277
|
// 诗名索引:客户端点诗识别按需拉取(数据是包内只读文件,读一次后进程内缓存)
|
|
278
|
-
const poemTitles = createPoemTitlesHandler({ dataDir: DATA_DIR });
|
|
278
|
+
const poemTitles = createPoemTitlesHandler({ dataDir: DATA_DIR, dataPort });
|
|
279
279
|
const briefing = createBriefingHandlers({
|
|
280
280
|
loadGrowth: store.loadGrowth,
|
|
281
281
|
loadReminders: store.loadReminders,
|
package/dist/shared/sse.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 约定:调用方把已收到的字符串拼进 buffer,交给 parseSseBuffer,
|
|
9
9
|
* 返回:
|
|
10
|
-
* events —— 这一批完整事件里 data
|
|
10
|
+
* events —— 这一批完整事件里 data 行解析出的**对象**(非 JSON / 非 data 行会被跳过)
|
|
11
11
|
* rest —— 还没收完的尾巴,需拼到下一次
|
|
12
|
-
* invalid ——
|
|
12
|
+
* invalid —— 解析失败、或解析出来不是对象的行数(便于发现上游格式变化,不抛异常)
|
|
13
13
|
*/
|
|
14
14
|
export function parseSseBuffer(buffer) {
|
|
15
15
|
const events = [];
|
|
@@ -28,7 +28,14 @@ export function parseSseBuffer(buffer) {
|
|
|
28
28
|
if (!payload || payload === '[DONE]')
|
|
29
29
|
continue;
|
|
30
30
|
try {
|
|
31
|
-
|
|
31
|
+
const v = JSON.parse(payload);
|
|
32
|
+
// 只收对象:`data: null` / 数字 / 字符串都不是本协议的帧。放进去会让调用方
|
|
33
|
+
// (client/chat.ts 直接读 obj.error / obj.delta)在 null 上抛 TypeError,
|
|
34
|
+
// 表现成「流式没接上 → 回退普通 /chat」,白烧一轮模型。这类帧计入 invalid。
|
|
35
|
+
if (v && typeof v === 'object')
|
|
36
|
+
events.push(v);
|
|
37
|
+
else
|
|
38
|
+
invalid++;
|
|
32
39
|
}
|
|
33
40
|
catch (e) {
|
|
34
41
|
invalid++;
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
|
|
13
13
|
| 项 | 值 |
|
|
14
14
|
| --- | --- |
|
|
15
|
-
| `package.json` version | 1.1.
|
|
16
|
-
| `src/client/version.ts` VERSION | 1.1.
|
|
17
|
-
| 最近 Gitee tag | `v1.1.
|
|
18
|
-
| Gitee `main` HEAD | `
|
|
19
|
-
| GitHub `main` | `
|
|
20
|
-
| npm latest | **1.1.
|
|
15
|
+
| `package.json` version | 1.1.60 |
|
|
16
|
+
| `src/client/version.ts` VERSION | 1.1.60(由 `scripts/gen-version.mjs` 生成) |
|
|
17
|
+
| 最近 Gitee tag | `v1.1.60`(提交 `96c9ce9`) |
|
|
18
|
+
| Gitee `main` HEAD | `96c9ce9` |
|
|
19
|
+
| GitHub `main` | `f7a4186`;tag `v1.1.60` → `f7a4186`(API 解包核对 1.1.60) |
|
|
20
|
+
| npm latest | **1.1.60**(2026-11-07 实读;发布返回 202,约 2–4 分钟后可见)。已发布版本:1.1.48 / 1.1.50 / 1.1.51 / 1.1.53 / 1.1.54 / 1.1.56 / 1.1.57 / 1.1.58 / 1.1.59 / 1.1.60;1.1.49、1.1.52、1.1.55 仅 Gitee+GitHub(跳档不影响 `latest`) |
|
|
21
21
|
| README 版本 | 无硬编码版本号 |
|
|
22
|
-
| `npm test` | **
|
|
22
|
+
| `npm test` | **2936 项**(41 个套件,`node tools/test-count.mjs` 核准、失败 0)。口径:每个脚本取自身最后一条汇总行求和,不再手工加总 |
|
|
23
23
|
| `npm pack --dry-run` | **129 个文件 / 17.1 MB**(包);`data/cache`、运行数据、`config.json`、`src/`、`scripts/`、`tools/` 均不进包 |
|
|
24
24
|
## 一、版本号规则
|
|
25
25
|
|
package/docs//345/235/221.md
CHANGED
|
@@ -530,4 +530,29 @@ dsh: warning: 1 entry did not activate
|
|
|
530
530
|
|
|
531
531
|
**修复**:补 `scripts/test-stream-ledger.mjs`(58 项,含**静态接线断言**:`index.ts` 的装配块必须含四个依赖)+ 负向验证(注释掉 `loadState` 必须红)。
|
|
532
532
|
|
|
533
|
-
**教训**:修关键路径要同时留**回归测试**,否则下一轮重构会静默改回去。
|
|
533
|
+
**教训**:修关键路径要同时留**回归测试**,否则下一轮重构会静默改回去。
|
|
534
|
+
|
|
535
|
+
## 坑 47:叙述锁只有一份,谁都能解(诗尾 onDone 解掉说书的锁)
|
|
536
|
+
|
|
537
|
+
- **现象**:念诗播到一半点「讲段书」,诗那一段播完的 `clearNarrate()` 会把说书刚上的锁清掉——说书段从此没有叙述锁、气泡不更新,麦克风还会在朗读中途开始拾音(把 TTS 当主人说话)。
|
|
538
|
+
- **原因**:`narration.ts` 只有一份 `until`,`clearNarrate` 是无条件清;而念诗/说书/菜单动作三个使用方共用同一对 `narrate/clearNarrate`。TTS 是**串行排队**的,上一段的 onDone 天然晚到。
|
|
539
|
+
- **现在做法**(2026-11-07,B84):`client.ts` 用 `makeNarrateHandle(label)` 给每个使用方发一个**持有者令牌**(`narrateOwner`);`clearNarrate()` 先撤自己的探针,**只有仍是自己持有时才清锁**。「停止说话」与叙述锁过期看门狗走**权威清锁** `resetNarration()`(不看持有者,立刻静音)。
|
|
540
|
+
- **还挡不住的**:同一使用方连点两次(连念两首),两次共用一个令牌;彻底修要 per-call token(改 culture/content 的 host 签名)。
|
|
541
|
+
- **诊断**:`__chubbyWhale.narrateOverdue`(计数)与 `narrateOverdueInfo`(`label/estMs/afterMs/lockLeftMs/at`)。只说书长段(估算 >2 分钟、`story.ts` 不挂兜底)会打点:到 `max(30s, 1.5×估算)` 仍是持有者且说书 busy 为 true 时**只报警不放锁**。看到计数涨 = 朗读 onDone 可能丢了,不是锁坏了。
|
|
542
|
+
|
|
543
|
+
## 坑 48:ASR 既没有单飞也没有纪元
|
|
544
|
+
|
|
545
|
+
- **现象**:极短时间两段录音会让两个 `/asr` 并发(第二个照发、`uploaded` 计 2);Stop→start 之后上一轮的识别结果仍被当成本轮的新话送进聊天;P0 打断只停 TTS、不停在途识别;主动 abort 还会被计成「识别失败」。
|
|
546
|
+
- **原因**:`onWindowDone` 可重入,没有在途标志;只有 `if (!running) return`,而 Stop→start 后 `running` 又为 true。
|
|
547
|
+
- **现在做法**(2026-11-07,B83):`asrBusy` 单飞(**忙则丢,不排队**:识别是「说完马上回话」的交互,排队会让第二句等第一段,断网时队列还会无界);`asrEpoch` 在 `start()`/`stop()`/`stopSpeaking()` 各 +1,`prepare` 之后、`res.json()` 之后、catch 里都校验,过期一律不送、不写缓存、不改状态;`abortAsrFlight()` 负责早退清理。
|
|
548
|
+
- **取舍要记住**:打断让在途识别作废是规格要求(epoch+1)的必然结果——极端「鱼在说话 + 主人同期开口 + 同时停止说话」时,那句要重说。
|
|
549
|
+
|
|
550
|
+
## 坑 49:读请求体的「超限」有两种语义,混用会让 413 送不到
|
|
551
|
+
|
|
552
|
+
- **现象**:上传超限时客户端只看到 `ECONNRESET`(连接被重置),永远收不到 413;前端把它当成网络故障。
|
|
553
|
+
- **原因**(B89 修复前):`readBody` 一发现超限就 `abortRequest()` → `destroy()` 再抛错。连接被主动断掉,`sendTooLarge()` 写的 413 根本没机会发出去。
|
|
554
|
+
- **现在两种语义(必须分清)**:
|
|
555
|
+
1. **声明超限**(`content-length` 已超上限):不读流、**不断连**,直接抛 `BODY_TOO_LARGE`;由调用方回 413(`sendTooLarge` 带 `connection: close` 收尾)。
|
|
556
|
+
2. **流式超限**(没声明或流比声明大):先在一个小预算(`DRAIN_GRACE`,1MB)内把剩余体读掉,让响应能写出去;预算外才断连。
|
|
557
|
+
- **给新路由的规矩**:判定超限后**必须**用 `sendTooLarge` 回包(它带 `connection: close`)。若改用普通 `sendJson`,未读完的 body 会留在连接里,keep-alive 复用就会错位(下个请求读到上一个的残体)。
|
|
558
|
+
- **别再加「先按 content-length 预判再拒」的 workaround**:`story.ts` 曾为了绕开这个坑自己加了一层预判,现在读取层已修好,预判只是省流量,不再是必需。
|
|
@@ -73,6 +73,9 @@ TTS 结束 → 再等 700ms(ECHO_MUTE_MS)让混响散掉
|
|
|
73
73
|
ASR 结果与本鱼最近说过的 6 句做相似度比对 → 命中即判回声丢弃
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
> 2026-11-07 补充(B84):叙述锁现在**按持有者令牌归属**——`clearNarrate()` 只在仍是自己持有时才清锁,
|
|
77
|
+
> 旧段 onDone(TTS 串行排队,天然晚到)不会再把新使用方的锁解掉;「停止说话」与过期看门狗走权威清锁。
|
|
78
|
+
> 想读长段朗读是否卡住:看 `__chubbyWhale.narrateOverdue` / `narrateOverdueInfo`(只报警,不动锁)。
|
|
76
79
|
## 四、状态机
|
|
77
80
|
|
|
78
81
|
```
|
|
@@ -105,7 +108,9 @@ ASR 结果与本鱼最近说过的 6 句做相似度比对 → 命中即判回
|
|
|
105
108
|
|
|
106
109
|
- **没有真正的任务队列**:P1/P2 用"空闲轮询 + 标志位"实现,不是调度器。极端情况下(连续多次提醒)仍会串行播报,只是不会互相打断。
|
|
107
110
|
- **恢复性打断有代价**:P0 打断说书后,说书的进度停在当前段,需要主人点「接着说」继续。
|
|
108
|
-
-
|
|
111
|
+
- **纪元机制覆盖对话与 ASR**(2026-11-07 起):说书进度、提醒仍各用自己的标志,没有统一事务。
|
|
112
|
+
对话走 `chatEpoch`,ASR 走 `asrEpoch`(start/stop/stopSpeaking 各 +1,await 后逐个校验);两者互不覆盖,
|
|
113
|
+
但都要遵守同一条:**过期结果不许再改任何状态**。
|
|
109
114
|
- 并发问题难用自动化测出来(需要真实麦克风 + 真实网络抖动),本文的规则来自实际故障复盘,**未做压力测试**。
|
|
110
115
|
|
|
111
116
|
---
|
|
@@ -300,3 +300,26 @@ Measure-Command { Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3080/api/
|
|
|
300
300
|
2. 统计卡四项(数据源/模型/TTS/备份)不再是恒定假值;
|
|
301
301
|
3. 朗读中喊「别」能立刻停;换唤醒词后不重开待命也能叫醒;
|
|
302
302
|
4. 说书连播 + 缩放拖拽均正常(缩放不会在拖动后丢失)。
|
|
303
|
+
|
|
304
|
+
## 十七、2026-11-07 凌晨批次(B78–B88:1.1.59 → 1.1.60)待重启核对
|
|
305
|
+
|
|
306
|
+
> 覆盖:云端 TTS 真接线、ASR 单飞与纪元、叙述锁持有者、读图超时、数据端口收口。
|
|
307
|
+
> 客户端改动刷新(`Ctrl+F5`);宿主改动(index/routes/data)需重启 dsh web。
|
|
308
|
+
|
|
309
|
+
| # | 改动 | 涉及文件 | 为什么 | 怎么验证 |
|
|
310
|
+
|---|------|----------|--------|----------|
|
|
311
|
+
| 1 | 云端 TTS 真的注入 | `src/index.ts`、`host/routes/tts.ts` | `cloudTts` 构造后从未注入,配了 Key 也从没走过云端(三层降级只有两层) | 配了 `cloudVoice.apiKey` 的主人重启后说一句话,日志应出现云端合成而非 edge-tts;失败仍回退 edge-tts |
|
|
312
|
+
| 2 | TTS 常驻 worker 由 `ctx.effect` 回收 | `src/index.ts` | 原来 `dispose()` 传不出来,只能等 5 分钟 idle | 反复禁用/启用插件,`tasklist` 里不应堆积 python 进程 |
|
|
313
|
+
| 3 | ASR 单飞 + 纪元 | `client/voice.ts` | 极短时间两段录音会并发识别;Stop→start 后旧话照送;P0 打断不作废在途识别 | 快速说两句:只应识别一次并回一次;说书时打断再说话,旧话不应被补送 |
|
|
314
|
+
| 4 | 叙述锁持有者令牌 | `client.ts`、`actions/culture.ts` | 诗尾 onDone 会解掉说书的锁(说书段无锁、气泡不更新、麦克风提前拾音) | 念诗播到一半点「讲段书」:说书继续、气泡照更新、麦克风不抢话 |
|
|
315
|
+
| 5 | 念诗 per-call 令牌 | `actions/culture.ts` | 同一使用方连念两首,旧诗的迟到回调会清新诗的锁 | 连点两次「念首诗」:第一首的回调不应打断第二首的进度 |
|
|
316
|
+
| 6 | `sayLine` 改 `ensure()` | `client.ts` | 待命关着刷新后,开场问候/情绪台词只有气泡没声 | 关待命→刷新→触发一句人设台词:应有声音(关「语音朗读」仍全静) |
|
|
317
|
+
| 7 | 读图 30s 超时 | `client/image.ts` | `FileReader` 永不回调时气泡卡在「本鲸看看这张图…」 | 拖一张大图/被占用文件:30s 内应给出失败提示而不是一直转 |
|
|
318
|
+
| 8 | 诗名/曲库走 DataPort | `host/data/*`、`routes/poem-titles.ts`、`culture.ts`、`index.ts` | 两处路由直读 `data/`,违反 ADR 0002;且 `poem-titles` 未注入 `dataPort` | 重启后点「念首诗」「讲个典故」:诗名索引与曲库正常(走端口),不再依赖文件回退 |
|
|
319
|
+
|
|
320
|
+
### 重启后自检(4 条)
|
|
321
|
+
|
|
322
|
+
1. 菜单标题栏版本显示 **1.1.60**;
|
|
323
|
+
2. 配了云端语音 Key 时,日志出现云端合成;
|
|
324
|
+
3. 念诗↔说书互不抢锁;待命关刷新后问候仍有声;
|
|
325
|
+
4. 快速连说两句只回一次(ASR 单飞生效)。
|
|
@@ -127,3 +127,10 @@
|
|
|
127
127
|
| B83 | 2026-11-07 | ASR 并发治理:单飞(忙则丢)+ 纪元(start/stop/stopSpeaking 各 +1,await 后校验)+ 打断作废在途 + 主动作废不计失败;新增 `test-asr-concurrency.mjs`(49) | 双 tsc 0 / verify-voice 50 / voice-queue 171 / 2845 项 0 失败 | 见 git |
|
|
128
128
|
| B84 | 2026-11-07 | 组件入口分身:叙述锁**持有者令牌**(诗尾 onDone 不解说书的锁)/ 说书长段只报警探针(`narrateOverdue`)/ 待命与唤醒文案改真 / `wakeHint` 恒空;新增 `test-entry-narration.mjs`(37) | 双 tsc 0 / 2845 项 0 失败 | 见 git |
|
|
129
129
|
| B85 | 2026-11-07 | `tools/test-count.mjs` 精确统计断言数(逐脚本取最后汇总行),修正人工加总可能的漏项 | 39 套件 / 2845 项 / 失败 0 | 见 git |
|
|
130
|
+
| B86 | 2026-11-07 | 主代理:文档口径(坑 47 叙述锁持有者 / 坑 48 ASR 单飞与纪元;并发纪律「纪元覆盖对话与 ASR」+ `clearNarrate` 只认持有者);`VoiceStats.asrBusyDropped` 计入丢段 | 2845→2846 项 0 失败 | 4dddf98 / 4373330 |
|
|
131
|
+
| B87 | 2026-11-07 | 数据端口分身:`DataPort` 增可选 `readPoemTitles`/`readMelodies`(本地 mtime 缓存+BOM 容错+拷贝隔离,云端声明即拉/缺失回退);`poem-titles`/`culture` 端口优先、文件回退仅限方法缺失;`index.ts` 补注入 `dataPort`;`test-data-layer` 端口面 6→8;新增 `test-dataport-poem.mjs`(56) | 双 tsc 0 / 2936 项 0 失败 | 见 git |
|
|
132
|
+
| B88 | 2026-11-07 | 念诗/待命分身:`poemGen` per-call 令牌(旧诗 onDone 不再清新诗的锁)/ `sayLine` 改 `bridge.ensure()`(待命关刷新后问候有声音)/ speak 抛错补 `release()` / 语音清理器不依赖 `earOn`;新增 `test-poem-narration.mjs`(34,含 3 条收尾契约) | 双 tsc 0 / 2936 项 0 失败 | 见 git |
|
|
133
|
+
| B89a | 2026-11-07 | 宿主 HTTP 分身:413 永远送不到(超限先 destroy)/ UTF-8 BOM 变空请求 / 二次写抛 `ERR_HTTP_HEADERS_SENT`;新增 `test-host-http.mjs`(32) | 双 tsc 0 / 3258 项 0 失败 | 见 git |
|
|
134
|
+
| B89b | 2026-11-07 | 客户端网络分身:非对象 SSE 帧进 events(null 让 chat 读 `.error` 抛错白回退一轮)/ 恢复快捷键 keyCode 兜底越权;新增 `test-net-client.mjs`(122+4 变异体) | 双 tsc 0 / 3258 项 0 失败 | 见 git |
|
|
135
|
+
| B89c | 2026-11-07 | 纯函数工具分身:切句器句首标点卡死 / 书名劫持陈述 / 否定句反着做 / 脏 fallback 透传 TTS / `lock(0)` 闭麦 60s 与 expire 界线 / 对仗分类 trim;新增 `test-utils-pure.mjs`(153) | 双 tsc 0 / 3258 项 0 失败 | 见 git |
|
|
136
|
+
| B89d | 2026-11-07 | 主代理:`/chat` 超时补 `TimeoutError`(23) / endpoint 只留 setter 一个写入口 / 多文件拖入先提示「一次一张」+ `test-multidrop.mjs`(10) / 坑 49 两种超限语义 / story 超限注释改真 | 双 tsc 0 / 3258 项 0 失败 | 见 git |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-chubby-whale",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.61",
|
|
4
4
|
"description": "DSH 网页版桌面宠物「肥鲸」——蓝发鲸鱼娘,固定在 harness 页面右下角:会眨眼、能拖拽、点击可聊天,支持语音对话、念诗说书、饭点提醒。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"scripts": {
|
|
75
75
|
"build": "node scripts/gen-version.mjs && node scripts/check-bom.mjs && tsc -p tsconfig.json && tsc -p tsconfig.client.json && esbuild src/client.ts --bundle --format=iife --external:react --charset=utf8 --outfile=dist/client.js",
|
|
76
76
|
"prepublishOnly": "npm run build",
|
|
77
|
-
"test": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/check-imports.mjs && node scripts/gen-module-index.mjs --check && node scripts/check-boundaries.mjs && node scripts/check-diag-keys.mjs && node scripts/check-text.mjs && node scripts/test.mjs && node scripts/test-chat-limits.mjs && node scripts/test-stream-ledger.mjs && node scripts/test-story-security.mjs && node scripts/test-behavior-regressions.mjs && node scripts/verify-voice.mjs && node scripts/smoke-client.mjs && node scripts/ape-bench.mjs && node scripts/smoke-host.mjs && node scripts/test-voice-queue.mjs && node scripts/test-tts-engine.mjs && node scripts/test-data-layer.mjs && node scripts/test-repos.mjs && node scripts/test-personality.mjs && node scripts/test-story-player.mjs && node scripts/test-chat-routes.mjs && node scripts/test-menu-actions.mjs && node scripts/test-story-client.mjs && node scripts/test-story-auto.mjs && node scripts/test-content-routes.mjs && node scripts/test-render-layer.mjs && node scripts/test-store-cache.mjs && node scripts/test-actions-rest.mjs && node scripts/test-misc-routes.mjs && node scripts/test-chat-client.mjs && node scripts/test-prefs-config.mjs && node scripts/test-media-reminders.mjs && node scripts/test-image-reader.mjs && node scripts/test-boundaries-guard.mjs && node scripts/test-assembly.mjs && node scripts/test-asr-concurrency.mjs && node scripts/test-entry-narration.mjs && node scripts/test-voice-bridge.mjs && node scripts/platform-check.mjs",
|
|
77
|
+
"test": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/check-imports.mjs && node scripts/gen-module-index.mjs --check && node scripts/check-boundaries.mjs && node scripts/check-diag-keys.mjs && node scripts/check-text.mjs && node scripts/test.mjs && node scripts/test-chat-limits.mjs && node scripts/test-stream-ledger.mjs && node scripts/test-story-security.mjs && node scripts/test-behavior-regressions.mjs && node scripts/verify-voice.mjs && node scripts/smoke-client.mjs && node scripts/ape-bench.mjs && node scripts/smoke-host.mjs && node scripts/test-voice-queue.mjs && node scripts/test-tts-engine.mjs && node scripts/test-data-layer.mjs && node scripts/test-repos.mjs && node scripts/test-personality.mjs && node scripts/test-story-player.mjs && node scripts/test-chat-routes.mjs && node scripts/test-menu-actions.mjs && node scripts/test-story-client.mjs && node scripts/test-story-auto.mjs && node scripts/test-content-routes.mjs && node scripts/test-dataport-poem.mjs && node scripts/test-render-layer.mjs && node scripts/test-store-cache.mjs && node scripts/test-actions-rest.mjs && node scripts/test-misc-routes.mjs && node scripts/test-chat-client.mjs && node scripts/test-prefs-config.mjs && node scripts/test-media-reminders.mjs && node scripts/test-image-reader.mjs && node scripts/test-boundaries-guard.mjs && node scripts/test-assembly.mjs && node scripts/test-asr-concurrency.mjs && node scripts/test-host-http.mjs && node scripts/test-entry-narration.mjs && node scripts/test-poem-narration.mjs && node scripts/test-multidrop.mjs && node scripts/test-utils-pure.mjs && node scripts/test-net-client.mjs && node scripts/test-voice-bridge.mjs && node scripts/platform-check.mjs",
|
|
78
78
|
"version:check": "node tools/version-check.mjs",
|
|
79
79
|
"typecheck": "node scripts/gen-version.mjs && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
80
80
|
"smoke:client": "node scripts/smoke-client.mjs",
|