dsh-chubby-whale 1.1.65 → 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,43 @@
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
+
23
+ ## [1.1.66] - 2026-11-07
24
+
25
+ ### 修复(B94 HTTP 错误分类与聊天断连分身)
26
+ - **`isTimeoutError` 把「客户端走了」与「上游太慢」混为一类**:日志分不清,路由也没法据此分流。新增 `isAbortError` / `isTimeoutOnly`(旧函数语义原样保留,不破既有断言)
27
+ - **`/chat` 不接断连**:客户端走了仍把模型跑完、且把 AbortError 记成 `CHAT-TIMEOUT`;请求体断连还会往死连接回 200 兜底包。现在断连即 abort 上游、**半截不记账**(`resolve` 出的完整回复仍照常记账——已付费的语义不变)
28
+ - **`/chat-stream` 漏 `req` 侧断连信号**、每请求留一个 `res close` 监听不摘、断连后仍往死 socket 写 `stream: client closed` 帧
29
+ - `http.ts` 新增公共 `watchClientGone`(三形状判定,正常结束有 `writableFinished` 守护)
30
+
31
+ ### 修复(B94 TTS 断连中止分身)
32
+ - **客户端走了仍在合成**:`/tts` 完全没有断连检测,`TtsEngine`/`cloudTts` 也没有 signal 通道。现在 signal 一路贯到 worker/CLI/云端
33
+ - 取舍写在代码里:edge-tts **共享 worker 不杀**(杀掉会连带失败并发段并重付启动),只把本次从 pending 摘除、产物作废;CLI 回退路径本请求独占 python,abort 直接 kill;临时文件 `EBUSY` 时 5s 补删一次
34
+ - abort 的请求**不记账、不写缓存、不进降级闸、不回退 edge-tts**,只留一行 `TTS-CLIENT-ABORT` 诊断
35
+
36
+ ### 修复(主代理)
37
+ - **整句打断词表三处各抄一份**:`echo-filter` 认 19 条、`voice-bridge` 只认 8 条 → 「停下来吧」在闸门能放行、到桥接却认不出。统一到 `shared/noise.ts` 的 `INTERRUPT_PHRASES` + `isInterruptCommand()`
38
+ - `intent.ts` 头部「纯函数」口径修正:判定同步,但会触发一次 `ensurePoemTitles()` 后台预热(有网络副作用),单测须 stub
39
+
40
+ ### 测试
41
+ - 新增 `test-chat-abort.mjs`(51)、`test-tts-abort.mjs`(61),接入 `npm test`;`test-shared-voice` +7
42
+ - 全链 **4033 项 0 失败**(`node tools/test-count.mjs`:56 个套件,失败 0,未识别 0)
43
+
7
44
  ## [1.1.65] - 2026-11-07
8
45
 
9
46
  ### 修复(B93 音色白名单同源分身)
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.65";
28
+ var VERSION = "1.1.67";
29
29
 
30
30
  // src/client/styles.ts
31
31
  var W = 240;
@@ -1133,6 +1133,32 @@
1133
1133
  if (/^([\u4e00-\u9fa5])\1{2,}$/.test(k)) return true;
1134
1134
  return false;
1135
1135
  }
1136
+ var INTERRUPT_PHRASES = Object.freeze([
1137
+ "停下",
1138
+ "停下来",
1139
+ "停下来吧",
1140
+ "停一下",
1141
+ "停一停",
1142
+ "停吧",
1143
+ "别说了",
1144
+ "别讲了",
1145
+ "别念了",
1146
+ "别吵",
1147
+ "别说下去了",
1148
+ "不要说了",
1149
+ "不用说了",
1150
+ "安静",
1151
+ "打住",
1152
+ "够了",
1153
+ "暂停",
1154
+ "算了"
1155
+ ]);
1156
+ function isInterruptCommand(text) {
1157
+ const k = normalizeNoiseKey(text);
1158
+ if (!k) return false;
1159
+ if (isInterruptWord(k)) return true;
1160
+ return INTERRUPT_PHRASES.indexOf(k) >= 0;
1161
+ }
1136
1162
  function isInterruptWord(text) {
1137
1163
  const k = normalizeNoiseKey(text);
1138
1164
  return k.length > 0 && INTERRUPT_WORDS.indexOf(k) >= 0;
@@ -1183,31 +1209,6 @@
1183
1209
  }
1184
1210
 
1185
1211
  // src/client/voice/echo-filter.ts
1186
- var SINGLE_INTERRUPT = /^[停别不]$/;
1187
- var INTERRUPT_PHRASES = [
1188
- "停下",
1189
- "停下来",
1190
- "停下来吧",
1191
- "停一下",
1192
- "停一停",
1193
- "停吧",
1194
- "别说了",
1195
- "别讲了",
1196
- "别念了",
1197
- "别吵",
1198
- "别说下去了",
1199
- "不要说了",
1200
- "不用说了",
1201
- "闭嘴",
1202
- "安静",
1203
- "打住",
1204
- "够了",
1205
- "暂停",
1206
- "算了"
1207
- ];
1208
- function isInterruptCommand(plain) {
1209
- return SINGLE_INTERRUPT.test(plain) || INTERRUPT_PHRASES.indexOf(plain) >= 0;
1210
- }
1211
1212
  function createEchoFilter() {
1212
1213
  const spokenRecent = [];
1213
1214
  const recentHeard = [];
@@ -1229,7 +1230,7 @@
1229
1230
  }
1230
1231
  const plain = normalizePlain(text).toLowerCase();
1231
1232
  const plainKey = echoKey(text).toLowerCase();
1232
- const interrupt = SINGLE_INTERRUPT.test(plain);
1233
+ const interrupt = isInterruptWord(plain);
1233
1234
  const stopCmd = isInterruptCommand(plain);
1234
1235
  if (plain.length < 2 && !interrupt) return { ok: false, reason: "trivial" };
1235
1236
  if (!interrupt && isNoiseText(plain)) return { ok: false, reason: "noise" };
@@ -3887,13 +3888,11 @@
3887
3888
  }
3888
3889
 
3889
3890
  // src/client/voice-bridge.ts
3890
- var STOP_PHRASES = ["停下", "停一下", "别说了", "别讲了", "别念了", "别吵", "安静", "够了"];
3891
3891
  var NARR_STOP_RE = /停|别念|不念|够了|算了|打住/;
3892
3892
  var HAS_CONTENT_RE = /[\u4e00-\u9fa5a-z0-9]/i;
3893
3893
  function isStopCommand(norm) {
3894
3894
  if (!norm) return false;
3895
- if (isInterruptWord(norm)) return true;
3896
- return STOP_PHRASES.indexOf(norm) >= 0;
3895
+ return isInterruptCommand(norm);
3897
3896
  }
3898
3897
  function bump(key) {
3899
3898
  try {
package/dist/host/http.js CHANGED
@@ -21,11 +21,35 @@ function bodyTooLarge() {
21
21
  export function isBodyTooLarge(err) {
22
22
  return Boolean(err && err.code === BODY_TOO_LARGE);
23
23
  }
24
- /** 超时/中止类错误(AbortSignal.timeout 抛 TimeoutError,手动 abort 抛 AbortError) */
24
+ /**
25
+ * 错误名(B94):只看 name,不猜 instanceof —— DOMException 跨 realm 不可靠,
26
+ * 测试里的替身错误也只是 Object.assign(new Error(...), { name })。
27
+ * 有的运行时/薄封装会把中止原因再包一层(err.reason = 原始 reason),这里顺手认一层。
28
+ */
29
+ function errorName(err) {
30
+ return String((err && err.name) || '');
31
+ }
32
+ /** 超时/中止类错误 —— **兼容旧语义:TimeoutError 与 AbortError 都算**(既有测试与 asr 路由按此口径) */
25
33
  export function isTimeoutError(err) {
26
- const n = String((err && err.name) || '');
34
+ const n = errorName(err);
27
35
  return n === 'TimeoutError' || n === 'AbortError';
28
36
  }
37
+ /**
38
+ * 只因**调用方**(外部 signal:客户端断连 / 切页 / 上层主动取消)中止 —— AbortError。
39
+ * 依据:fetch 被中止时以 signal.reason 拒绝;AbortController.abort() 的默认 reason 是 AbortError。
40
+ * 与内部超时(TimeoutError)互斥:路由据此把「客户端走了」与「上游太慢」分开记日志/记账
41
+ * (B93 取证:两者混在 isTimeoutError 里时,CHAT-TIMEOUT / ASR-TIMEOUT 分不出是谁先放弃的)。
42
+ */
43
+ export function isAbortError(err) {
44
+ return errorName(err) === 'AbortError' || errorName(err && err.reason) === 'AbortError';
45
+ }
46
+ /**
47
+ * 只是内部**超时**(AbortSignal.timeout(ms) 到点)—— TimeoutError;调用方中止不算。
48
+ * 旧名 isTimeoutError 被「两者都算」的历史语义占着,区分用的新名字另起(B94)。
49
+ */
50
+ export function isTimeoutOnly(err) {
51
+ return errorName(err) === 'TimeoutError' || errorName(err && err.reason) === 'TimeoutError';
52
+ }
29
53
  /** N 毫秒后自动中止的 signal(Node 18+ 的 AbortSignal.timeout,项目 engines 要求 >=22) */
30
54
  export function timeoutSignal(ms) {
31
55
  return AbortSignal.timeout(ms);
@@ -37,6 +61,25 @@ function pickSignal(signal, init) {
37
61
  return undefined;
38
62
  return typeof cand.addEventListener === 'function' && typeof cand.aborted === 'boolean' ? cand : undefined;
39
63
  }
64
+ /**
65
+ * 取中止原因(B94):先触发那一方的 reason 原样转发;老运行时 signal 没有 reason 时
66
+ * 按来源补一个**同名**的 DOMException —— 否则内部超时会退化成 AbortError,
67
+ * isTimeoutOnly 就再也认不出「这是超时还是客户端中止」(手写兜底路径的关键)。
68
+ */
69
+ function abortReason(src, name) {
70
+ if (src && src.reason !== undefined)
71
+ return src.reason;
72
+ const D = globalThis.DOMException;
73
+ if (typeof D === 'function') {
74
+ try {
75
+ return new D('aborted: ' + name, name);
76
+ }
77
+ catch { }
78
+ }
79
+ const e = new Error('aborted: ' + name);
80
+ e.name = name;
81
+ return e;
82
+ }
40
83
  /**
41
84
  * 把「内部超时」和「调用方中止」(客户端断连等外部 signal)合成一个 signal:任一触发都中止外呼。
42
85
  *
@@ -80,12 +123,11 @@ function combineSignals(timeout, outer) {
80
123
  }
81
124
  function onAbort(ev) {
82
125
  unhookOuter();
83
- const src = ev && (ev.target || ev.currentTarget);
126
+ // 事件没带源 signal(少数实现)时按「谁已经中止」判来源:绝不能把超时当调用方中止
127
+ const given = ev && (ev.target || ev.currentTarget);
128
+ const src = given || (timeout.aborted ? timeout : outer);
84
129
  try {
85
- if (src && src.reason !== undefined)
86
- ctl.abort(src.reason);
87
- else
88
- ctl.abort();
130
+ ctl.abort(abortReason(src, src === timeout ? 'TimeoutError' : 'AbortError'));
89
131
  }
90
132
  catch { }
91
133
  }
@@ -93,7 +135,7 @@ function combineSignals(timeout, outer) {
93
135
  if (timeout.aborted || outer.aborted) {
94
136
  const src = timeout.aborted ? timeout : outer;
95
137
  try {
96
- ctl.abort(src.reason);
138
+ ctl.abort(abortReason(src, src === timeout ? 'TimeoutError' : 'AbortError'));
97
139
  }
98
140
  catch { }
99
141
  return { signal: ctl.signal, dispose() { } };
@@ -219,3 +261,74 @@ export async function readBody(req, maxBytes) {
219
261
  throw bodyTooLarge(); // 已经读空,路由这时写 413 能送到客户端
220
262
  return Buffer.concat(chunks);
221
263
  }
264
+ /**
265
+ * 盯住「客户端是不是走了」(B94:从 routes/asr.ts 的私有实现提成公共件,两条 chat 路由共用)。
266
+ * 返回的 signal 交给 host/http.ts 的外呼(fetchWithTimeout / fetchJson 的第 4 个参数):
267
+ * 客户端一走,还在等上游的那次付费请求就立刻中止,不必跑满超时、也不把回复写进记忆。
268
+ * onGone 可选:仅供路由在自己的 controller 上收口(如 /chat-stream 的空闲/总时长 controller),
269
+ * 在 ctl.abort() 之后调用一次,抛错只吞掉。
270
+ *
271
+ * 为什么不能只听 req 的 close(Node 24 本机实测,时间线见 scripts/test-asr-abort.mjs):
272
+ * 请求体读完时 req 就会发 close(此时 req.complete=true、res.writableEnded=false、响应还没写)——
273
+ * 只看 close 会把**每个正常请求**都误判成断连。真正的断连有三种形状:
274
+ * ① req 'aborted':请求体还没收完客户端就断了(Node 24 实测会发,虽已标记废弃);
275
+ * ② req 'close' 且 req.complete === false:①的兜底(老版本/某些客户端不发 aborted);
276
+ * ③ res 'close' 且响应没写完(writableEnded 与 writableFinished 都为 false):客户端发完了
277
+ * body、等不到结果先走了 —— 客户端 30s 硬超时就是这个形状(宿主还在跑 30s 的聊天/60s 的识别)。
278
+ * 正常结束的 res 'close' 里 writableFinished=true,不会命中 ③,所以不会误中止正常请求。
279
+ */
280
+ export function watchClientGone(req, res, onGone) {
281
+ const ctl = new AbortController();
282
+ let gone = false;
283
+ const mark = () => {
284
+ if (gone)
285
+ return;
286
+ gone = true;
287
+ try {
288
+ ctl.abort();
289
+ }
290
+ catch { }
291
+ if (onGone) {
292
+ try {
293
+ onGone();
294
+ }
295
+ catch { }
296
+ }
297
+ };
298
+ const onReqAborted = () => mark();
299
+ const onReqClose = () => { if (!req || req.complete === false)
300
+ mark(); };
301
+ const onResClose = () => { if (!res || !(res.writableEnded || res.writableFinished))
302
+ mark(); };
303
+ const on = (target, ev, fn) => {
304
+ // 测试里的假 req/res 未必是 EventEmitter:没有 on 就静默跳过(既有用例都是这种形状)
305
+ if (!target || typeof target.on !== 'function')
306
+ return;
307
+ try {
308
+ target.on(ev, fn);
309
+ }
310
+ catch { }
311
+ };
312
+ const off = (target, ev, fn) => {
313
+ if (!target || typeof target.removeListener !== 'function')
314
+ return;
315
+ try {
316
+ target.removeListener(ev, fn);
317
+ }
318
+ catch { }
319
+ };
320
+ on(req, 'aborted', onReqAborted);
321
+ on(req, 'close', onReqClose);
322
+ on(res, 'close', onResClose);
323
+ return {
324
+ signal: ctl.signal,
325
+ gone: () => gone,
326
+ dispose() {
327
+ // req/res 是每请求对象,不摘也不会串到下一个请求;但已结束的 handler 没必要继续响应
328
+ // 'close'/'aborted'(多做一次无意义的 abort),排障时也容易看花眼
329
+ off(req, 'aborted', onReqAborted);
330
+ off(req, 'close', onReqClose);
331
+ off(res, 'close', onResClose);
332
+ },
333
+ };
334
+ }
@@ -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) {
@@ -18,6 +18,9 @@
18
18
  * · 请求解析、system 片段、messages 结构全部走 host/chat-core.ts(同一输入 → 同一请求体);
19
19
  * · 命中缓存:直接发一条 delta + done(cached:true)
20
20
  * · 未命中:边转发边累积,结束时写缓存、写记忆、记 token;半截(中断/客户端断开)一律不落盘。
21
+ * · 客户端断连(B94):req 'aborted' / req close(complete=false) / res close(响应没写完) 都算
22
+ * 「客户端走了」——立刻 abort 上游、不写记忆不记账;正常结束的 res close(writableFinished)
23
+ * 不算断连。判定与 /chat、/asr 共用 host/http.ts 的 watchClientGone 一份实现。
21
24
  *
22
25
  * 记账口径(2026-11-06 起与普通 /chat 一致):
23
26
  * · 成功回复:亲密度 +1、经验 +1(可升级)、appendTurns、token、onReply;
@@ -34,7 +37,7 @@
34
37
  *
35
38
  * 纯函数与其它路由共用 host/chat-core.ts,不再从 routes/chat.ts 横向 import。
36
39
  */
37
- import { sendTooLarge } from '../http.js';
40
+ import { sendTooLarge, watchClientGone } from '../http.js';
38
41
  import { readJsonBody } from '../body.js';
39
42
  import { sendJson, sendSseHeaders } from '../respond.js';
40
43
  import { DEFAULT_CHAT_MODEL } from '../config.js';
@@ -46,6 +49,12 @@ const SSE_PING_MS = 12 * 1000; // SSE 心跳间隔
46
49
  const CHAT_MAX_BODY = 6 * 1024 * 1024;
47
50
  /** 图片超限文案:与 host/routes/chat.ts 的同一句保持一致(路由之间不横向 import,各持一份) */
48
51
  const IMAGE_TOO_LARGE_FALLBACK = '主人,这张图太大啦(原图超过约 3MB),本鲸一口吞不下,换张小一点的再发吧~';
52
+ /**
53
+ * 客户端已经走了的日志标签(B94):与上游超时/网络错误分开,排障时一眼能分清谁先放弃。
54
+ * 分两条对应时机:外呼/读流途中断开(中止外呼)/ 还没发起外呼就断开(body 阶段断连)。
55
+ */
56
+ const LOG_CLIENT_ABORT_OUTBOUND = '[chat-stream] CHAT-CLIENT-ABORT 客户端已断开,上游外呼已中止(不计入上游超时/网络失败)';
57
+ const LOG_CLIENT_ABORT_REQUEST = '[chat-stream] CHAT-CLIENT-ABORT 客户端已断开,请求中止(未发起外呼)';
49
58
  export function createChatStreamHandler(opts) {
50
59
  // 与普通 /chat 共用同一个装配值(index.ts 的 readChatModel),缓存 key 也带它
51
60
  const model = opts.model || DEFAULT_CHAT_MODEL;
@@ -131,6 +140,16 @@ export function createChatStreamHandler(opts) {
131
140
  }
132
141
  idleTimer = setTimeout(() => abortUpstream('idle timeout ' + CHAT_IDLE_TIMEOUT + 'ms'), CHAT_IDLE_TIMEOUT);
133
142
  };
143
+ /**
144
+ * 断连观察器(B94,与 /asr、/chat 共用 host/http.ts 的同一套判定):
145
+ * 覆盖 req 'aborted' / req close(complete=false) / res close(响应没写完) 三种真实断连形状,
146
+ * 正常结束的 res close(writableFinished=true)不会命中 —— 基线只看 res close 时,
147
+ * 客户端在**上传 body 阶段**断开会漏掉(req 侧没人听)。
148
+ * 客户端一走就 abort 上游:没有读者的流继续读到 30s 空闲/120s 硬上限,等于白烧 token;
149
+ * 收尾阶段(上游已读完)不再 abort。
150
+ */
151
+ const client = watchClientGone(req, res, () => { if (!finished)
152
+ abortUpstream('client closed'); });
134
153
  try {
135
154
  const parsed = await readJsonBody(req, CHAT_MAX_BODY);
136
155
  if (!parsed.ok) {
@@ -159,24 +178,11 @@ export function createChatStreamHandler(opts) {
159
178
  return;
160
179
  }
161
180
  sendSseHeaders(res);
162
- // 立刻把 SSE 头刷出去,客户端不必等首帧;同时记录客户端断开
181
+ // 立刻把 SSE 头刷出去,客户端不必等首帧(断连监听已由上面的 watchClientGone 挂好)
163
182
  try {
164
183
  res.flushHeaders && res.flushHeaders();
165
184
  }
166
185
  catch { }
167
- let clientGone = false;
168
- try {
169
- if (typeof res.on === 'function') {
170
- res.on('close', () => {
171
- clientGone = true;
172
- // 客户端已经走了:没有读者的流继续读到 30s 空闲/120s 硬上限,等于白烧 token。
173
- // 收尾阶段(上游已读完/已 res.end)不再 abort。
174
- if (!finished)
175
- abortUpstream('client closed');
176
- });
177
- }
178
- }
179
- catch { }
180
186
  // SSE 心跳:浏览器/反向代理会掐掉长时间没有数据的连接(豆包/nginx 一类默认 60s 左右),
181
187
  // 每 12s 写一行注释帧(以 : 开头,客户端解析器直接跳过)既保活又不污染事件流。
182
188
  // 正常结束/出错统一由 finally 里的 stopPing 清掉,不会残留定时器。
@@ -264,6 +270,14 @@ export function createChatStreamHandler(opts) {
264
270
  totalTimer = setTimeout(() => abortUpstream('total timeout ' + CHAT_MAX_DURATION + 'ms'), CHAT_MAX_DURATION);
265
271
  touchIdle();
266
272
  }
273
+ // 客户端在发起外呼之前就走了(读完 body 就断开/切页):把中止补到刚建好的 controller 上。
274
+ // fetch 拿到的是已中止的 signal,真实网络请求不会真的打出去;日志由下面的 connect 分支统一出。
275
+ if (client.gone() && ctl && !ctl.signal.aborted) {
276
+ try {
277
+ ctl.abort();
278
+ }
279
+ catch { }
280
+ }
267
281
  let upstream = null;
268
282
  try {
269
283
  upstream = await fetch(CHAT_URL, {
@@ -282,6 +296,15 @@ export function createChatStreamHandler(opts) {
282
296
  });
283
297
  }
284
298
  catch (err) {
299
+ // 客户端先走了:这次外呼是我们自己 abort 的(不是上游超时/网络故障),断开后也没人收帧
300
+ if (client.gone()) {
301
+ console.error(LOG_CLIENT_ABORT_OUTBOUND);
302
+ try {
303
+ res.end();
304
+ }
305
+ catch { }
306
+ return;
307
+ }
285
308
  // 空闲/总时长中止时抛的是 AbortError,消息没用,用我们记下的原因
286
309
  send({ error: 'connect: ' + (abortReason || String(err?.message || err)) });
287
310
  res.end();
@@ -295,6 +318,8 @@ export function createChatStreamHandler(opts) {
295
318
  // 响应头已到:接下来按「两段数据之间的间隔」算空闲
296
319
  touchIdle();
297
320
  let aborted = false;
321
+ /** 上游是否已经读到 done(= 这份回复是完整的)。半截与完整的判定只看它,不看客户端在不在。 */
322
+ let sawDone = false;
298
323
  const reader = upstream.body.getReader();
299
324
  const dec = new TextDecoder();
300
325
  let buf = '';
@@ -328,8 +353,10 @@ export function createChatStreamHandler(opts) {
328
353
  try {
329
354
  for (;;) {
330
355
  const { value, done } = await reader.read();
331
- if (done)
356
+ if (done) {
357
+ sawDone = true;
332
358
  break;
359
+ }
333
360
  touchIdle(); // 每收到一段就重置空闲计时:又慢又长的回复不会再被 30s 硬切
334
361
  buf += dec.decode(value, { stream: true });
335
362
  const parsedSse = parseSseBuffer(buf);
@@ -347,7 +374,11 @@ export function createChatStreamHandler(opts) {
347
374
  }
348
375
  catch (err) {
349
376
  aborted = true;
350
- send({ error: 'stream: ' + (abortReason || String(err?.message || err)) });
377
+ // 客户端先走了:这是我们自己 abort 的,不是上游故障;连接已断,也别再往死 socket 上写 error 帧
378
+ if (client.gone())
379
+ console.error(LOG_CLIENT_ABORT_OUTBOUND);
380
+ else
381
+ send({ error: 'stream: ' + (abortReason || String(err?.message || err)) });
351
382
  }
352
383
  finally {
353
384
  // 流已结束(正常或异常),超时定时器再留着只会误伤后续收尾
@@ -355,20 +386,27 @@ export function createChatStreamHandler(opts) {
355
386
  }
356
387
  // 上游已读完:后面的收尾与客户端断开无关,close 事件不再 abort
357
388
  finished = true;
358
- // 流中断或客户端已断开:这段回复是半截的,绝不能写进缓存和记忆(否则被永久命中)
359
- if (aborted || clientGone) {
389
+ // 半截 = 上游没读完(read 抛错、空闲/总时长超时、被我们 abort 都落在这里):
390
+ // 半截回复绝不能写进缓存和记忆(否则被永久命中),也不记 token —— 没有最终 usage,估算只会虚记。
391
+ if (!sawDone) {
392
+ if (client.gone())
393
+ console.error('[chat-stream] CHAT-CLIENT-ABORT 客户端已断开且上游未读完:本轮不写缓存/记忆、不记 token、不加经验');
360
394
  try {
361
395
  res.end();
362
396
  }
363
397
  catch { }
364
- ;
365
398
  return;
366
399
  }
400
+ // 走到这里说明**上游已经读完**(这份回复是完整的)。客户端可能在最后几帧才走 —— 那和 /chat 的
401
+ // 「完整回复照常记账」同一口径(已付费的回复不该因为回不去就丢掉);只留一条诊断便于排障。
402
+ if (aborted || client.gone()) {
403
+ console.error('[chat-stream] CHAT-CLIENT-ABORT-DELIVERED 客户端已断开但上游已读完:照常记账(不写 done 帧)');
404
+ }
367
405
  const text = reply.trim() || '主人……本鲸刚刚走神了。';
368
406
  // 收尾:token / 记忆 / done(与普通 /chat 同口径)
369
407
  // token 口径:
370
408
  // · 请求体已带 stream_options.include_usage,usage 在 [DONE] 之前的最后一帧里给,拿到就记;
371
- // · 中断(空闲/总时长超时、客户端断开、上游异常)在上面的 aborted/clientGone 分支已 return,
409
+ // · 中断(空闲/总时长超时、客户端断开、上游异常)在上面的 aborted / client.gone() 分支已 return,
372
410
  // 不计 token —— 半截流没有最终 usage,估算只会虚记,宁可漏记;
373
411
  // · 上游不回 usage(部分兼容服务不实现)时同样跳过,不猜。
374
412
  try {
@@ -420,8 +458,11 @@ export function createChatStreamHandler(opts) {
420
458
  }
421
459
  catch { }
422
460
  }
423
- send({ done: true, reply: text, cached: false, intimacy, upgraded, firstDeltaMs: firstDeltaAt ? firstDeltaAt - started : 0, ms: Date.now() - started });
424
- 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
+ }
425
466
  // 缓存落盘放在 done/res.end 之后:cache.setJson 内部要 await 磁盘写 + prune 扫描,
426
467
  // 放在收尾前会把客户端看到的「说完」推迟。内存条目在 setJson 开头就同步写入,命中率不受影响。
427
468
  if (opts.cache && cacheOn && cacheUsable && reply.trim()) {
@@ -472,15 +513,20 @@ export function createChatStreamHandler(opts) {
472
513
  }
473
514
  catch (err) {
474
515
  try {
475
- send({ error: String(err?.message || err) });
516
+ // 请求体都没收完客户端就断了(req 'aborted' / ECONNRESET):不算内部错误,也不往死连接写 error 帧
517
+ if (client.gone())
518
+ console.error(LOG_CLIENT_ABORT_REQUEST);
519
+ else
520
+ send({ error: String(err?.message || err) });
476
521
  res.end();
477
522
  }
478
523
  catch { }
479
524
  }
480
525
  finally {
481
- // 所有退出路径统一收口:心跳 interval 与两个超时定时器一个都不许残留
526
+ // 所有退出路径统一收口:心跳 interval、两个超时定时器、req/res 上的断连监听,一个都不许残留
482
527
  clearTimers();
483
528
  stopPing();
529
+ client.dispose();
484
530
  }
485
531
  };
486
532
  }