dsh-chubby-whale 1.1.65 → 1.1.66
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 +21 -0
- package/dist/client.js +29 -30
- package/dist/host/http.js +121 -8
- package/dist/host/routes/chat-stream.js +58 -22
- package/dist/host/routes/chat.js +38 -4
- package/dist/host/routes/tts.js +113 -3
- package/dist/host/tts/spawn.js +40 -4
- package/dist/host/tts/worker.js +62 -10
- package/dist/host/tts-engine.js +99 -5
- 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 +5 -5
- package/docs//345/276/205/351/207/215/345/220/257/347/224/237/346/225/210.md +11 -0
- package/docs//346/250/241/345/235/227/346/216/245/345/217/243/346/270/205/345/215/225.md +3 -3
- package/docs//350/267/250/345/271/263/345/217/260/344/270/216/345/210/206/345/267/245.md +3 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@
|
|
|
4
4
|
|
|
5
5
|
## [未发布]
|
|
6
6
|
|
|
7
|
+
## [1.1.66] - 2026-11-07
|
|
8
|
+
|
|
9
|
+
### 修复(B94 HTTP 错误分类与聊天断连分身)
|
|
10
|
+
- **`isTimeoutError` 把「客户端走了」与「上游太慢」混为一类**:日志分不清,路由也没法据此分流。新增 `isAbortError` / `isTimeoutOnly`(旧函数语义原样保留,不破既有断言)
|
|
11
|
+
- **`/chat` 不接断连**:客户端走了仍把模型跑完、且把 AbortError 记成 `CHAT-TIMEOUT`;请求体断连还会往死连接回 200 兜底包。现在断连即 abort 上游、**半截不记账**(`resolve` 出的完整回复仍照常记账——已付费的语义不变)
|
|
12
|
+
- **`/chat-stream` 漏 `req` 侧断连信号**、每请求留一个 `res close` 监听不摘、断连后仍往死 socket 写 `stream: client closed` 帧
|
|
13
|
+
- `http.ts` 新增公共 `watchClientGone`(三形状判定,正常结束有 `writableFinished` 守护)
|
|
14
|
+
|
|
15
|
+
### 修复(B94 TTS 断连中止分身)
|
|
16
|
+
- **客户端走了仍在合成**:`/tts` 完全没有断连检测,`TtsEngine`/`cloudTts` 也没有 signal 通道。现在 signal 一路贯到 worker/CLI/云端
|
|
17
|
+
- 取舍写在代码里:edge-tts **共享 worker 不杀**(杀掉会连带失败并发段并重付启动),只把本次从 pending 摘除、产物作废;CLI 回退路径本请求独占 python,abort 直接 kill;临时文件 `EBUSY` 时 5s 补删一次
|
|
18
|
+
- abort 的请求**不记账、不写缓存、不进降级闸、不回退 edge-tts**,只留一行 `TTS-CLIENT-ABORT` 诊断
|
|
19
|
+
|
|
20
|
+
### 修复(主代理)
|
|
21
|
+
- **整句打断词表三处各抄一份**:`echo-filter` 认 19 条、`voice-bridge` 只认 8 条 → 「停下来吧」在闸门能放行、到桥接却认不出。统一到 `shared/noise.ts` 的 `INTERRUPT_PHRASES` + `isInterruptCommand()`
|
|
22
|
+
- `intent.ts` 头部「纯函数」口径修正:判定同步,但会触发一次 `ensurePoemTitles()` 后台预热(有网络副作用),单测须 stub
|
|
23
|
+
|
|
24
|
+
### 测试
|
|
25
|
+
- 新增 `test-chat-abort.mjs`(51)、`test-tts-abort.mjs`(61),接入 `npm test`;`test-shared-voice` +7
|
|
26
|
+
- 全链 **4033 项 0 失败**(`node tools/test-count.mjs`:56 个套件,失败 0,未识别 0)
|
|
27
|
+
|
|
7
28
|
## [1.1.65] - 2026-11-07
|
|
8
29
|
|
|
9
30
|
### 修复(B93 音色白名单同源分身)
|
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.66";
|
|
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
|
+
}
|
|
@@ -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();
|
|
@@ -347,7 +370,11 @@ export function createChatStreamHandler(opts) {
|
|
|
347
370
|
}
|
|
348
371
|
catch (err) {
|
|
349
372
|
aborted = true;
|
|
350
|
-
|
|
373
|
+
// 客户端先走了:这是我们自己 abort 的,不是上游故障;连接已断,也别再往死 socket 上写 error 帧
|
|
374
|
+
if (client.gone())
|
|
375
|
+
console.error(LOG_CLIENT_ABORT_OUTBOUND);
|
|
376
|
+
else
|
|
377
|
+
send({ error: 'stream: ' + (abortReason || String(err?.message || err)) });
|
|
351
378
|
}
|
|
352
379
|
finally {
|
|
353
380
|
// 流已结束(正常或异常),超时定时器再留着只会误伤后续收尾
|
|
@@ -355,20 +382,24 @@ export function createChatStreamHandler(opts) {
|
|
|
355
382
|
}
|
|
356
383
|
// 上游已读完:后面的收尾与客户端断开无关,close 事件不再 abort
|
|
357
384
|
finished = true;
|
|
358
|
-
//
|
|
359
|
-
|
|
385
|
+
// 流中断或客户端已断开:这段回复是半截的,绝不能写进缓存和记忆(否则被永久命中)。
|
|
386
|
+
// 注意这里保持保守口径:客户端在流读取途中断开一律不落盘 —— 既有用例
|
|
387
|
+
// (test-stream-ledger ③B / test-chat-limits ⑦B)钉的就是这一条;「完整回复只是回不去」
|
|
388
|
+
// 的记账口径属于普通 /chat 那条同步路径(它没有半截概念),两边不混用。
|
|
389
|
+
if (aborted || client.gone()) {
|
|
390
|
+
if (client.gone())
|
|
391
|
+
console.error('[chat-stream] CHAT-CLIENT-ABORT 客户端已断开:本轮不写缓存/记忆、不记 token、不加经验');
|
|
360
392
|
try {
|
|
361
393
|
res.end();
|
|
362
394
|
}
|
|
363
395
|
catch { }
|
|
364
|
-
;
|
|
365
396
|
return;
|
|
366
397
|
}
|
|
367
398
|
const text = reply.trim() || '主人……本鲸刚刚走神了。';
|
|
368
399
|
// 收尾:token / 记忆 / done(与普通 /chat 同口径)
|
|
369
400
|
// token 口径:
|
|
370
401
|
// · 请求体已带 stream_options.include_usage,usage 在 [DONE] 之前的最后一帧里给,拿到就记;
|
|
371
|
-
// · 中断(空闲/总时长超时、客户端断开、上游异常)在上面的 aborted/
|
|
402
|
+
// · 中断(空闲/总时长超时、客户端断开、上游异常)在上面的 aborted / client.gone() 分支已 return,
|
|
372
403
|
// 不计 token —— 半截流没有最终 usage,估算只会虚记,宁可漏记;
|
|
373
404
|
// · 上游不回 usage(部分兼容服务不实现)时同样跳过,不猜。
|
|
374
405
|
try {
|
|
@@ -472,15 +503,20 @@ export function createChatStreamHandler(opts) {
|
|
|
472
503
|
}
|
|
473
504
|
catch (err) {
|
|
474
505
|
try {
|
|
475
|
-
|
|
506
|
+
// 请求体都没收完客户端就断了(req 'aborted' / ECONNRESET):不算内部错误,也不往死连接写 error 帧
|
|
507
|
+
if (client.gone())
|
|
508
|
+
console.error(LOG_CLIENT_ABORT_REQUEST);
|
|
509
|
+
else
|
|
510
|
+
send({ error: String(err?.message || err) });
|
|
476
511
|
res.end();
|
|
477
512
|
}
|
|
478
513
|
catch { }
|
|
479
514
|
}
|
|
480
515
|
finally {
|
|
481
|
-
// 所有退出路径统一收口:心跳 interval
|
|
516
|
+
// 所有退出路径统一收口:心跳 interval、两个超时定时器、req/res 上的断连监听,一个都不许残留
|
|
482
517
|
clearTimers();
|
|
483
518
|
stopPing();
|
|
519
|
+
client.dispose();
|
|
484
520
|
}
|
|
485
521
|
};
|
|
486
522
|
}
|
package/dist/host/routes/chat.js
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
* host/routes/chat.ts —— 聊天路由(可复用模块)
|
|
3
3
|
* 外呼统一走 host/http.ts:主聊天 30 秒、记忆摘要 20 秒(后台跑,不吊住回复);
|
|
4
4
|
* 请求体上限 6MB,单图 base64 上限 4MB(≈3MB 原图,超限回 413,与流式同一口径)
|
|
5
|
+
* 客户端断连(30s 硬超时/切页/断网)立刻中止外呼,不把模型跑完、不记账(B94):
|
|
6
|
+
* · 中途被 abort:没有完整回复 → 不写记忆、不记鱼粮、不回包(日志是 CHAT-CLIENT-ABORT);
|
|
7
|
+
* · 回复已完整拿到、只是回不去(abort 晚到一步):照常记账/写记忆 —— 已经付过费,
|
|
8
|
+
* 丢掉反而让记忆里少一轮真实对话。两条口径都由 scripts/test-chat-abort.mjs 钉住。
|
|
5
9
|
* 纯函数(语言判定/缓存 key/prompt 组装)在 host/chat-core.ts,流式路由共用同一份;
|
|
6
10
|
* 本文件只做 HTTP 编排与「缓存 → 模型 → 记忆/成长」的调用顺序。
|
|
7
11
|
*
|
|
8
12
|
* 模型名不写死:由 index.ts 用 readChatModel 按配置算好,经 deps.model 注入;
|
|
9
13
|
* max_tokens 预算见 chat-core.ts 的 CHAT_MAX_TOKENS / SUMMARY_MAX_TOKENS。
|
|
10
14
|
*/
|
|
11
|
-
import { fetchJson, isTimeoutError, sendTooLarge } from '../http.js';
|
|
15
|
+
import { fetchJson, isAbortError, isTimeoutError, isTimeoutOnly, sendTooLarge, watchClientGone } from '../http.js';
|
|
12
16
|
import { readJsonBody } from '../body.js';
|
|
13
17
|
import { sendJson, sendError } from '../respond.js';
|
|
14
18
|
import { DEFAULT_CHAT_MODEL } from '../config.js';
|
|
@@ -20,12 +24,23 @@ const CHAT_MAX_BODY = 6 * 1024 * 1024; // 6MB(图片 base64 约 4MB * 1.34)
|
|
|
20
24
|
const NET_FALLBACK = '主人,本鲸一时想不起来话(网络或接口有点问题)……';
|
|
21
25
|
/** 图片超限文案:与 host/routes/chat-stream.ts 的同一句保持一致(路由之间不横向 import,各持一份) */
|
|
22
26
|
const IMAGE_TOO_LARGE_FALLBACK = '主人,这张图太大啦(原图超过约 3MB),本鲸一口吞不下,换张小一点的再发吧~';
|
|
27
|
+
/**
|
|
28
|
+
* 客户端已经走了的日志标签(B94):与上游超时/网络错误分开,排障时一眼能分清谁先放弃。
|
|
29
|
+
* 分两条是因为时机差一步:外呼发出后断开(中止外呼)/ 请求体还没读完就断开(根本没发起外呼)。
|
|
30
|
+
* 文案刻意不写 CHAT-TIMEOUT / CHAT-NETWORK 这两个标签名(与 /asr 的「不计入 ASR 失败」同口径):
|
|
31
|
+
* 否则按标签 grep 日志时会自己命中自己。
|
|
32
|
+
*/
|
|
33
|
+
const LOG_CLIENT_ABORT_OUTBOUND = '[chat] CHAT-CLIENT-ABORT 客户端已断开,上游外呼已中止(不计入上游超时/网络失败)';
|
|
34
|
+
const LOG_CLIENT_ABORT_REQUEST = '[chat] CHAT-CLIENT-ABORT 客户端已断开,请求中止(未发起外呼,不计入上游失败)';
|
|
23
35
|
export function createChatHandler(opts) {
|
|
24
36
|
let summaryCount = 0;
|
|
25
37
|
let summarizing = false;
|
|
26
38
|
// 装配值优先;直接构造 handler(离线测试/旧调用方)时回落到配置模块的默认模型
|
|
27
39
|
const model = opts.model || DEFAULT_CHAT_MODEL;
|
|
28
40
|
return async function handleChat(req, res) {
|
|
41
|
+
// 先挂断连观察器:读 body → 外呼 → 写记忆的整段都在它的看护下,结束时必须 dispose(finally)。
|
|
42
|
+
// 客户端 30s 硬超时/切页/断网先走了以后,宿主不再把这次付费外呼跑满、也不把半截状态记进账(B94)。
|
|
43
|
+
const client = watchClientGone(req, res);
|
|
29
44
|
try {
|
|
30
45
|
const parsed = await readJsonBody(req, CHAT_MAX_BODY);
|
|
31
46
|
if (!parsed.ok) {
|
|
@@ -119,7 +134,7 @@ export function createChatHandler(opts) {
|
|
|
119
134
|
temperature: 0.8,
|
|
120
135
|
thinking: { type: 'disabled' },
|
|
121
136
|
}),
|
|
122
|
-
}, CHAT_TIMEOUT);
|
|
137
|
+
}, CHAT_TIMEOUT, client.signal);
|
|
123
138
|
if (!r.ok) {
|
|
124
139
|
console.error('[chat] CHAT-HTTP ' + r.status + ' ' + String(r.text || '').slice(0, 200));
|
|
125
140
|
sendJson(res, { ok: false, error: `HTTP ${r.status}`, reply: NET_FALLBACK });
|
|
@@ -128,11 +143,21 @@ export function createChatHandler(opts) {
|
|
|
128
143
|
data = r.data;
|
|
129
144
|
}
|
|
130
145
|
catch (err) {
|
|
131
|
-
//
|
|
132
|
-
|
|
146
|
+
// 客户端先走了(30s 硬超时/切页/断网):这次外呼是**我们自己** abort 的,不是上游超时、
|
|
147
|
+
// 也不是网络故障。处理口径与 /asr 一致:不回包(连接已断,没人收)、不写记忆、不记账,
|
|
148
|
+
// 只留专用诊断(B94:此前这里会把它记成 CHAT-TIMEOUT,排障时分不清是谁先放弃的)。
|
|
149
|
+
if (client.gone()) {
|
|
150
|
+
console.error(LOG_CLIENT_ABORT_OUTBOUND);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// 分类口径(host/http.ts):内部超时 = TimeoutError;调用方中止但没标 gone = AbortError;其余算网络错
|
|
154
|
+
if (isTimeoutOnly(err))
|
|
133
155
|
console.error('[chat] CHAT-TIMEOUT ' + CHAT_TIMEOUT + 'ms 无响应,已中止');
|
|
156
|
+
else if (isAbortError(err))
|
|
157
|
+
console.error('[chat] CHAT-ABORT 外呼被中止(无客户端断连标记)');
|
|
134
158
|
else
|
|
135
159
|
console.error('[chat] CHAT-NETWORK ' + String(err?.message || err));
|
|
160
|
+
// 回给客户端的 error 字段沿用旧口径(超时/中止类都算 timeout),不改客户端可见语义
|
|
136
161
|
sendJson(res, { ok: false, error: isTimeoutError(err) ? 'timeout' : String(err?.message || err), reply: NET_FALLBACK });
|
|
137
162
|
return;
|
|
138
163
|
}
|
|
@@ -251,9 +276,18 @@ export function createChatHandler(opts) {
|
|
|
251
276
|
}
|
|
252
277
|
catch (err) {
|
|
253
278
|
try {
|
|
279
|
+
// 请求体都没收完客户端就断了(req 'aborted' / ECONNRESET):不算内部错误,也不往死连接写兜底包
|
|
280
|
+
if (client.gone()) {
|
|
281
|
+
console.error(LOG_CLIENT_ABORT_REQUEST);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
254
284
|
sendError(res, err, { reply: '主人,本鲸卡住了(内部错误)……' });
|
|
255
285
|
}
|
|
256
286
|
catch { }
|
|
257
287
|
}
|
|
288
|
+
finally {
|
|
289
|
+
// 结束就摘监听:已结束的 handler 没必要继续响应 'close'/'aborted'(与 /asr 同一收口口径)
|
|
290
|
+
client.dispose();
|
|
291
|
+
}
|
|
258
292
|
};
|
|
259
293
|
}
|