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 +37 -0
- package/README.md +12 -4
- package/dist/client.js +29 -30
- package/dist/host/http.js +121 -8
- package/dist/host/routes/asr.js +11 -64
- package/dist/host/routes/chat-stream.js +71 -25
- package/dist/host/routes/chat.js +38 -4
- package/dist/host/routes/tts.js +51 -4
- package/dist/host/tts/spawn.js +40 -4
- package/dist/host/tts/worker.js +250 -34
- package/dist/host/tts-engine.js +110 -6
- package/dist/host/voice/cloud-tts.js +89 -2
- package/dist/shared/noise.js +23 -0
- package/docs//345/217/221/345/270/203/350/247/204/350/214/203.md +16 -6
- package/docs//345/235/221.md +17 -0
- package/docs//345/271/266/345/217/221/347/272/252/345/276/213.md +19 -8
- package/docs//345/276/205/351/207/215/345/220/257/347/224/237/346/225/210.md +20 -0
- package/docs//346/250/241/345/235/227/346/216/245/345/217/243/346/270/205/345/215/225.md +4 -3
- 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,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
|
-
>
|
|
16
|
+
> 发版规范(Gitee 必发;GitHub / npm 可达时同步同一版本):见 [docs/发布规范.md](docs/发布规范.md)
|
|
15
17
|
|
|
16
|
-
##
|
|
18
|
+
## 安装(三种来源任选)
|
|
17
19
|
|
|
18
20
|
```bash
|
|
19
|
-
# ①
|
|
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
|
-
#
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
-
|
|
126
|
+
// 事件没带源 signal(少数实现)时按「谁已经中止」判来源:绝不能把超时当调用方中止
|
|
127
|
+
const given = ev && (ev.target || ev.currentTarget);
|
|
128
|
+
const src = given || (timeout.aborted ? timeout : outer);
|
|
84
129
|
try {
|
|
85
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/host/routes/asr.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
}
|