dsh-chubby-whale 1.1.66 → 1.1.67

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 CHANGED
@@ -4,6 +4,22 @@
4
4
 
5
5
  ## [未发布]
6
6
 
7
+ ## [1.1.67] - 2026-11-07
8
+
9
+ ### 新增
10
+ - **资源通道基础件 `client/channel.ts`**(`docs/并发纪律.md` §七 一直缺的那一块):通道内串行、优先级(数字小先跑,与 P0>P1>P2 同口径)、conflate(同 key 只留最新,被丢的旧调用方以 rejected 收口)、冻结统计快照与 `idle()`。**三条资源(mic/asr/speak)尚未接入**,`channels` 诊断仍为空 —— 文档口径已同步改准
11
+
12
+ ### 修复
13
+ - **流式记账看错了对象**:`/chat-stream` 原来用 `aborted || client.gone()` 决定是否记账,于是「完整回复只是回不去」也被丢掉,与 `/chat` 不一致。现在只看 `sawDone`(上游 `read()` 是否读到 done):半截照旧不落盘,完整则照常记账、只跳过 done 帧并留 `CHAT-CLIENT-ABORT-DELIVERED` 诊断
14
+ - **edge-tts worker 收到取消能立刻停写**:原来 python 侧合成期间根本不读 stdin(`for line in sys.stdin` 读完才跑),发取消也要等整段跑完。重写为「读线程 + 队列」:取消行立即回执(实测 15–16ms),合成改用 `stream()` 分块写盘、每块写前查停止标记;主进程 abort 时先发 `{"cancel":id}` 再收尾,新增 `aborted` 统计。**边界**:停写粒度是分块边界而非 SIGKILL,且仍不杀跨请求共享的 worker(注释写明)
15
+
16
+ ### 文档
17
+ - **安装说明补两个来源**:README 安装节从「两种来源」改为 npm 包 / Gitee / GitHub 镜像 / 本地 link 四种,渠道表补镜像与 npm 包地址;`docs/发布规范.md` 的安装冒烟同步补三条命令与「可达性前提」
18
+
19
+ ### 测试
20
+ - 新增 `test-channel.mjs`(22)、`test-tts-cancel.mjs`(49),接入 `npm test`;`test-stream-ledger` / `test-chat-limits` 情形 B 断言按新口径更新
21
+ - 全链 **4106 项 0 失败**(`node tools/test-count.mjs`:58 个套件,失败 0,未识别 0)
22
+
7
23
  ## [1.1.66] - 2026-11-07
8
24
 
9
25
  ### 修复(B94 HTTP 错误分类与聊天断连分身)
package/README.md CHANGED
@@ -8,18 +8,26 @@ DSH 网页版桌面宠物:蓝发女仆鲸鱼娘,常驻 harness 页面右下
8
8
  | 渠道 | 地址 |
9
9
  | --- | --- |
10
10
  | 主仓库(Gitee) | <https://gitee.com/adengou/dsh-chubby-whale> |
11
+ | 镜像(GitHub) | <https://github.com/adengou/dsh-chubby-whale> |
12
+ | npm 包 | <https://www.npmjs.com/package/dsh-chubby-whale> |
11
13
  | 更新日志 | [CHANGELOG.md](CHANGELOG.md) |
12
14
 
13
15
  > 语音待命的信号处理设计与调参:见 [docs/耳朵算法.md](docs/耳朵算法.md)
14
- > 发版规范(只发 Gitee):见 [docs/发布规范.md](docs/发布规范.md)
16
+ > 发版规范(Gitee 必发;GitHub / npm 可达时同步同一版本):见 [docs/发布规范.md](docs/发布规范.md)
15
17
 
16
- ## 安装(两种来源任选)
18
+ ## 安装(三种来源任选)
17
19
 
18
20
  ```bash
19
- # ① 从 Gitee 装(推荐)
21
+ # ① npm 包(最省事,装发布版)
22
+ dsh plugin --profile web add "dsh-chubby-whale"
23
+
24
+ # ② 从 Gitee 装(主仓库)
20
25
  dsh plugin --profile web add "git+https://gitee.com/adengou/dsh-chubby-whale.git"
21
26
 
22
- # ② 本地 link(开发调试)
27
+ # ③ 从 GitHub 装(镜像,连通时与 Gitee 同一版本)
28
+ dsh plugin --profile web add "git+https://github.com/adengou/dsh-chubby-whale.git"
29
+
30
+ # ④ 本地 link(开发调试)
23
31
  dsh plugin --profile web add "link:C:\Users\<你>\.dsh\plugins\dsh-chubby-whale"
24
32
 
25
33
  # 装完重启 dsh web
package/dist/client.js CHANGED
@@ -25,7 +25,7 @@
25
25
  }
26
26
 
27
27
  // src/client/version.ts
28
- var VERSION = "1.1.66";
28
+ var VERSION = "1.1.67";
29
29
 
30
30
  // src/client/styles.ts
31
31
  var W = 240;
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { hashBytes } from '../../shared/hash.js';
9
9
  import { isInterruptWord, isNoiseText } from '../../shared/noise.js';
10
- import { fetchJson, isTimeoutError, sendTooLarge } from '../http.js';
10
+ import { fetchJson, isAbortError, isTimeoutOnly, sendTooLarge, watchClientGone } from '../http.js';
11
11
  import { readJsonBody } from '../body.js';
12
12
  import { sendJson, sendError } from '../respond.js';
13
13
  const ASR_MODEL = 'Qwen/Qwen3-ASR-1.7B';
@@ -26,66 +26,6 @@ const ASR_TIMEOUT = 60 * 1000;
26
26
  */
27
27
  const LOG_CLIENT_ABORT_OUTBOUND = '[asr] ASR-CLIENT-ABORT 客户端已断开,上游外呼已中止(不计入 ASR 失败)';
28
28
  const LOG_CLIENT_ABORT_REQUEST = '[asr] ASR-CLIENT-ABORT 客户端已断开,请求中止(未发起外呼,不计入 ASR 失败)';
29
- /**
30
- * 盯住「客户端是不是走了」。返回的 signal 会被合进外呼超时(见 host/http.ts 的 fetchWithTimeout),
31
- * 客户端一走,还在等上游的那次付费外呼就立刻中止。
32
- *
33
- * 为什么不能只听 req 的 close(Node 24 本机实测,时间线见 scripts/test-asr-abort.mjs):
34
- * 请求体读完时 req 就会发 close(此时 req.complete=true、res.writableEnded=false、响应还没写)——
35
- * 只看 close 会把**每个正常请求**都误判成断连。真正的断连有三种形状:
36
- * ① req 'aborted':请求体还没收完客户端就断了(Node 24 实测会发,虽已标记废弃);
37
- * ② req 'close' 且 req.complete === false:①的兜底(老版本/某些客户端不发 aborted);
38
- * ③ res 'close' 且响应没写完(writableEnded 与 writableFinished 都为 false):客户端发完了
39
- * body、等不到结果先走了 —— 客户端 30s 硬超时就是这个形状(宿主还在跑 60s 的那次)。
40
- * 正常结束的 res 'close' 里 writableFinished=true,不会命中 ③,所以不会误中止正常请求。
41
- */
42
- function watchClientGone(req, res) {
43
- const ctl = new AbortController();
44
- let gone = false;
45
- const mark = () => {
46
- if (gone)
47
- return;
48
- gone = true;
49
- try {
50
- ctl.abort();
51
- }
52
- catch { }
53
- };
54
- const onReqAborted = () => mark();
55
- const onReqClose = () => { if (!req || req.complete === false)
56
- mark(); };
57
- const onResClose = () => { if (!res || !(res.writableEnded || res.writableFinished))
58
- mark(); };
59
- const on = (target, ev, fn) => {
60
- // 测试里的假 req/res 未必是 EventEmitter:没有 on 就静默跳过(既有用例都是这种形状)
61
- if (!target || typeof target.on !== 'function')
62
- return;
63
- try {
64
- target.on(ev, fn);
65
- }
66
- catch { }
67
- };
68
- const off = (target, ev, fn) => {
69
- if (!target || typeof target.removeListener !== 'function')
70
- return;
71
- try {
72
- target.removeListener(ev, fn);
73
- }
74
- catch { }
75
- };
76
- on(req, 'aborted', onReqAborted);
77
- on(req, 'close', onReqClose);
78
- on(res, 'close', onResClose);
79
- return {
80
- signal: ctl.signal,
81
- gone: () => gone,
82
- dispose() {
83
- off(req, 'aborted', onReqAborted);
84
- off(req, 'close', onReqClose);
85
- off(res, 'close', onResClose);
86
- },
87
- };
88
- }
89
29
  export function createAsrHandler(opts) {
90
30
  return async function handleAsr(req, res) {
91
31
  // 先挂上断连观察器:从读 body 到外呼返回的整段都在它的看护下,结束时必须 dispose(finally)
@@ -182,12 +122,19 @@ export function createAsrHandler(opts) {
182
122
  console.error(LOG_CLIENT_ABORT_OUTBOUND);
183
123
  return;
184
124
  }
185
- // 超时/网络错误:用户看到的仍是 ok:false + 空文本,日志里留标签
186
- if (isTimeoutError(err))
125
+ // 超时/中止/网络错误:用户看到的仍是 ok:false + 空文本,日志里留标签。
126
+ // 分类口径(2026-11-07 B95):isTimeoutError 把 AbortError 与 TimeoutError 混为一类,
127
+ // 排障时会把「被我们自己中止」误读成「上游太慢」;改用只认内部超时的 isTimeoutOnly,
128
+ // 外部中止单独留 ASR-ABORT(正常客户端断连在上面 gone() 分支就 return 了)。
129
+ const timedOut = isTimeoutOnly(err);
130
+ if (timedOut)
187
131
  console.error('[asr] ASR-TIMEOUT ' + ASR_TIMEOUT + 'ms 无响应,已中止');
132
+ else if (isAbortError(err))
133
+ console.error('[asr] ASR-ABORT 外呼被中止(非内部超时)');
188
134
  else
189
135
  console.error('[asr] ASR-NETWORK ' + String(err?.message || err));
190
- sendJson(res, { ok: false, error: isTimeoutError(err) ? 'timeout' : String(err?.message || err), text: '' });
136
+ // 回包文案保持兼容:客户端只认 'timeout' 这个粗标签(据此降级/重试),细分只体现在日志与诊断里
137
+ sendJson(res, { ok: false, error: (timedOut || isAbortError(err)) ? 'timeout' : String(err?.message || err), text: '' });
191
138
  return;
192
139
  }
193
140
  if (!r.ok) {
@@ -318,6 +318,8 @@ export function createChatStreamHandler(opts) {
318
318
  // 响应头已到:接下来按「两段数据之间的间隔」算空闲
319
319
  touchIdle();
320
320
  let aborted = false;
321
+ /** 上游是否已经读到 done(= 这份回复是完整的)。半截与完整的判定只看它,不看客户端在不在。 */
322
+ let sawDone = false;
321
323
  const reader = upstream.body.getReader();
322
324
  const dec = new TextDecoder();
323
325
  let buf = '';
@@ -351,8 +353,10 @@ export function createChatStreamHandler(opts) {
351
353
  try {
352
354
  for (;;) {
353
355
  const { value, done } = await reader.read();
354
- if (done)
356
+ if (done) {
357
+ sawDone = true;
355
358
  break;
359
+ }
356
360
  touchIdle(); // 每收到一段就重置空闲计时:又慢又长的回复不会再被 30s 硬切
357
361
  buf += dec.decode(value, { stream: true });
358
362
  const parsedSse = parseSseBuffer(buf);
@@ -382,19 +386,22 @@ export function createChatStreamHandler(opts) {
382
386
  }
383
387
  // 上游已读完:后面的收尾与客户端断开无关,close 事件不再 abort
384
388
  finished = true;
385
- // 流中断或客户端已断开:这段回复是半截的,绝不能写进缓存和记忆(否则被永久命中)。
386
- // 注意这里保持保守口径:客户端在流读取途中断开一律不落盘 —— 既有用例
387
- // (test-stream-ledger ③B / test-chat-limits ⑦B)钉的就是这一条;「完整回复只是回不去」
388
- // 的记账口径属于普通 /chat 那条同步路径(它没有半截概念),两边不混用。
389
- if (aborted || client.gone()) {
389
+ // 半截 = 上游没读完(read 抛错、空闲/总时长超时、被我们 abort 都落在这里):
390
+ // 半截回复绝不能写进缓存和记忆(否则被永久命中),也不记 token —— 没有最终 usage,估算只会虚记。
391
+ if (!sawDone) {
390
392
  if (client.gone())
391
- console.error('[chat-stream] CHAT-CLIENT-ABORT 客户端已断开:本轮不写缓存/记忆、不记 token、不加经验');
393
+ console.error('[chat-stream] CHAT-CLIENT-ABORT 客户端已断开且上游未读完:本轮不写缓存/记忆、不记 token、不加经验');
392
394
  try {
393
395
  res.end();
394
396
  }
395
397
  catch { }
396
398
  return;
397
399
  }
400
+ // 走到这里说明**上游已经读完**(这份回复是完整的)。客户端可能在最后几帧才走 —— 那和 /chat 的
401
+ // 「完整回复照常记账」同一口径(已付费的回复不该因为回不去就丢掉);只留一条诊断便于排障。
402
+ if (aborted || client.gone()) {
403
+ console.error('[chat-stream] CHAT-CLIENT-ABORT-DELIVERED 客户端已断开但上游已读完:照常记账(不写 done 帧)');
404
+ }
398
405
  const text = reply.trim() || '主人……本鲸刚刚走神了。';
399
406
  // 收尾:token / 记忆 / done(与普通 /chat 同口径)
400
407
  // token 口径:
@@ -451,8 +458,11 @@ export function createChatStreamHandler(opts) {
451
458
  }
452
459
  catch { }
453
460
  }
454
- send({ done: true, reply: text, cached: false, intimacy, upgraded, firstDeltaMs: firstDeltaAt ? firstDeltaAt - started : 0, ms: Date.now() - started });
455
- res.end();
461
+ // 客户端已走就别再往死 socket 写 done 帧(记账照做,回包跳过)
462
+ if (!client.gone()) {
463
+ send({ done: true, reply: text, cached: false, intimacy, upgraded, firstDeltaMs: firstDeltaAt ? firstDeltaAt - started : 0, ms: Date.now() - started });
464
+ res.end();
465
+ }
456
466
  // 缓存落盘放在 done/res.end 之后:cache.setJson 内部要 await 磁盘写 + prune 扫描,
457
467
  // 放在收尾前会把客户端看到的「说完」推迟。内存条目在 setJson 开头就同步写入,命中率不受影响。
458
468
  if (opts.cache && cacheOn && cacheUsable && reply.trim()) {
@@ -9,7 +9,7 @@
9
9
  * 被中止的那次**不记失败、不进缓存、不进降级闸、不回包**,只留一行 TTS-CLIENT-ABORT 诊断。
10
10
  */
11
11
  import { hashKey } from '../../shared/hash.js';
12
- import { sendTooLarge } from '../http.js';
12
+ import { sendTooLarge, watchClientGone } from '../http.js';
13
13
  import { readJsonBody } from '../body.js';
14
14
  import { sendJson, sendBuffer } from '../respond.js';
15
15
  import { normalizeRate, TTS_UNAVAILABLE } from '../../shared/tts.js';
@@ -39,69 +39,6 @@ function oneLine(err, max = 220) {
39
39
  */
40
40
  const LOG_CLIENT_ABORT_SYNTH = '[tts] TTS-CLIENT-ABORT 客户端已断开,合成已中止(不计入失败,不进降级闸)';
41
41
  const LOG_CLIENT_ABORT_REQUEST = '[tts] TTS-CLIENT-ABORT 客户端已断开,请求中止(未发起合成)';
42
- /**
43
- * 盯住「客户端是不是走了」。思路与 routes/asr.ts 的 watchClientGone 一致(B93 那条),返回的
44
- * signal 一路传给 TtsEngine / cloudTts —— 客户端一走,还在等 python worker / 云端的合成立刻放弃。
45
- *
46
- * 为什么不能只听 req 的 close(Node 24 本机实测,时间线取证见 scripts/test-asr-abort.mjs):
47
- * 请求体读完时 req 就会发 close(此时 req.complete=true、响应还没写)——只看 close 会把
48
- * **每个正常请求**都误判成断连。真正的断连有三种形状:
49
- * ① req 'aborted':请求体还没收完客户端就断了;
50
- * ② req 'close' 且 req.complete === false:①的兜底(老版本/某些客户端不发 aborted);
51
- * ③ res 'close' 且响应没写完(writableEnded 与 writableFinished 都为 false):客户端发完了
52
- * body、等不到音频先走了 —— 客户端 30s 硬超时就是这个形状。
53
- * 正常结束的 res 'close' 里 writableFinished=true,不会命中 ③,所以不会误中止正常请求。
54
- *
55
- * 为什么不与 asr.ts 抽成公共模块:路由的依赖白名单(check-imports 规则 A / check-boundaries B6)
56
- * 只允许依赖 respond/body/http/ports/shared,抽出去就要新开一个 host/ 层模块,超出 B94 的改动范围。
57
- */
58
- function watchClientGone(req, res) {
59
- const ctl = new AbortController();
60
- let gone = false;
61
- const mark = () => {
62
- if (gone)
63
- return;
64
- gone = true;
65
- try {
66
- ctl.abort();
67
- }
68
- catch { }
69
- };
70
- const onReqAborted = () => mark();
71
- const onReqClose = () => { if (!req || req.complete === false)
72
- mark(); };
73
- const onResClose = () => { if (!res || !(res.writableEnded || res.writableFinished))
74
- mark(); };
75
- const on = (target, ev, fn) => {
76
- // 测试里的假 req/res 未必是 EventEmitter:没有 on 就静默跳过(既有用例都是这种形状)
77
- if (!target || typeof target.on !== 'function')
78
- return;
79
- try {
80
- target.on(ev, fn);
81
- }
82
- catch { }
83
- };
84
- const off = (target, ev, fn) => {
85
- if (!target || typeof target.removeListener !== 'function')
86
- return;
87
- try {
88
- target.removeListener(ev, fn);
89
- }
90
- catch { }
91
- };
92
- on(req, 'aborted', onReqAborted);
93
- on(req, 'close', onReqClose);
94
- on(res, 'close', onResClose);
95
- return {
96
- signal: ctl.signal,
97
- gone: () => gone,
98
- dispose() {
99
- off(req, 'aborted', onReqAborted);
100
- off(req, 'close', onReqClose);
101
- off(res, 'close', onResClose);
102
- },
103
- };
104
- }
105
42
  export function createTtsHandler(opts) {
106
43
  // 引擎必须由装配层构造并注入(src/index.ts),路由内**不再**自建:
107
44
  // createEdgeTtsEngine 持有常驻 python 子进程,而路由工厂只交出 handler 函数,
@@ -23,7 +23,24 @@
23
23
  * 不再干等到 20s 预算耗尽;
24
24
  * · **刻意不杀 worker**:它是跨请求共享的,杀一次会连带失败掉并发的其它段,还要重付启动开销
25
25
  * (取舍详见 host/tts-engine.ts 的文件头)。做法是把本请求从 pending 里摘掉 ——
26
- * python 稍后照常回 {"id":…,"ok":…},因为找不到对应项而被丢弃;产物由引擎侧作废清理。
26
+ * python 稍后回包因为找不到对应项而被丢弃;产物由引擎侧作废清理。
27
+ *
28
+ * 跨进程取消指令(2026-11-07 B95 —— 替换 B94 那句「协议没有取消指令」的边界):
29
+ * 主进程 abort 时,除了摘 pending,还向 worker 的 stdin 写一行 `{"cancel": <id>}`。
30
+ * id 是这条请求的编号,一定已经随请求写过,所以不存在「取消一个 worker 没见过的 id」。
31
+ * worker 收到后**不等当前合成跑完**就回一条回执(python 侧由独立读线程发出):
32
+ * {"id": <id>, "cancelled": true, "active": <bool>}
33
+ * · active=true —— 取消命中了正在跑的那一段:已置停止标记,合成在**下一个分块边界**
34
+ * 停写、不再往产物文件写、也不再回 ok/false;
35
+ * · active=false —— 该 id 还没轮到(已标记作废,轮到它时直接跳过、不产生产物),
36
+ * 或者早已结束(没有可取消的东西,只回执)。
37
+ * 回执在主进程侧是「识别后丢弃」:pending 在 abort 那一刻就摘掉了,收尾不等回执;
38
+ * 显式认出 {"cancelled":true} 只是不让它走进「合成结果」那条分支,也不把它悬在半空。
39
+ *
40
+ * python 侧的真实能力边界(如实记,别让人以为能掐断网络):
41
+ * edge_tts 的 stream() 是同步等 websocket 收包的,取消标记只能在**收到下一块之后**
42
+ * 被看到。所以「回执立即」成立(读线程发)≠「合成立即停」:真正停写最坏要等下一块
43
+ * 数据到达、或底层连接超时。取消不是 SIGKILL —— 我们仍然不杀共享 worker。
27
44
  */
28
45
  import { spawn } from 'node:child_process';
29
46
  import { readFile, writeFile } from 'node:fs/promises';
@@ -33,9 +50,21 @@ import { tmpdir } from 'node:os';
33
50
  import { fileURLToPath } from 'node:url';
34
51
  import { StringDecoder } from 'node:string_decoder';
35
52
  import { IDLE_KILL_MS, READY_TIMEOUT_MS, WORKER_RETRY_MS, bumpStat, killChild, logWarn, pythonEnv, tailText, } from './common.js';
36
- /** 常驻 worker 的 python 源码:导入一次 edge_tts,之后逐行收 JSON、合成、回写文件 */
53
+ /**
54
+ * 常驻 worker 的 python 源码:导入一次 edge_tts,之后逐行收 JSON、合成、回写文件。
55
+ *
56
+ * 为什么不是「主线程边读 stdin 边合成」(B94 及以前):那种写法在合成期间根本不读 stdin,
57
+ * 取消指令要等这一段跑完才被看见,回执就永远不是「立刻」。B95 改成:
58
+ * · 一个 daemon 读线程专门读 stdin:取消行进 take_cancel 立刻回执并置停止标记,
59
+ * 合成请求进队列,由主线程串行执行(保序、与旧实现一样的串行语义);
60
+ * · 合成用 edge_tts 的 stream() 分块写盘(本地 7.3.1 实测 save() 就是 stream() 的薄包装),
61
+ * 每块之前看一眼停止标记 —— 命中就不再写产物。
62
+ * 边界:stream() 等 websocket 收包是阻塞在 await 上的,停止标记最坏要等下一块到达才被看到;
63
+ * 回执即时、停写不是瞬时。回执/结果都整行写出(out_lock),不会交错。
64
+ */
37
65
  const WORKER_SOURCE = `
38
- import sys, json, asyncio
66
+ import sys, json, asyncio, threading, queue
67
+
39
68
 
40
69
  def main():
41
70
  try:
@@ -44,29 +73,111 @@ def main():
44
73
  print(json.dumps({"ready": False, "error": repr(e)}), flush=True)
45
74
  return
46
75
  print(json.dumps({"ready": True}), flush=True)
47
- for line in sys.stdin:
48
- line = line.strip()
49
- if not line:
50
- continue
76
+
77
+ # Protocol (B95):
78
+ # request {"id": N, "text": .., "voice": .., "rate": .., "pitch": .., "out": ..}
79
+ # cancel {"cancel": N} written by the host on abort
80
+ # ack {"id": N, "cancelled": true, "active": bool}
81
+ # result {"id": N, "ok": true|false, "error": ..}
82
+ # Boundary: edge_tts stream() blocks while awaiting the next websocket chunk, so the
83
+ # stop flag is only observed *after* that chunk arrives. The ack is immediate (sent by
84
+ # the reader thread) but the actual stop may lag by one chunk / the connection timeout.
85
+ lock = threading.Lock() # guards cancelled + state
86
+ out_lock = threading.Lock() # two threads write stdout; keep each line whole
87
+ reqs = queue.Queue()
88
+ cancelled = {}
89
+ state = {"id": None, "ev": None}
90
+
91
+ def emit(obj):
92
+ with out_lock:
93
+ print(json.dumps(obj), flush=True)
94
+
95
+ def take_cancel(cid):
96
+ with lock:
97
+ cancelled[cid] = True
98
+ while len(cancelled) > 512:
99
+ cancelled.pop(next(iter(cancelled)))
100
+ ev = state["ev"] if state["id"] == cid else None
101
+ if ev is not None:
102
+ ev.set()
103
+ emit({"id": cid, "cancelled": True, "active": ev is not None})
104
+
105
+ def reader():
106
+ for line in sys.stdin:
107
+ line = line.strip()
108
+ if not line:
109
+ continue
110
+ try:
111
+ msg = json.loads(line)
112
+ except Exception:
113
+ continue
114
+ if not isinstance(msg, dict):
115
+ continue
116
+ if "cancel" in msg:
117
+ cid = msg.get("cancel")
118
+ if isinstance(cid, int) and not isinstance(cid, bool):
119
+ take_cancel(cid)
120
+ continue
121
+ reqs.put(msg)
122
+ reqs.put(None)
123
+
124
+ threading.Thread(target=reader, daemon=True).start()
125
+
126
+ async def synth(req, out, ev):
127
+ c = edge_tts.Communicate(
128
+ req.get("text") or "",
129
+ req.get("voice") or "zh-CN-XiaoyiNeural",
130
+ rate=req.get("rate") or "+0%",
131
+ pitch=req.get("pitch") or "+0Hz",
132
+ )
133
+ stream = c.stream()
51
134
  try:
52
- req = json.loads(line)
53
- except Exception:
54
- continue
135
+ with open(out, "wb") as f:
136
+ async for chunk in stream:
137
+ if ev.is_set():
138
+ break
139
+ if chunk.get("type") == "audio":
140
+ f.write(chunk.get("data") or b"")
141
+ finally:
142
+ # close the websocket now instead of waiting for GC
143
+ try:
144
+ await stream.aclose()
145
+ except Exception:
146
+ pass
147
+
148
+ while True:
149
+ req = reqs.get()
150
+ if req is None:
151
+ return
55
152
  rid = req.get("id")
56
- out = req.get("out") or ""
153
+ if not isinstance(rid, int) or isinstance(rid, bool):
154
+ continue
155
+ ev = threading.Event()
156
+ with lock:
157
+ state["id"] = rid
158
+ state["ev"] = ev
159
+ stale = cancelled.pop(rid, False)
160
+ if stale:
161
+ # cancelled before it started: write nothing, answer nothing (ack already sent)
162
+ with lock:
163
+ if state["id"] == rid:
164
+ state["id"] = None
165
+ state["ev"] = None
166
+ continue
57
167
  try:
58
- async def run():
59
- c = edge_tts.Communicate(
60
- req.get("text") or "",
61
- req.get("voice") or "zh-CN-XiaoyiNeural",
62
- rate=req.get("rate") or "+0%",
63
- pitch=req.get("pitch") or "+0Hz",
64
- )
65
- await c.save(out)
66
- asyncio.run(run())
67
- print(json.dumps({"id": rid, "ok": True}), flush=True)
168
+ asyncio.run(synth(req, str(req.get("out") or ""), ev))
68
169
  except Exception as e:
69
- print(json.dumps({"id": rid, "ok": False, "error": repr(e)}), flush=True)
170
+ if not ev.is_set():
171
+ emit({"id": rid, "ok": False, "error": repr(e)})
172
+ else:
173
+ if not ev.is_set():
174
+ emit({"id": rid, "ok": True})
175
+ finally:
176
+ with lock:
177
+ if state["id"] == rid:
178
+ state["id"] = None
179
+ state["ev"] = None
180
+
70
181
 
71
182
  main()
72
183
  `;
@@ -101,13 +212,39 @@ async function ensureWorkerFile() {
101
212
  }
102
213
  throw lastError || new Error('tts worker script not writable');
103
214
  }
104
- /** 每个 engine 实例一个 runner:worker / starting / 冷却 / seq 都是实例内状态(与拆分前一致) */
105
- export function createWorkerRunner() {
215
+ /**
216
+ * 每个 engine 实例一个 runner:worker / starting / 冷却 / seq 都是实例内状态(与拆分前一致)。
217
+ *
218
+ * opts.onCancelSent:一行 {"cancel": id} **真的写进** worker stdin 时回调一次(B95)。
219
+ * 只在这里回传「成功发出取消指令」这一件事——写不进去(管道已坏 / 流已销毁)就不回调,
220
+ * 统计口径因此不会把「想取消但没发出去」算成取消。回调本身不抛异常(调用方只是自增计数)。
221
+ */
222
+ export function createWorkerRunner(opts = {}) {
223
+ const { onCancelSent } = opts;
106
224
  let seq = 0;
107
225
  let worker = null;
108
226
  let starting = null;
109
227
  /** worker 启动失败后的冷却截止时间(0 = 不冷却) */
110
228
  let workerRetryAt = 0;
229
+ /**
230
+ * 向 worker 写一行取消指令 `{"cancel": <id>}`。**不等回执**:回执到达时这条请求的
231
+ * pending 早已摘除(onAbort 里先发指令、再收尾),收尾不依赖 worker 的响应速度。
232
+ * 返回是否真的写进去了 —— false 的两种情况(stdin 已销毁 / write 抛错)都不计数。
233
+ */
234
+ function sendCancel(w, id) {
235
+ const stdin = w.child.stdin;
236
+ if (!stdin || stdin.destroyed)
237
+ return false;
238
+ try {
239
+ stdin.write(JSON.stringify({ cancel: id }) + '\n');
240
+ return true;
241
+ }
242
+ catch (e) {
243
+ // 管道已坏:worker 多半也要死了,这里只记一笔,不改变「本次按中止收尾」的结论
244
+ logWarn('取消指令写入失败(忽略)', e);
245
+ return false;
246
+ }
247
+ }
111
248
  function killWorker() {
112
249
  const w = worker;
113
250
  if (!w)
@@ -237,6 +374,13 @@ export function createWorkerRunner() {
237
374
  finish(false);
238
375
  continue;
239
376
  }
377
+ // 取消回执(B95):{"id":N,"cancelled":true,"active":bool}。
378
+ // pending 在 abort 那一刻就摘掉了(见 synth 的 onAbort),所以这里通常找不到对应项;
379
+ // 收尾**不等**这条回执。显式认出它的目的有两个:① 不让它走进下面「合成结果」的
380
+ // 分支(cancelled 不是 ok/false);② 证明回执没有被静默丢弃——它是被理解后忽略的。
381
+ // 不在这里打日志:断连是常态,每条刷一行只会淹没真正的失败。
382
+ if (msg.cancelled === true)
383
+ continue;
240
384
  if (typeof msg.id === 'number') {
241
385
  const p = w.pending.get(msg.id);
242
386
  if (p) {
@@ -364,7 +508,8 @@ export function createWorkerRunner() {
364
508
  settled = true;
365
509
  if (timer)
366
510
  clearTimeout(timer);
367
- // pending 在这里统一摘除:中止后 python 迟到的回包找不到对应项,自然被丢弃
511
+ // pending 在这里统一摘除:中止后 python 迟到的 ok/false 与取消回执都找不到对应项,
512
+ // 自然被丢弃(回执另有显式分支,见上面的 stdout 解析)
368
513
  w.pending.delete(id);
369
514
  if (sig) {
370
515
  try {
@@ -375,9 +520,19 @@ export function createWorkerRunner() {
375
520
  resolve(v);
376
521
  }
377
522
  function onAbort() {
523
+ if (settled)
524
+ return;
378
525
  // 客户端走了:立刻放弃等待这一段,但**不杀 worker**(它是跨请求共享的,取舍见文件头)。
379
- // 代价是这一段的 python 仍会把 mp3 写完、再回一行 JSON(被丢弃)——引擎侧随后删临时文件,
380
- // 删不掉(Windows 句柄未放开)时再排一次延迟清理。
526
+ // 先发取消指令(B95)再收尾:python 侧据此标记作废 / 在下一个分块边界停写,并回一条
527
+ // 回执(回执到达时 pending 已摘,被识别后丢弃)。写不进去就不计数 —— 统计口径是
528
+ // 「成功发出取消指令」,不是「想取消」。
529
+ if (sendCancel(w, id)) {
530
+ try {
531
+ if (onCancelSent)
532
+ onCancelSent();
533
+ }
534
+ catch { }
535
+ }
381
536
  finish(false);
382
537
  }
383
538
  timer = setTimeout(() => {
@@ -386,11 +541,19 @@ export function createWorkerRunner() {
386
541
  w.pending.delete(id);
387
542
  w.dead = true;
388
543
  bumpStat('timeouts');
389
- // 超时:整个 worker 已经不可信(可能卡在网络/ffmpeg 上),杀掉由上层回退单次 spawn
544
+ // 超时:整个 worker 已经不可信(可能卡在网络/ffmpeg 上),杀掉由上层回退单次 spawn。
545
+ // 这里**不发**取消指令:进程马上就没,发出去也没人读。
390
546
  killWorker();
391
547
  // 走统一的收尾(清定时器 / 摘 abort 监听),避免这次请求的 signal 上留一个死监听
392
548
  finish(false);
393
549
  }, Math.max(1, deadline - Date.now()));
550
+ // 从 await settleWithin 回到这里之间 signal 仍可能被中止:已中止的 signal 不会再触发
551
+ // abort 事件,不在这兜住就会一路挂到 20s 预算(B95 顺手补上的边界)。
552
+ // 请求还没写出去,所以不需要(也不该)发取消指令。
553
+ if (sig && sig.aborted) {
554
+ finish(false);
555
+ return;
556
+ }
394
557
  w.pending.set(id, { resolve: finish, timer });
395
558
  if (sig) {
396
559
  try {
@@ -414,7 +577,8 @@ export function createWorkerRunner() {
414
577
  // ① 上面已经杀掉了(超时 / 写失败)——再杀是空操作;
415
578
  // ② worker 回了 {"ok":false}(例如某段文本 NoAudioReceived)——进程本身健康,
416
579
  // 杀掉会连带失败掉其它并发请求,还要重新付出 worker 启动开销。
417
- // (中止也走这里:同上,进程健康,不能为一次断连连累并发请求。)
580
+ // (中止也走这里:同上,进程健康,不能为一次断连连累并发请求。取消指令已经发出去了,
581
+ // python 会在下一个分块边界停写并回执;这里只是不再等它。)
418
582
  // 中止不是合成失败:别把这段的 stderr 当成"回退单次 spawn"的告警打出去(上层会记
419
583
  // TTS-CLIENT-ABORT,两种口径混在一起排障时看花眼)。
420
584
  if (w.err && !(sig && sig.aborted)) {
@@ -50,18 +50,20 @@
50
50
  * - 合成超时 / 进程非 0 退出 / 网络失败 → 普通 Error(路由 500)
51
51
  * - 调用方中止(req.signal,客户端断连)→ Error 且 name='AbortError'(没有 code,路由按断连处理)
52
52
  *
53
- * ── 断连中止(2026-11-07 B94)──────────────────────────────────────────────
54
- * 路由把「客户端走了」变成 req.signal 传进来。本层只做三件事,且刻意**不杀**常驻 worker:
53
+ * ── 断连中止(2026-11-07 B94,B95 补上跨进程取消)─────────────────────────
54
+ * 路由把「客户端走了」变成 req.signal 传进来。本层只做四件事,且刻意**不杀**常驻 worker:
55
55
  * ① 立刻放弃等待:探测 / 等 worker / 等回包三段都被 signal 打断(raceAbort、tts/worker.ts 的
56
56
  * pending 摘除),不再干等到 20s 预算耗尽;
57
57
  * ② 清理并作废本次产物:临时 mp3 照旧在 finally 删;worker 那条路如果 python 还在往里写
58
58
  * (Windows 句柄没放开、unlink 报 EBUSY),再排一次延迟清理,绝不复用这份文件;
59
59
  * ③ 不回退重跑:worker 因 abort 返回 false 时不再走单次 spawn,避免同一段文本合成两遍。
60
+ * ④ 向共享 worker 发一条跨进程取消指令(B95):{"cancel": <id>}。python 侧命中在跑的合成
61
+ * 就在下一个分块边界停写、没轮到就标记作废,两种情况都立刻回一条取消回执;worker 仍然不杀。
62
+ * 协议格式与「回执即时 ≠ 停写即时」的能力边界写在 tts/worker.ts 文件头。
60
63
  * 取舍(为什么"不等待"而不是"连 python 一起停"):常驻 worker 是**跨请求共享**的(并发的另几段
61
64
  * 还在排队),为一次断连杀掉它,会连带失败掉别人的合成、还要重付一次启动 + import 开销
62
- * (实测冷启动约 2.5s)。所以宁可让 worker 把已排队的这一段跑完、回包因 pending 已摘除而被丢弃
63
- * (结果不写缓存、不回包),代价是一段文本的 CPU/网络白跑,但省掉杀进程与重启的连锁反应。
64
- * 真要「连 python 都立刻停」得给 worker 协议加跨进程取消指令,超出本批范围。
65
+ * (实测冷启动约 2.5s)。B94 的做法是让 worker 把这一段跑完、回包被丢弃;B95 进一步让它
66
+ * 「停写 + 回执」,实测省掉这段网络与磁盘上的白跑,但取消本身仍受分块边界限制(不是 SIGKILL)。
65
67
  * 单次 spawn 回退路径不受此限:那个 python 是**本请求独占**的,abort 时直接 kill(tts/spawn.ts)。
66
68
  */
67
69
  import { readdir, readFile, stat, unlink } from 'node:fs/promises';
@@ -73,6 +75,12 @@ import { createWorkerRunner } from './tts/worker.js';
73
75
  import { synthViaCli } from './tts/spawn.js';
74
76
  // 探测缓存清理:排障/测试用,旧导入路径(tts-engine)继续可用
75
77
  export { resetTtsInterpreterProbe } from './tts/python.js';
78
+ /**
79
+ * 取消指令成功写出的计数(B95)。刻意放在模块级而不是 engine 实例里:与 tts/common.ts 的其它
80
+ * 统计同口径(跨实例累计),排障时不会因为换了个 engine 实例就归零。
81
+ * 只由 createWorkerRunner 的 onCancelSent 回调自增 —— 那里已经保证了「真的写出去了」。
82
+ */
83
+ let abortedSends = 0;
76
84
  /** 只读统计快照:给测试和排障用,调用方改不到内部状态 */
77
85
  export function getTtsEngineStats() {
78
86
  const outcome = peekProbeOutcome();
@@ -89,12 +97,14 @@ export function getTtsEngineStats() {
89
97
  workerKills: stats.workerKills,
90
98
  cliKills: stats.cliKills,
91
99
  timeouts: stats.timeouts,
100
+ aborted: abortedSends,
92
101
  };
93
102
  }
94
103
  export function createEdgeTtsEngine(opts = {}) {
95
104
  // 合成总预算:默认 20s,允许测试传更小值,但绝不放大到 20s 以上
96
105
  const timeoutMs = Math.min(Math.max(1, Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS);
97
- const worker = createWorkerRunner();
106
+ // 取消指令写成功一次就 +1(B95);写不进去时 runner 不回调,所以这里不用再判一次
107
+ const worker = createWorkerRunner({ onCancelSent: () => { abortedSends++; } });
98
108
  // 进程崩溃/句柄没放开会留下 chubby-tts-*.mp3:启动时顺手扫一次过期残留
99
109
  void sweepStaleTmp();
100
110
  function remaining(startedAt) {
@@ -12,14 +12,14 @@
12
12
 
13
13
  | 项 | 值 |
14
14
  | --- | --- |
15
- | `package.json` version | 1.1.66 |
16
- | `src/client/version.ts` VERSION | 1.1.66(由 `scripts/gen-version.mjs` 生成) |
17
- | 最近 Gitee tag | `v1.1.66`(见 `git tag --list`) |
15
+ | `package.json` version | 1.1.67 |
16
+ | `src/client/version.ts` VERSION | 1.1.67(由 `scripts/gen-version.mjs` 生成) |
17
+ | 最近 Gitee tag | `v1.1.67`(见 `git tag --list`) |
18
18
  | Gitee `main` HEAD | `git log -1` 实读 |
19
19
  | GitHub `main` | 发布后 `node tools/gh-verify.mjs v1.1.62` 核对(脚本按 tag 名推期望版本) |
20
- | npm latest | **1.1.66**(发布后轮询确认) |
20
+ | npm latest | **1.1.67**(发布后轮询确认) |
21
21
  | README 版本 | 无硬编码版本号 |
22
- | `npm test` | **4033 项**(56 个套件,`node tools/test-count.mjs` 核准、失败 0) |
22
+ | `npm test` | **4106 项**(58 个套件,`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
 
@@ -169,14 +169,24 @@ node tools/check-installed.mjs --dir "C:/Users/Lenovo/.dsh/profiles/web/node_mod
169
169
  要求:仓库 `dist` 与安装副本文件集合一致(51=51)、从 `dist/index.js` 出发的相对 import 闭包齐全、`dist/client.js` 哈希一致。
170
170
  注意:脚本默认 `--dir` 写死旧用户名路径 `C:/Users/eric8/...`,当前机器必须显式传 `--dir`。当前 web profile 是指向仓库本体的 Junction 链接,集合天然一致,此项只在 Gitee 直装副本上有判别力。
171
171
 
172
- 3. Gitee 直装冒烟:
172
+ 3. 三个来源的安装冒烟(`dsh plugin` 只是把参数转发给 pnpm,所以 pnpm 支持的 spec 都能用):
173
173
 
174
174
  ```bash
175
+ # npm 包(发布后 2–4 分钟可见)
176
+ dsh plugin --profile web add "dsh-chubby-whale"
177
+
178
+ # Gitee 主仓库
175
179
  dsh plugin --profile web add "git+https://gitee.com/adengou/dsh-chubby-whale.git"
180
+
181
+ # GitHub 镜像(连通时与 Gitee 同一 tag)
182
+ dsh plugin --profile web add "git+https://github.com/adengou/dsh-chubby-whale.git"
183
+
184
+ # 冒烟:导出齐全即装好
176
185
  node -e "import('<安装目录>/dist/index.js').then(m=>console.log(Object.keys(m)))"
177
186
  ```
178
187
 
179
188
  应导出 `Config`、`SYSTEM_PROMPT`、`apply`、`inject`、`name`。
189
+ 注意:npm/GitHub 两条只在对应渠道**可达且已同步同一版本**时才做;不可达就只验 Gitee,并在发版记录里写明原因。
180
190
 
181
191
  ## 六、回滚流程
182
192
 
@@ -556,3 +556,20 @@ dsh: warning: 1 entry did not activate
556
556
  2. **流式超限**(没声明或流比声明大):先在一个小预算(`DRAIN_GRACE`,1MB)内把剩余体读掉,让响应能写出去;预算外才断连。
557
557
  - **给新路由的规矩**:判定超限后**必须**用 `sendTooLarge` 回包(它带 `connection: close`)。若改用普通 `sendJson`,未读完的 body 会留在连接里,keep-alive 复用就会错位(下个请求读到上一个的残体)。
558
558
  - **别再加「先按 content-length 预判再拒」的 workaround**:`story.ts` 曾为了绕开这个坑自己加了一层预判,现在读取层已修好,预判只是省流量,不再是必需。
559
+
560
+ ## 坑 50|流式「客户端断开」不等于「回复半截」(2026-11-07 B95)
561
+
562
+ **现象**:同一次对话,走 `/chat`(同步)会照常记账,走 `/chat-stream`(流式)却一律不落盘 ——
563
+ 只要客户端断开,哪怕上游已经把回复读完、只是回不去。两边记账结果不一致,而且用户已经付过费的回复被丢掉。
564
+
565
+ **根因**:老判定是 `aborted || client.gone()`。这两个条件描述的是「客户端在不在」,
566
+ 而记账该看的是「**上游读完了没有**」—— 是两件事。客户端在我们 abort 之后离开时,read 会抛错(半截,
567
+ 确实不该记);但如果 abort 晚于最后一帧、或 `send()` 在收尾时抛错,上游其实已经读完(完整,该记)。
568
+
569
+ **修法**:加 `sawDone` 标志(`reader.read()` 返回 `done:true` 时置位),收尾只按它分流:
570
+ - `!sawDone` → 半截:不写缓存/记忆、不记 token、不加经验(原有保守口径保留在这一支);
571
+ - `sawDone` → 完整:照常记账;若客户端已走,**只跳过 done 帧**并打 `CHAT-CLIENT-ABORT-DELIVERED` 诊断。
572
+
573
+ **证据**:`test-stream-ledger` 情形 B(close 之后第二次 read 才返回 done)断言从「不落盘」改为
574
+ 「state/exp/turns/cache/onReply 各 1 次 + token=12 + 无 done 帧 + 正文完整」;情形 A(上游中断)仍断言不落盘,
575
+ 说明两种形态真的被区分开了。`test-chat-limits` 情形 B 同步改口径。
@@ -117,10 +117,13 @@ ASR 结果与本鱼最近说过的 6 句做相似度比对 → 命中即判回
117
117
 
118
118
  ## 七、"要不要一个全局单线程队列?"——不要,要三条资源通道(**设计稿,尚未实现**)
119
119
 
120
- > 2026-11-06 更正(分身取证):本节描述的是**设计目标**。仓库里**没有** `src/client/channel.ts`,
121
- > 全仓也没有 `createChannel` / `conflate` 的实现,更没有下面说的单元测试。
122
- > 现状:三条资源靠各模块自己的标志位串行(录音段排队 + `ttsQ` 串行播放 + ASR 段号去重),
123
- > `client.ts` 里 `channels` 诊断对象恒为空。若真要落地,按本节方案做,并补上测试再接进 `npm test`。
120
+ > 2026-11-07 更新(B95):**基础件已落地** —— `src/client/channel.ts` 提供 `createChannel()`:
121
+ > 通道内串行、优先级(数字小先跑,与 P0>P1>P2 同口径)、conflate(同 key 留最新)与统计快照,
122
+ > 由 `scripts/test-channel.mjs`(22 项断言,已接入 `npm test`)验收。
123
+ >
124
+ > **仍未接入**:`mic` / `asr` / `speak` 三条资源目前还是各模块自己的标志位(录音段排队 + `ttsQ`
125
+ > 串行播放 + ASR 段号去重),`client.ts` 的 `channels` 诊断对象仍恒为空。接入要逐个资源替换
126
+ > 现有串行逻辑并重跑并发断言,属独立一批,别为「用上新件」而硬套。
124
127
 
125
128
  浏览器 JS 本来就是单线程,所以"单线程"不是选择,是既定事实。真正要选的是**排队粒度**。
126
129
 
@@ -152,14 +155,22 @@ ASR 结果与本鱼最近说过的 6 句做相似度比对 → 命中即判回
152
155
  ### 单元测试(**未实现,下面的数字是设计目标,不是实测**)
153
156
 
154
157
  ```
155
- 目标1:优先级 P9(先塞) → P1 → P5 期望执行顺序 P9 → P1 → P5
156
- 目标2:conflate A 在跑时塞 B、C 期望执行 A → C,B 被丢弃(dropped=1)
158
+ 实测1:阻塞期间塞 P3 → P1 → P0 → P2,放行后执行顺序 P0 → P1 → P2 → P3
159
+ 实测2:conflate A 在跑时塞 B、C 执行 A → C,B 被丢弃(dropped=1,旧调用方 rejected 收口)
160
+ 实测3:同步抛错的任务不会卡死队列(failed+1,后续任务照常)
161
+ 实测4:两条通道可同时跑(maxOverlap=2),证明不是全局串行
157
162
  ```
158
163
 
159
- > 现状:没有 `channel.ts`,也没有对应测试文件;上面两行是验收标准,落地后才算通过。
164
+ > 上述四条的实现与断言在 `src/client/channel.ts` / `scripts/test-channel.mjs`(2026-11-07 落地)。
160
165
 
161
166
  ### 什么不该进队列
162
167
 
163
168
  - 渲染 / 动画:走 rAF,进队列只会抖;
164
169
  - 状态更新(setState):同步语义,排队会撕裂 UI;
165
- - 网络读接口(余额、诗、故事):彼此独立,并发即可,只对"最新一次"做纪元校验。
170
+ - 网络读接口(余额、诗、故事):彼此独立,并发即可,只对"最新一次"做纪元校验。
171
+
172
+ ### 附:流式记账看「上游读完没有」,不看「客户端在不在」(2026-11-07 B95)
173
+
174
+ `/chat-stream` 原来用 `aborted || client.gone()` 决定是否记账,会把「完整回复只是回不去」也丢掉;
175
+ 现在改为只看 `sawDone`(上游 `read()` 是否读到 `done`)。细节与断言见 `docs/坑.md` 坑
176
+ 『流式「客户端断开」不等于「回复半截」』。
@@ -390,3 +390,12 @@ Measure-Command { Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3080/api/
390
390
  | 4 | 断连请求不写缓存 | 半成品/中止结果若进缓存会污染后续朗读 | 打断后重新朗读同一句:应重新合成 |
391
391
  | 5 | 打断词表统一 | 「停下来吧」等整句说法此前只在回声闸门生效 | 说书中说「停下来吧」「不要说了」:都应立刻停 |
392
392
  | 6 | 正常请求不误判断连 | 判定依赖 `req.complete` 与 `res.writableFinished` | 正常对话/朗读**不应**出现任何 abort 诊断 |
393
+
394
+ ## 二十三、2026-11-07 凌晨批次(B95:1.1.67)待重启核对
395
+
396
+ | # | 改动 | 为什么 | 怎么验证 |
397
+ |---|------|--------|----------|
398
+ | 1 | 流式「完整回复只是回不去」也记账 | 原来一律不落盘,与 `/chat` 不一致 | 长回答中途关页面:复开应能在记忆里看到那句完整回复 |
399
+ | 2 | 半截回复仍不落盘 | 中断形态不能与完整混淆 | 强杀连接后再对话:记忆里不应出现半句 |
400
+ | 3 | worker 收到取消立刻回执 | 原来合成期间不读 stdin,取消要等整段跑完 | 朗读中打断:日志/统计出现 `aborted` 增长,且很快能开始下一段 |
401
+ | 4 | 断连后不再写满整段音频 | 被取消段仍在写产物 | 打断后看 temp 目录:不应留下整段 mp3 |
@@ -5,7 +5,7 @@
5
5
  > 架构宪章:**模块只对外暴露这些导出,外部不得读取模块内部实现**。
6
6
  > 新增/修改导出 = 接口变更,必须同步调用点并在提交信息里写明。
7
7
 
8
- > 统计:120 个模块文件,727 个导出项。
8
+ > 统计:121 个模块文件,731 个导出项。
9
9
 
10
10
  ## 宿主(Node)(`src/host/`)
11
11
 
@@ -86,6 +86,7 @@
86
86
  | `browser-tts.ts` | `browserVoiceUsable`、`speakViaBrowser`、`watchdogMs` |
87
87
  | `bubble.ts` | `BUBBLE_CSS`、`Bubble`、`BubbleOptions`、`STATUS_TEXTS`、`createBubble`、`ensureBubbleStyle` |
88
88
  | `busy.ts` | `BUSY_STALE_MS`、`BusyLock`、`Epoch`、`createBusyLock`、`createEpoch` |
89
+ | `channel.ts` | `Channel`、`ChannelStats`、`ChannelTaskOptions`、`createChannel` |
89
90
  | `chat-route.ts` | `LocalRouteHost`、`LocalRouter`、`createLocalRouter` |
90
91
  | `chat.ts` | `ChatController`、`ChatHost`、`createChatSender`、`resolveChatLang` |
91
92
  | `dropzone.ts` | `DropDeps`、`DropResult`、`MAX_DROP_BYTES`、`MAX_TEXT_CHARS`、`VISION_PROMPT`、`buildTextPrompt`、`handleFileDrop`、`isImageFile`、`isTextFile`、`readFileAsBase64` |
@@ -151,3 +151,7 @@
151
151
  | B94a | 2026-11-07 | 主代理:整句打断词表统一到 `shared/noise.ts`(`INTERRUPT_PHRASES`/`isInterruptCommand`)/ `intent` 头部「纯函数」口径修正;`test-shared-voice` +7 | 双 tsc 0 / 4033 项 0 失败 | 见 git |
152
152
  | B94b | 2026-11-07 | HTTP 错误分类与聊天断连分身:`isAbortError`/`isTimeoutOnly`/公共 `watchClientGone` / `/chat` 与 `/chat-stream` 接断连、半截不记账、不回死 socket;新增 `test-chat-abort.mjs`(51) | 双 tsc 0 / 4033 项 0 失败 | 见 git |
153
153
  | B94c | 2026-11-07 | TTS 断连中止分身:`signal` 贯通 ports/路由/引擎/worker/云端,abort 不记账不缓存不回退、共享 worker 不杀、CLI kill、临时文件补删;新增 `test-tts-abort.mjs`(61) | 双 tsc 0 / 4033 项 0 失败 | 见 git |
154
+ | B95a | 2026-11-07 | 主代理:落地 `client/channel.ts`(串行/优先级/conflate/统计/idle)+ `test-channel.mjs`(22);`docs/并发纪律.md` §七 从「设计稿未实现」改为「基础件落地、三条资源未接入」 | 双 tsc 0 / 4106 项 0 失败 | 见 git |
155
+ | B95b | 2026-11-07 | 主代理:流式记账改看 `sawDone`(上游读完没有),与 `/chat` 对齐;`test-stream-ledger`/`test-chat-limits` 情形 B 断言更新;`docs/坑.md` 新增坑 50 | 双 tsc 0 / 4106 项 0 失败 | 见 git |
156
+ | B95c | 2026-11-07 | worker 取消协议分身:python 改「读线程+队列」,取消即时回执(实测 15–16ms),`stream()` 分块写前查停止标记,abort 先发 `{cancel:id}`;`aborted` 统计;新增 `test-tts-cancel.mjs`(49) | 双 tsc 0 / 4106 项 0 失败 | 见 git |
157
+ | B95d | 2026-11-07 | 主代理:README 安装节补 npm/GitHub 两源(渠道表同步),`发布规范` 安装冒烟补三条命令与可达性前提 | 全链 4106 项 0 失败 | 见 git |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-chubby-whale",
3
- "version": "1.1.66",
3
+ "version": "1.1.67",
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-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-persona-cloud.mjs && node scripts/test-client-glue.mjs && node scripts/test-poem-titles-retry.mjs && node scripts/test-shared-voice.mjs && node scripts/test-audio-sim.mjs && node scripts/test-voice-routes.mjs && node scripts/test-voices-shared.mjs && node scripts/test-voice-tts.mjs && node scripts/test-asr-abort.mjs && node scripts/test-chat-abort.mjs && node scripts/test-tts-abort.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-persona-cloud.mjs && node scripts/test-client-glue.mjs && node scripts/test-poem-titles-retry.mjs && node scripts/test-shared-voice.mjs && node scripts/test-channel.mjs && node scripts/test-audio-sim.mjs && node scripts/test-voice-routes.mjs && node scripts/test-voices-shared.mjs && node scripts/test-voice-tts.mjs && node scripts/test-asr-abort.mjs && node scripts/test-chat-abort.mjs && node scripts/test-tts-abort.mjs && node scripts/test-tts-cancel.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",