dsh-chubby-whale 1.1.48 → 1.1.50

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.50] - 2026-11-07
8
+
9
+ ### 修复(B61 菜单/动作分身)
10
+ - **「连播」开关两套状态打架**:动作按内存里的一次性 `auto` 取反,菜单标签却读 `localStorage`;整本讲完时 `auto` 被清 false 但不落盘 → 菜单显示「开」,点一下只是把它设回 true,要连点两下才关。现在动作与菜单同源读存储
11
+ - **「聊天缓存」开关连点双发**:乐观翻转后 `postJson` 未包 `try`,连点会发 2 个 POST、回执乱序时旧回滚盖掉新结果;`fetch` 同步抛错时本地永久停在错值。现在加在途锁 + 失败/抛错/8s 无回执统一回滚
12
+ - **菜单脏值不打勾**:`chatLang`/`ttsVoice` 未校验就当勾选源,手改或旧版本留下的脏值会让所有选项都不打勾,而运行时其实已回落 `auto`/晓伊。现在按归一化后的实际值勾选
13
+
14
+ ### 修复(B62 客户端说书分身)
15
+ - **P0 打不断在途的说书请求(P1 并发)**:`beginRound()` 把 `narrating` 置 false,而 P0 分支只看 `narrating || ttsPlaying`——请求在途时两者都假,于是不掐朗读也不清 `storyBusy`,响应回来后照样念,说书接在回答后面。现在 `beginRound` 即置 `narrating=true`,五条收尾路径都会清
16
+ - **整本讲完只关内存不落盘**:之前只改 `refs.auto`,菜单仍显示「连播:开」,下次「接着说」只讲一段就停。现在同时写 `localStorage` 并补一句「连播已关,想接着听点『接着说』」
17
+ - **超时被报成网络故障**:`AbortSignal.timeout(25000)` 的拒绝名是 `TimeoutError`(Node 24 实测 code 23),旧判断只认 `AbortError/20/2`,于是超时一律提示「说书连不上」。已补 `TimeoutError`/23
18
+
19
+ ### 测试与文档
20
+ - 新增 `scripts/test-chat-routes.mjs`(31)、`test-menu-actions.mjs`(33)、`test-story-client.mjs`(74),均接入 `npm test`
21
+ - 修正 `scripts/test.mjs` 头注释与断言矛盾:原写「右键菜单底部必须是版本号」,实际断言是「菜单不含版本项、版本只在标题栏」(2026-11-05 需求变更)
22
+ - 全链 **1895 项 0 失败**(1788 + 33 + 74)
23
+
24
+ > 渠道:1.1.49 只发了 Gitee / GitHub(npm 未发);1.1.50 起 Gitee + GitHub + npm 三端同步为 latest。
25
+
26
+ ## [1.1.49] - 2026-11-07
27
+
28
+ ### 修复(B60 聊天链路分身,4 个有复现证据的缺陷)
29
+ - **流式末帧丢失 → 假回复进长期记忆(P1)**:`parseSseBuffer` 只消费「以换行结尾」的行,流结束后残留缓冲从不 flush;上游最后一帧没有行尾换行时,正文与 usage 一起被丢,整条回复退化成「本鲸刚刚走神了」并写入记忆(实测记忆里出现 `[["主人今天吃白饭了吗","主人……本鲸刚刚走神了。"]]`、token 0 条)。现在把逐帧消费抽成 `consume()`,流结束对残留再消费一次
30
+ - **摘要计数器跨 handler 共享**:`summaryCount` 原在模块级,与 `/chat` 的「每 handler 一份」不一致;同进程两个实例会互相凑数、提前触发摘要。已移进 `createChatStreamHandler`
31
+ - **空白缓存被当命中**:流式只判 `hit.reply` 真值,而 `/chat` 判 `trim().length > 0`;缓存里若是纯空白回复,流式回「 」并跳过亲密度/经验/记忆/token 记账。两路已同口径
32
+ - **`/chat` 状态读写失败会吞掉已付费回复**:`loadState/saveState` 抛错时落进外层 catch,回「本鲸卡住了」,但模型调用与 `appendTurns` 都已完成 → 客户端重发造成记忆重复。现在只让 `intimacy=0`,回复照常返回(日志 `[chat] STATE-ERR`)
33
+ - `appendTurns` 缺失时由静默改为打 `[chat-stream] MEMORY-APPEND-MISSING`(与 `/chat` 同口径)
34
+
35
+ ### 测试
36
+ - 新增 `scripts/test-chat-routes.mjs`(31 条断言:末帧 flush / 计数隔离 / 空白缓存 / 状态失败 / 并发记账),接入 `npm test`
37
+ - 全链 **1788 项 0 失败**(1757 + 31)
38
+
39
+ ### 渠道(2026-11-07 主人指令:可达即同步)
40
+ - **npm 1.1.48 已发布**:`registry.npmjs.org` 返回 202(异步处理),约 4 分钟后可见;`dist-tags.latest = 1.1.48`,包 128 文件 / 17.1 MB
41
+ - **GitHub 已同步**:`github.com` 的 git 端口被重置,改走 `api.github.com`(Git Data API)上传 → `main = 77d1536`(246 文件),并建 tag `v1.1.48`(版本核对 1.1.48 ✅)
42
+ - 恢复 `tools/gh-sync.mjs` 与 `npm run sync:github`(1.1.43 曾按「只 Gitee」删除,现按条件同步政策恢复),《发布规范》第四节改写为「Gitee 主渠道 + 条件同步」,写入 202 / 2026-11-07 实测口径
43
+
7
44
  ## [1.1.48] - 2026-11-07
8
45
 
9
46
  ### 修复(B57 说书分身)
package/dist/client.js CHANGED
@@ -14,7 +14,7 @@
14
14
  }
15
15
 
16
16
  // src/client/version.ts
17
- var VERSION = "1.1.48";
17
+ var VERSION = "1.1.50";
18
18
 
19
19
  // src/client/styles.ts
20
20
  var W = 240;
@@ -2605,8 +2605,11 @@
2605
2605
  const voiceOn = state.voiceOn ? true : false;
2606
2606
  const earOn = state.earOn ? true : false;
2607
2607
  const voiceChatOn = state.voiceChatOn ? true : false;
2608
- const chatLang = state.chatLang ? String(state.chatLang) : "auto";
2609
- const ttsVoice = state.ttsVoice ? String(state.ttsVoice) : DEFAULT_VOICE;
2608
+ const chatLang = (() => {
2609
+ const v = state.chatLang ? String(state.chatLang) : "";
2610
+ return v === "zh" || v === "en" ? v : "auto";
2611
+ })();
2612
+ const ttsVoice = normalizeVoiceName(state.ttsVoice);
2610
2613
  const chatCacheOn = !(state.chatCacheOn === false);
2611
2614
  const voiceTick = (voice) => ttsVoice === voice ? "✓ " : "";
2612
2615
  const growthText = state && state.growth ? state.growth : "--";
@@ -4252,9 +4255,16 @@
4252
4255
  table.define("story-reset", (host) => {
4253
4256
  host.story.reset();
4254
4257
  });
4258
+ function storyAutoPref(host) {
4259
+ try {
4260
+ const s = localStorage.getItem("chubby-story-auto");
4261
+ return s === null ? true : s !== "0";
4262
+ } catch (e) {
4263
+ return host && host.storyState && host.storyState.auto ? !!host.storyState.auto.current : true;
4264
+ }
4265
+ }
4255
4266
  table.define("story-auto", (host) => {
4256
- const cur = host && host.storyState && host.storyState.auto ? !!host.storyState.auto.current : true;
4257
- const next = !cur;
4267
+ const next = !storyAutoPref(host);
4258
4268
  try {
4259
4269
  if (host.storyState && host.storyState.auto) host.storyState.auto.current = next;
4260
4270
  } catch (e) {
@@ -4857,6 +4867,8 @@
4857
4867
 
4858
4868
  // src/client/actions/system.ts
4859
4869
  function registerSystemActions(table) {
4870
+ let cacheToggleBusy = false;
4871
+ let cacheToggleSeq = 0;
4860
4872
  function syncFeed(host) {
4861
4873
  try {
4862
4874
  getJson("/pet?action=feed").then(() => {
@@ -4997,21 +5009,40 @@
4997
5009
  delJson("/cache").then((d) => host.bubble(d && d.ok ? "聊天缓存清空啦(" + (d.cleared || 0) + " 条)" : "缓存清空失败", 5e3)).catch(() => host.bubble("缓存清空失败", 4e3));
4998
5010
  });
4999
5011
  table.define("cache-toggle", (host) => {
5012
+ if (cacheToggleBusy) {
5013
+ host.bubble("正在切换聊天缓存,稍等~", 2500);
5014
+ return;
5015
+ }
5000
5016
  const nowOn = !host.chatCacheOn();
5001
5017
  host.setChatCacheOn(nowOn);
5002
- postJson("/cache-toggle", { enabled: nowOn }).then((d) => {
5003
- if (!d || !d.ok) {
5004
- host.setChatCacheOn(!nowOn);
5005
- host.bubble("切换失败", 4e3);
5006
- return;
5018
+ cacheToggleBusy = true;
5019
+ const my = ++cacheToggleSeq;
5020
+ let guard = null;
5021
+ let settled = false;
5022
+ const finish = (real, text) => {
5023
+ if (settled || my !== cacheToggleSeq) return;
5024
+ settled = true;
5025
+ if (guard) {
5026
+ clearT(guard);
5027
+ guard = null;
5007
5028
  }
5008
- const real = typeof d.enabled === "boolean" ? d.enabled : nowOn;
5029
+ cacheToggleBusy = false;
5009
5030
  host.setChatCacheOn(real);
5010
- host.bubble("聊天缓存:" + (real ? "开(省 token)" : "关(每次重新回答)"), 5e3);
5011
- }).catch(() => {
5012
- host.setChatCacheOn(!nowOn);
5013
- host.bubble("切换失败", 4e3);
5014
- });
5031
+ host.bubble(text, 5e3);
5032
+ };
5033
+ try {
5034
+ guard = setTimeout(() => finish(!nowOn, "切换失败"), 8e3);
5035
+ Promise.resolve(postJson("/cache-toggle", { enabled: nowOn })).then((d) => {
5036
+ if (!d || !d.ok) {
5037
+ finish(!nowOn, "切换失败");
5038
+ return;
5039
+ }
5040
+ const real = typeof d.enabled === "boolean" ? d.enabled : nowOn;
5041
+ finish(real, "聊天缓存:" + (real ? "开(省 token)" : "关(每次重新回答)"));
5042
+ }).catch(() => finish(!nowOn, "切换失败"));
5043
+ } catch (e) {
5044
+ finish(!nowOn, "切换失败");
5045
+ }
5015
5046
  });
5016
5047
  table.define("clear-memory", (host) => {
5017
5048
  let go = false;
@@ -6163,7 +6194,8 @@
6163
6194
  return s;
6164
6195
  }
6165
6196
  function isAbortError(err) {
6166
- return !!err && (err.name === "AbortError" || err.code === 20 || err.code === 2);
6197
+ if (!err) return false;
6198
+ return err.name === "AbortError" || err.name === "TimeoutError" || err.code === 20 || err.code === 23 || err.code === 2;
6167
6199
  }
6168
6200
  function storyErrorText(raw) {
6169
6201
  const e = str(raw).trim();
@@ -6219,7 +6251,7 @@
6219
6251
  clearGuard();
6220
6252
  const token = bumpGen();
6221
6253
  refs.busy.current = true;
6222
- refs.narrating.current = false;
6254
+ refs.narrating.current = true;
6223
6255
  return token;
6224
6256
  }
6225
6257
  function armDeadline(token, message) {
@@ -6307,7 +6339,11 @@
6307
6339
  }
6308
6340
  } else {
6309
6341
  refs.auto.current = false;
6310
- host.bubble(host.persona.line("poemDone"), 6e3);
6342
+ try {
6343
+ localStorage.setItem("chubby-story-auto", "0");
6344
+ } catch (e) {
6345
+ }
6346
+ host.bubble(host.persona.line("poemDone") + "(《" + String(d && d.book || refs.book.current || "这本") + "》讲完啦,连播已关;想接着听点「接着说」)", 8e3);
6311
6347
  }
6312
6348
  }
6313
6349
  });
@@ -46,10 +46,15 @@ const SSE_PING_MS = 12 * 1000; // SSE 心跳间隔
46
46
  const CHAT_MAX_BODY = 6 * 1024 * 1024;
47
47
  /** 图片超限文案:与 host/routes/chat.ts 的同一句保持一致(路由之间不横向 import,各持一份) */
48
48
  const IMAGE_TOO_LARGE_FALLBACK = '主人,这张图太大啦(原图超过约 3MB),本鲸一口吞不下,换张小一点的再发吧~';
49
- let summaryCount = 0;
50
49
  export function createChatStreamHandler(opts) {
51
50
  // 与普通 /chat 共用同一个装配值(index.ts 的 readChatModel),缓存 key 也带它
52
51
  const model = opts.model || DEFAULT_CHAT_MODEL;
52
+ /**
53
+ * 「每 10 次聊天压缩一次长期记忆」的计数器:与普通 /chat 一样**每个 handler 一份**。
54
+ * 原来放在模块级,等于全进程共享——同一个模块建两个 handler(测试、双插件实例)时,
55
+ * 第二个实例的成功请求会替第一个实例凑数,摘要提前触发(scripts/test-chat-routes.mjs 用例 B 复现)。
56
+ */
57
+ let summaryCount = 0;
53
58
  return async function handleChatStream(req, res) {
54
59
  // SSE 头:立刻发出,避免客户端等到第一个 token 才开始
55
60
  const started = Date.now();
@@ -211,7 +216,9 @@ export function createChatStreamHandler(opts) {
211
216
  if (opts.cache && cacheOn && cacheUsable) {
212
217
  try {
213
218
  const hit = await opts.cache.getJson(cacheKey, 'json', CHAT_CACHE_TTL);
214
- if (hit && typeof hit.reply === 'string' && hit.reply) {
219
+ // 判空口径与 /chat 一致(cached.reply.trim().length > 0):缓存里若是一条纯空白回复,
220
+ // 流式原来会当成命中,回一句「 」并把这一轮的记账全跳过(/chat 会正常走模型)。
221
+ if (hit && typeof hit.reply === 'string' && hit.reply.trim().length > 0) {
215
222
  // 缓存命中与 /chat 同口径:只加亲密度,不加经验、不写记忆、不 onReply
216
223
  let cachedIntimacy = 0;
217
224
  try {
@@ -294,6 +301,30 @@ export function createChatStreamHandler(opts) {
294
301
  let reply = '';
295
302
  let usage = null;
296
303
  let firstDeltaAt = 0;
304
+ /**
305
+ * 消费一批已解析的 SSE 帧。
306
+ * 正文优先;choices[0].delta.content 为空时取 reasoning_content 回退(新模型若忽略
307
+ * thinking:{type:'disabled'} 会只吐 reasoning_content,不补这条用户看到的就是空回复)。
308
+ * 抽成函数是因为流结束后还要对残留尾巴再消费一次(见下面的收尾 flush)。
309
+ */
310
+ const consume = (events) => {
311
+ for (const j of events) {
312
+ const d = j?.choices?.[0]?.delta;
313
+ let piece = '';
314
+ if (typeof d?.content === 'string' && d.content)
315
+ piece = d.content;
316
+ else if (typeof d?.reasoning_content === 'string' && d.reasoning_content)
317
+ piece = d.reasoning_content;
318
+ if (piece) {
319
+ if (!firstDeltaAt)
320
+ firstDeltaAt = Date.now();
321
+ reply += piece;
322
+ send({ delta: piece });
323
+ }
324
+ if (j?.usage)
325
+ usage = j.usage;
326
+ }
327
+ };
297
328
  try {
298
329
  for (;;) {
299
330
  const { value, done } = await reader.read();
@@ -303,25 +334,15 @@ export function createChatStreamHandler(opts) {
303
334
  buf += dec.decode(value, { stream: true });
304
335
  const parsedSse = parseSseBuffer(buf);
305
336
  buf = parsedSse.rest;
306
- for (const j of parsedSse.events) {
307
- // 正文优先;choices[0].delta.content 为空时取 reasoning_content 回退。
308
- // 新模型若忽略 thinking:{type:'disabled'},会只吐 reasoning_content 而正文为空——
309
- // 不补这条回退,用户看到的就是一条空回复(TTS 也念不出字)。
310
- const d = j?.choices?.[0]?.delta;
311
- let piece = '';
312
- if (typeof d?.content === 'string' && d.content)
313
- piece = d.content;
314
- else if (typeof d?.reasoning_content === 'string' && d.reasoning_content)
315
- piece = d.reasoning_content;
316
- if (piece) {
317
- if (!firstDeltaAt)
318
- firstDeltaAt = Date.now();
319
- reply += piece;
320
- send({ delta: piece });
321
- }
322
- if (j?.usage)
323
- usage = j.usage;
324
- }
337
+ consume(parsedSse.events);
338
+ }
339
+ // 收尾 flush:parseSseBuffer 只认「以换行结尾」的行,上游若在最后一个 data 行后直接断连接
340
+ // (没有行尾换行),整帧会原样留在 rest 里。不补这一刀的话,最后一段正文与 usage 帧被静默丢弃,
341
+ // 半截回复还会被当成完整回复:实测整条回复变成「主人……本鲸刚刚走神了。」并写进长期记忆。
342
+ buf += dec.decode(); // 冲掉分块边界上残留的多字节字符
343
+ if (buf.trim()) {
344
+ consume(parseSseBuffer(buf + '\n').events);
345
+ buf = '';
325
346
  }
326
347
  }
327
348
  catch (err) {
@@ -359,8 +380,12 @@ export function createChatStreamHandler(opts) {
359
380
  try {
360
381
  if (typeof opts.appendTurns === 'function')
361
382
  opts.appendTurns(message, text);
383
+ else
384
+ console.error('[chat-stream] MEMORY-APPEND-MISSING 没有 appendTurns,跳过记忆写入');
385
+ }
386
+ catch (err) {
387
+ console.error('[chat-stream] MEMORY-APPEND-ERR ' + String(err?.message || err));
362
388
  }
363
- catch { }
364
389
  // 亲密度 +1(与 /chat 同口径)
365
390
  let intimacy = 0;
366
391
  try {
@@ -15,8 +15,7 @@ import { DEFAULT_CHAT_MODEL } from '../config.js';
15
15
  import { CHAT_URL, CHAT_CACHE_TTL, CHAT_MAX_TOKENS, chatCacheKey, chatContextFingerprint, readChatRequest, buildChatMessages, summarizeMemory, } from '../chat-core.js';
16
16
  // 兼容旧导入方(scripts/selftest.mjs、chat-stream 历史版本):纯函数仍从本模块可导出
17
17
  export { HISTORY_IN_PROMPT, normalizeChatText, detectLang, recentHistory, chatCacheKey } from '../chat-core.js';
18
- const CHAT_TIMEOUT = 30 * 1000; // 主聊天:30 秒
19
- const SUMMARY_TIMEOUT = 20 * 1000; // 长期记忆摘要:20 秒
18
+ const CHAT_TIMEOUT = 30 * 1000; // 主聊天:30 秒(记忆摘要的超时在 chat-core.summarizeMemory 里)
20
19
  const CHAT_MAX_BODY = 6 * 1024 * 1024; // 6MB(图片 base64 约 4MB * 1.34)
21
20
  const NET_FALLBACK = '主人,本鲸一时想不起来话(网络或接口有点问题)……';
22
21
  /** 图片超限文案:与 host/routes/chat-stream.ts 的同一句保持一致(路由之间不横向 import,各持一份) */
@@ -165,9 +164,20 @@ export function createChatHandler(opts) {
165
164
  console.error('[chat] MEMORY-APPEND-ERR ' + String(err?.message || err));
166
165
  }
167
166
  // 亲密度 +1
168
- const st = opts.loadState();
169
- st.intimacy = Math.min(100, (Number(st.intimacy) || 0) + 1);
170
- opts.saveState(st);
167
+ // 端口读写必须自己 try 住(与 /chat-stream 同口径):loadState/saveState 抛错时,
168
+ // 不能把「已经花掉 token 换来的回复」吞成一条内部错误——模型调用、token、记忆写回都已完成,
169
+ // 这时回 ok:false 会让客户端把同一条消息重发一次,记忆里出现重复问答。
170
+ // 状态失败只让 intimacy 回 0,回复照常返回。
171
+ let intimacy = 0;
172
+ try {
173
+ const st = opts.loadState();
174
+ st.intimacy = Math.min(100, (Number(st.intimacy) || 0) + 1);
175
+ opts.saveState(st);
176
+ intimacy = Number(st.intimacy) || 0;
177
+ }
178
+ catch (err) {
179
+ console.error('[chat] STATE-ERR ' + String(err?.message || err));
180
+ }
171
181
  // 成长 +1(来源 chat,受日上限与来源上限约束,可能升级)
172
182
  // 既有口径:空回复(content/reasoning 都空、已回退到兜底文案)同样计 1 次经验、1 次 L2。
173
183
  // 审计已确认这是取舍而非漏判;要改成「空回复不计」属行为变更,本次未动。
@@ -192,7 +202,7 @@ export function createChatHandler(opts) {
192
202
  convo = lines.join('\n');
193
203
  }
194
204
  const saveSummary = opts.saveMemorySummary;
195
- sendJson(res, { ok: true, reply: reply || '主人……本鲸刚刚走神了。', intimacy: st.intimacy, upgraded });
205
+ sendJson(res, { ok: true, reply: reply || '主人……本鲸刚刚走神了。', intimacy, upgraded });
196
206
  // 缓存落盘放在回包之后:cache.setJson 内部还要 await 磁盘写 + prune 目录扫描,
197
207
  // 放在回包前会把主人可见的延迟拖上几十~几百毫秒。缓存本身已在 setJson 开头同步进内存,
198
208
  // 并发请求照样能命中,不损失命中率。
@@ -8,18 +8,19 @@
8
8
  >
9
9
  > 本文取代 `docs/发布流程.md`。后者按「Gitee / GitHub / npm 三渠道同步」编写,渠道已废止,仅作历史记录。
10
10
 
11
- ## 〇、当前基线(2026-11-06 实读)
11
+ ## 〇、当前基线(2026-11-07 实读)
12
12
 
13
13
  | 项 | 值 |
14
14
  | --- | --- |
15
- | `package.json` version | 1.1.10 |
16
- | `src/client/version.ts` VERSION | 1.1.10(由 `scripts/gen-version.mjs` 生成) |
17
- | 最近 tag | `v1.1.10`(提交 `b47a338`) |
18
- | `main` HEAD | 见 `git log -1`;未发版内容记在 CHANGELOG「未发布」 |
19
- | README 版本 | 无硬编码版本号(已去掉 npm 徽章,只留 Gitee) |
20
- | `npm test` | 942 项:740 单测 + 59 限额 + 58 流式记账 + 17 说书安全 + 50 语音契约 + 18+18 冒烟 + 32 平台 |
21
- | `npm pack --dry-run` | 116 个文件,16.6 MB(包);`data/cache`、运行数据、`config.json`、`src/`、`scripts/` 均不进包 |
22
-
15
+ | `package.json` version | 1.1.48 |
16
+ | `src/client/version.ts` VERSION | 1.1.48(由 `scripts/gen-version.mjs` 生成) |
17
+ | 最近 Gitee tag | `v1.1.48`(提交 `d58d078`) |
18
+ | Gitee `main` HEAD | `34bd8c3`(含渠道政策落档) |
19
+ | GitHub `main` | `1d98e86`;tag `v1.1.48` → `77d1536`(版本核对 1.1.48) |
20
+ | npm latest | **1.1.48**(2026-11-07 实读;发布返回 202,约 4 分钟后可见) |
21
+ | README 版本 | 无硬编码版本号 |
22
+ | `npm test` | **1757 项**:861 单测 + 59 限额 + 58 流式 + 17 说书安全 + 110 行为回归 + 50 语音契约 + 18 客户端冒烟 + 18 宿主冒烟 + 171 语音队列 + 32 TTS 引擎 + 79 数据层 + 112 仓储 + 83 人格 + 57 说书 + 32 平台 |
23
+ | `npm pack --dry-run` | **128 个文件 / 17.1 MB**(包);`data/cache`、运行数据、`config.json`、`src/`、`scripts/`、`tools/` 均不进包 |
23
24
  ## 一、版本号规则
24
25
 
25
26
  采用语义化版本 `主.次.修订`。
@@ -98,19 +99,49 @@ fix(host): 写盘失败打印 WRITE-FAIL 日志
98
99
  磁盘满或权限问题此前静默失败,事后无法定位;关联 docs/坑.md 与 docs/待重启生效.md 第 5 条。
99
100
  ```
100
101
 
101
- ## 四、发布:只 Gitee
102
+ ## 四、发布渠道(2026-11-07 起:Gitee 主渠道 + 条件同步)
103
+
104
+ > **2026-11-07 主人指令**:默认仍以 Gitee 为唯一主渠道;**若 GitHub / npm 可达,则同步发布最新版本**。
105
+ > 本机实测(同一时刻测的,不是推测):
106
+ > - `github.com` 的 git 端口被重置(`Recv failure: Connection was reset`,HTTP 000);`api.github.com` 返回 200 → **GitHub 只能走 API 上传**。
107
+ > - `registry.npmjs.org` 可达,`npm whoami` = `adengou`,`npm ping` PONG → 可发布。
108
+
109
+ ### 4.1 Gitee(默认,必做)
102
110
 
103
111
  ```bash
104
- cd C:\Users\Lenovo\.dsh\plugins\dsh-chubby-whale
105
112
  git push origin main --tags
106
113
  ```
107
114
 
108
- - 只推 Gitee:`origin` 指向 `https://gitee.com/adengou/dsh-chubby-whale`。
109
- - **不上 GitHub**:本机 `github.com` 不可达,插件也不需要从 GitHub 分发。
110
- - **不发 npm**:npm 包冻结在 1.0.1;宿主插件由 Gitee 仓库以 `git+https://gitee.com/adengou/dsh-chubby-whale.git` 直装,npm 包与仓库的 `data/`、`dist/`、`docs/` 会分叉,且发布需维护 token 与二次 2FA。
111
- - `package.json` 的 `repository` / `homepage` / `bugs` 已指向 Gitee,与本节一致。
112
- - 冻结命令:`npm publish`(与「只 Gitee」冲突,禁止)。`npm run sync:github` 与 `tools/gh-sync.mjs` 已于 1.1.43 删除;`npm run version:check` 已于 1.1.43 改为**只读本地 + Gitee**(`--offline` 可断网自检),不再探测 GitHub / npm,可正常作为发版自检项。
115
+ - `origin` 指向 `https://gitee.com/adengou/dsh-chubby-whale`。
116
+ - 回滚一律用 `revert`,禁止 force push `main`。
117
+
118
+ ### 4.2 GitHub(API 通道,可达时同步)
119
+
120
+ ```bash
121
+ # 凭据:git 凭据管理器里的 github.com PAT(不落盘、不打印)
122
+ GH_TOKEN=<pat> node tools/gh-sync.mjs --dry-run # 先看差异
123
+ GH_TOKEN=<pat> node tools/gh-sync.mjs # 真传(Git Data API,逐文件 diff)
124
+ ```
125
+
126
+ - 上传后**必须建 tag**(GitHub API 不会自动带 tag):`POST /repos/{owner}/{repo}/git/refs`,`ref=refs/tags/vX.Y.Z`、`sha=<main 的 commit>`。
127
+ - 校验:`GET /repos/{owner}/{repo}/contents/package.json?ref=vX.Y.Z`,解 base64 后 version 必须等于 `package.json`。
128
+ - 2026-11-07 实测:`main` = `77d1536`(246 文件),tag `v1.1.48` 指向同一提交,版本核对 1.1.48 ✅。
129
+
130
+ ### 4.3 npm(可达时同步)
131
+
132
+ ```bash
133
+ npm publish --registry=https://registry.npmjs.org --no-audit --no-fund
134
+ ```
135
+
136
+ - **返回 HTTP 202 是正常的**:npm 采用异步处理,`registry.npmjs.org/<包>/<版本>` 在 1–5 分钟内才可能返回 200;
137
+ 立即 `npm view` 会看到旧版本,**不要当成失败**(2026-11-07 实测:202 接收 → 约 4 分钟后可见)。
138
+ - 复核口径:`npm view dsh-chubby-whale version --registry=https://registry.npmjs.org` 与 `dist-tags.latest`。
139
+ - `package.json` 的 `files` 白名单决定包里内容(2026-11-07 实测 128 文件 / 17.1 MB)。
140
+
141
+ ### 4.4 失败处理
113
142
 
143
+ - 任一外渠失败**不影响 Gitee 发布**;把现象(HTTP 码 / 报错原文)写进 CHANGELOG 的「未发布」或批次表,不粉饰。
144
+ - 冻结项:`npm publish` 之外的 npm 写操作(如 `npm deprecate`)仍需单独确认。
114
145
  ## 五、发布后校验(安装完整性)
115
146
 
116
147
  坑 34 的教训:安装副本漏 `dist/shared/immutable.js`、`shared/noise.js`,dsh web 重启后整个插件 `ERR_MODULE_NOT_FOUND`、不激活。发布后必须做下面三项。
@@ -256,4 +256,23 @@ Measure-Command { Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3080/api/
256
256
 
257
257
  1. `curl -s http://127.0.0.1:3080/api/chubby-whale/status` 正常;右键菜单版本号应为 **1.1.44**(刷新即见,不必重启);
258
258
  2. 点一句朗读:首段可能 3–5 秒(worker 冷启动 + 解释器探测),之后每段应明显变快;连续点不应再出现成段的 python traceback;
259
- 3. 断网再恢复后第一次朗读应自动恢复(闸门 60 秒到期即清零),不需要重装插件。
259
+ 3. 断网再恢复后第一次朗读应自动恢复(闸门 60 秒到期即清零),不需要重装插件。
260
+
261
+ ## 十五、2026-11-07 凌晨批次(B54–B57:1.1.45 → 1.1.48)待重启核对
262
+
263
+ > 覆盖:打断词放行、朗读队列上限、仓储五修、人格四修、说书分段与 isLast。
264
+ > 客户端改动刷新(`Ctrl+F5`)即可;宿主改动需重启 dsh web。
265
+
266
+ | # | 改动 | 涉及文件 | 为什么 | 怎么验证 |
267
+ |---|------|----------|--------|----------|
268
+ | 1 | 单字打断词「停/别/不/闭嘴/打住」放行且不写缓存 | `shared/noise.ts`、`host/routes/asr.ts` | 原先单字一律判噪声,喊停永远收不到 | 语音对话中说「停」:朗读立刻中断(不再等整段念完) |
269
+ | 2 | 朗读队列上限 200(丢最旧待播段,计 `stats.qDropped`) | `client/voice.ts`、`client/voice/tts-queue.ts` | 断网时队列无限堆积 | 说书连播 + 断网:内存不持续涨,`__chubbyWhale` 里 `qDropped` 有计数 |
270
+ | 3 | 仓储五修(鱼粮首写丢今日计数 / 重复提醒连响 / 坏提醒溢出 RangeError / 亲密度负值) | `host/repos/*` | 新装第一天计数必丢、提醒连响、一条坏数据废全部提醒 | `node scripts/test-repos.mjs`;`reminders.json` 里 `repeat=60` 的逾期项只响一次 |
271
+ | 4 | 人格四修(L2「我是 AI」漏网 / `at` 类型脏值 / 阈值 0 与负值反转 / 截断半个 emoji) | `host/personality*`、`host/personality/*` | 自述红线是安全项,落盘会进系统提示词 | `node scripts/test-personality.mjs`;导入含 `at:{a:1}` 的备份不再出现 `[object Object]` |
272
+ | 5 | 说书分段推导(piece/total 由标题分组算)、`isLast` 只收紧、结尾句按回末出 | `host/story/player.ts`、`host/story/repo.ts` | 数据没有 piece 字段导致 piece 恒 1、书中间误判「讲完了」→ 连播刚开口就停 | `node scripts/test-story-player.mjs`;儒林外史连播不再第 0 段就停 |
273
+
274
+ ### 重启后自检(3 条)
275
+
276
+ 1. 右键菜单版本号显示 **1.1.48**(刷新即见,不必重启);
277
+ 2. 说书连播一本(如儒林外史)能连续往下走,不再第一段就报「讲完了」;
278
+ 3. 语音对话说「停」能立刻打断;连播长书后 `qDropped` 不应持续增长(正常朗读远达不到 200 段)。
@@ -101,3 +101,7 @@
101
101
  | B55 | 2026-11-06 | 朗读队列上限:`TTS_QUEUE_MAX=200` + `capQueue()` 纯函数(丢最旧待播段、`stats.qDropped` 计数);12 条边界断言;**一次自我回退**(预取失败不计连败 → verify-voice 抓出降级变慢,已还原并只修注释);版本 1.1.46 | 双 tsc 0 / build 0 / **1505 项 0 失败** / 接口清单 117 模块 709 导出 | 见 git |
102
102
  | B56 | 2026-11-07 | **分身批次②**:仓储 5 修(鱼粮首写丢计数 / 27 提醒连响 / 一条坏提醒废全部 / repeat 有限正整数 / 亲密度越喂越负)+ 人格 4 修(L2「我是 AI」红线漏网 / 历史 at 类型脏值 / 阈值 0 与负值反转 / 截断切出半个 emoji);主代理补口:记忆写盘失败打 WRITE-FAIL、备份导入 repeat 限 60s~7 天;新增 `test-repos.mjs`(112) + `test-personality.mjs`(83) 并入 npm test;版本 1.1.47 | 双 tsc 0 / build 0 / **1700 项 0 失败** / 接口清单 117 模块 709 导出 | 见 git |
103
103
  | B57 | 2026-11-07 | 说书分身:分段位置按标题分组推导(回内序号 / 全书累计序号),修「连播一本书刚开口就停」(18 段 total===1 误判 isLast);结尾句改按回末出;`repo` 并发去重各给一份数组;新增 `test-story-player.mjs`(57) 并入 npm test;版本 1.1.48 | 双 tsc 0 / build 0 / **1757 项 0 失败** / 接口清单 117 模块 709 导出 | 见 git |
104
+ | B58 | 2026-11-07 | 渠道政策落地:按主人指令「GitHub/npm 可达即同步」——npm 发布 1.1.48(202 → 4 分钟后可见,latest=1.1.48);GitHub 因 git 端口被重置改走 API,`main=77d1536`(246 文件)+ tag `v1.1.48` 版本核对通过;恢复 `tools/gh-sync.mjs` 与 `sync:github`,发布规范第四节改写并写入实测口径 | 全链 1757 项 0 失败(未改代码,仅工具/文档) | 见 git |
105
+ | B60 | 2026-11-07 | 聊天链路分身:流式末帧 flush(假回复进记忆)/ 摘要计数器移入 handler / 空白缓存判空同口径 / `/chat` 状态读写 try 包住(不吞已付费回复)/ 记忆缺失日志;新增 `test-chat-routes.mjs`(31) 并入 npm test;版本 1.1.49 | 双 tsc 0 / build 0 / **1788 项 0 失败** / 接口清单 117 模块 709 导出 | 见 git |
106
+ | B61 | 2026-11-07 | 菜单/动作分身:连播开关与菜单同源读存储(修「点一下关不掉」)/ cache-toggle 加在途锁与失败回滚(连点双发、回执乱序)/ chatLang·ttsVoice 脏值归一;新增 `test-menu-actions.mjs`(33);版本 1.1.50 | 双 tsc 0 / build 0(+912B) / 1895 项 0 失败 | 见 git |
107
+ | B62 | 2026-11-07 | 客户端说书分身:`beginRound` 即置 narrating(P0 在途可打断,修「回答后面接着说书」)/ 整本讲完把连播写 `0` / `TimeoutError` 归入超时;新增 `test-story-client.mjs`(74);顺带修正 `test.mjs` 头注释与断言矛盾;版本 1.1.50 | 双 tsc 0 / build 0(+328B) / 1895 项 0 失败 | 见 git |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-chubby-whale",
3
- "version": "1.1.48",
3
+ "version": "1.1.50",
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/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/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",
@@ -82,7 +82,8 @@
82
82
  "smoke:host": "node scripts/smoke-host.mjs",
83
83
  "bench:tts": "node tools/tts-bench.mjs",
84
84
  "bench:voice": "node tools/voice-bench.mjs",
85
- "check:installed": "node tools/check-installed.mjs"
85
+ "check:installed": "node tools/check-installed.mjs",
86
+ "sync:github": "node tools/gh-sync.mjs"
86
87
  },
87
88
  "repository": {
88
89
  "type": "git",