dsh-chubby-whale 1.1.53 → 1.1.56

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,49 @@
4
4
 
5
5
  ## [未发布]
6
6
 
7
+ ## [1.1.56] - 2026-11-07
8
+
9
+ ### 修复(B69 动作剩余分身)
10
+ - **叙述锁被旧 onDone 提前解掉**:`content.ts` 里每个朗读 onDone 都直接 `clearNarrate()`,而 TTS 是排队串行的——「今日一句没念完就点今日简报」时,前一段播完会把简报刚上的锁解掉,朗读还在响、麦克风提前拾音。现在锁按**代次**归属,只解自己且只解一次;`speak` 同步抛错也当场放锁
11
+ - **「选本书」无防连点**:连点会并发两个请求、后到的气泡顶掉前者;已补 `beginOnce`
12
+ - **导入没有二次确认**:备份导入是整字段覆盖、无撤销入口;现在落盘前确认(可注入 `confirm`,无对话框环境不拦)
13
+ - **413 文案误判**:导入走 `sendTooLarge` 回 `too large`(小写),而客户端只认 `TOO_LARGE` → 超限被说成「备份文件读不了」;两处归一
14
+ - 换唤醒词的气泡不再谎报生效时机(配合下方根因修复)
15
+
16
+ ### 修复(B70 杂项路由分身)
17
+ - **`/config` 可能回显 Key**:注入方把 Key 读取器当布尔传进来时,响应会原样带回 `sk-live-...`;现在所有布尔字段走 `safeBool`
18
+ - **`/stats` 亲密度与 `/state` 不同口径**(250 vs 100)、NaN 写进 JSON(`uptimeMs:null`)、`tts`/`backup` 自检字段原样透传怪值;已逐项归一
19
+ - **`/briefing` 无方法校验**:POST 也 200 且每次都外呼天气;`exp:-5` 显示「初识(-5)」;提醒数组含 `null` 会让整段待办消失
20
+ - **`/balance` 上游怪回包**:200 + `[{}]` 时响应只剩 `{ok:true}`(`balance`/`currency` 键被丢掉);现在恒有值或回可诊断 `BAD_RESPONSE`
21
+ - **喂食加分失败时显示亲密度 0**(实际值还在):已回落 `loadIntimacy`
22
+
23
+ ### 修复(主代理:唤醒词立即生效)
24
+ - **换唤醒词必须重开语音待命**:唤醒门只在桥接实例创建时读一次词表;现在每次判定前按当前词表同步(原地替换数组,保留 30 秒有效窗),诊断面板与提示文案也读当前词表
25
+
26
+ ### 测试
27
+ - 新增 `scripts/test-actions-rest.mjs`(37 条)与 `scripts/test-misc-routes.mjs`(97 条),均接入 `npm test`
28
+ - 全链 **2403 项 0 失败**(2173 + 37 + 97 + 96)
29
+
30
+
31
+ ### 修复(B72 客户端聊天分身,4 处)
32
+ - **人设情绪在默认路径上从不触发**:`reactTo` 原来放在 `tryStream` 之后,而流式(默认开)成功就在同一行 return 掉了——夸/凶/剩饭/胖/泡面五类情绪、气泡表情、爱心基本从未生效。已移到点播路由之后、`tryStream` 之前(点播命中已提前 return,不会误触发)
33
+ - **流式回退普通 /chat 前缺纪元校验**:`await tryStream()` 之后直接走普通请求,被新提问顶掉的旧回复仍会多发一条 `/chat` 并残留占线。已补 `isCurrent(myEpoch)`
34
+ - **P0 打断漏说书占线**:条件只有 `narrating || ttsPlaying`,而叙述锁过期看门狗只清 `narrating`、不清 `storyBusy`,TTS 退避期也会把 `ttsPlaying` 置 false → 旁白会在回答后再抢话筒。已补 `storyBusy`
35
+ - **占线阈值手写 35000**:与 `busy.ts` 的 `BUSY_STALE_MS` 不一致,自定义 staleMs 的实例会出现「看门狗已解锁、发送入口还当忙」。已改用常量
36
+
37
+ ### 测试(B72)
38
+ - 新增 `scripts/test-chat-client.mjs`(96 条:纪元/打断/占线/消息入列/情绪触发),接入 `npm test`
39
+
40
+ ## [1.1.54] - 2026-11-07
41
+
42
+ ### 修复(B68 主代理:缩放被拖拽抹掉)
43
+ - **「先缩放再拖」会丢缩放**:`client.ts` 的 `applyPos` 在拖动时直接写 `el.style.transform = translate3d(...)`、松手写 `transform = ''`,把 `stageStyle` 里的 `scale(...)` 整个覆盖;React 认为 style 没变不会写回,于是拖一次缩放就没了
44
+ - 新增 `styles.stageTransform(scale, tx, ty)` 作为**唯一**的 stage transform 构造函数(位移 + 缩放一起给),`stageStyle` 与 `applyPos` 共用;位移按屏幕像素走,不受缩放倍数影响
45
+
46
+ ### 测试
47
+ - `scripts/test-render-layer.mjs` 补 7 条:合成顺序/NaN 位移归零/`stageStyle` 与构造器一致,以及 `client.ts` 三分支静态契约(拖动带 scale、静置保留 scale、不再清空 transform)
48
+ - 全链 **2173 项 0 失败**;接口清单 119 模块 / 717 导出
49
+
7
50
  ## [1.1.53] - 2026-11-07
8
51
 
9
52
  ### 修复(B66 缓存/存储分身)
package/dist/client.js CHANGED
@@ -14,7 +14,7 @@
14
14
  }
15
15
 
16
16
  // src/client/version.ts
17
- var VERSION = "1.1.53";
17
+ var VERSION = "1.1.56";
18
18
 
19
19
  // src/client/styles.ts
20
20
  var W = 240;
@@ -23,6 +23,7 @@
23
23
  var CH = 426;
24
24
  var SX = CW / 1696;
25
25
  var SY = CH / 1504;
26
+ var stageTransform = (scale, tx = 0, ty = 0) => "translate3d(" + (tx || 0) + "px," + (ty || 0) + "px,0) scale(" + (scale || 1) + ")";
26
27
  var stageStyle = (scale, hidden) => ({
27
28
  position: "fixed",
28
29
  left: 0,
@@ -34,7 +35,7 @@
34
35
  zIndex: 9999,
35
36
  cursor: "grab",
36
37
  touchAction: "none",
37
- transform: "scale(" + scale + ")",
38
+ transform: stageTransform(scale),
38
39
  // 缩放原点用左上角:与 stage.ts 的 clampPos(按左上坐标 + W*s 宽高钳制)同口径;
39
40
  // 原先 100% 100%(右下)会让缩放后的鱼贴边溢出约 96px。
40
41
  transformOrigin: "0 0",
@@ -3654,9 +3655,28 @@
3654
3655
  let voice = null;
3655
3656
  let pendingVoice = readStoredVoiceName();
3656
3657
  const gate = createWakeGate({ words: loadCustomWakeWords() });
3658
+ let wakeWordsSig = "";
3659
+ function syncWakeWords() {
3660
+ let next;
3661
+ try {
3662
+ const custom = loadCustomWakeWords();
3663
+ next = (custom && custom.length ? custom : WAKE_WORDS).slice();
3664
+ } catch (e) {
3665
+ return;
3666
+ }
3667
+ const sig = next.join("|");
3668
+ if (sig === wakeWordsSig) return;
3669
+ wakeWordsSig = sig;
3670
+ try {
3671
+ gate.words.length = 0;
3672
+ for (const w of next) gate.words.push(w);
3673
+ } catch (e) {
3674
+ }
3675
+ }
3657
3676
  const wakeStats = { passed: 0, dropped: 0, hits: 0, lastHitAt: 0, dropNotifiedAt: 0 };
3658
3677
  let asrNoKeyNotified = false;
3659
3678
  function wakeInfo() {
3679
+ syncWakeWords();
3660
3680
  const now = Date.now();
3661
3681
  const remainingMs = gate.remainingMs(now);
3662
3682
  return {
@@ -3728,6 +3748,7 @@
3728
3748
  }
3729
3749
  } catch (e) {
3730
3750
  }
3751
+ syncWakeWords();
3731
3752
  const r = gate.check(text);
3732
3753
  if (r.hit) {
3733
3754
  wakeStats.hits++;
@@ -4537,15 +4558,31 @@
4537
4558
  if (!first.length && !second.length) return null;
4538
4559
  return { first: first.join("\n"), second: second.join("\n") };
4539
4560
  }
4561
+ var narrateGen = 0;
4540
4562
  function showAndSpeak(host, shown, speech, ms2) {
4541
4563
  host.bubble(shown, ms2);
4542
4564
  host.talkFor(5e3);
4543
4565
  if (!host.voiceOn()) return;
4566
+ const my = ++narrateGen;
4544
4567
  host.narrate(estimateSpeakMs(speech));
4545
- host.speak(speech, { rate: host.narrationRate(), onDone: () => host.clearNarrate() });
4568
+ let released = false;
4569
+ const release = () => {
4570
+ if (released) return;
4571
+ released = true;
4572
+ if (my === narrateGen) host.clearNarrate();
4573
+ };
4574
+ try {
4575
+ host.speak(speech, { rate: host.narrationRate(), onDone: release });
4576
+ } catch (e) {
4577
+ release();
4578
+ }
4546
4579
  }
4547
4580
  function registerContentActions(table) {
4548
4581
  table.define("story-books", (host) => {
4582
+ if (!beginOnce("story-books")) {
4583
+ host.bubble("正在翻书单,稍等~", 2e3);
4584
+ return;
4585
+ }
4549
4586
  getJson("/story/books", { cache: "no-store" }).then((d) => {
4550
4587
  const books = parseBookList(d);
4551
4588
  if (!books.length) {
@@ -4770,9 +4807,12 @@
4770
4807
  return false;
4771
4808
  }
4772
4809
  }
4810
+ function isTooLargeError(err) {
4811
+ return str(err).replace(/[^A-Za-z]/g, "").toUpperCase() === "TOOLARGE";
4812
+ }
4773
4813
  function exportFailText(d) {
4774
- const err = d && typeof d === "object" ? String(d.error || "") : "";
4775
- return err === "TOO_LARGE" ? "备份太大(超过 2MB),导出没成功" : "导出没成功";
4814
+ const err = d && typeof d === "object" ? d.error : "";
4815
+ return isTooLargeError(err) ? "备份太大(超过 2MB),导出没成功" : "导出没成功";
4776
4816
  }
4777
4817
  function exportBackup(host) {
4778
4818
  const fetchJson = host.getJson || getJson;
@@ -4887,11 +4927,29 @@
4887
4927
  }
4888
4928
  }
4889
4929
  function importFailText(d) {
4890
- const err = d && typeof d === "object" ? String(d.error || "") : "";
4891
- if (err === "TOO_LARGE") return "备份太大(超过 2MB),没导入";
4892
- if (err === "BAD_BODY") return "备份文件格式不对,没导入";
4930
+ const err = d && typeof d === "object" ? str(d.error).trim() : "";
4931
+ if (isTooLargeError(err)) return "备份太大(超过 2MB),没导入";
4932
+ if (err.toUpperCase() === "BAD_BODY") return "备份文件格式不对,没导入";
4893
4933
  return "备份文件读不了";
4894
4934
  }
4935
+ var IMPORT_CONFIRM_TEXT = "导入会按备份整字段覆盖本机的记忆/人格/成长/鱼粮/提醒,确定吗?";
4936
+ function askImportConfirm(host) {
4937
+ const injected = host && typeof host.confirm === "function" ? host.confirm : null;
4938
+ if (injected) {
4939
+ try {
4940
+ return !!injected(IMPORT_CONFIRM_TEXT);
4941
+ } catch (e) {
4942
+ return false;
4943
+ }
4944
+ }
4945
+ try {
4946
+ const w = typeof window !== "undefined" ? window : null;
4947
+ if (w && typeof w.confirm === "function") return !!w.confirm(IMPORT_CONFIRM_TEXT);
4948
+ } catch (e) {
4949
+ return false;
4950
+ }
4951
+ return true;
4952
+ }
4895
4953
  function clearChatCache(host) {
4896
4954
  try {
4897
4955
  const del = typeof host.delJson === "function" ? host.delJson : delJson;
@@ -4923,6 +4981,10 @@
4923
4981
  host.bubble("备份文件读不了", 6e3);
4924
4982
  return;
4925
4983
  }
4984
+ if (!askImportConfirm(host)) {
4985
+ host.bubble("没确认,导入取消", 4e3);
4986
+ return;
4987
+ }
4926
4988
  let p;
4927
4989
  try {
4928
4990
  p = Promise.resolve(sendJson("/data/import", body));
@@ -5292,6 +5354,28 @@
5292
5354
  }
5293
5355
 
5294
5356
  // src/client/actions/voice.ts
5357
+ function wakeWordsLive(host, want) {
5358
+ let info = null;
5359
+ try {
5360
+ info = host.wakeInfo ? host.wakeInfo() : null;
5361
+ } catch (e) {
5362
+ info = null;
5363
+ }
5364
+ const live = info && Array.isArray(info.words) ? info.words.map((x) => str(x)) : null;
5365
+ if (!live || !live.length) return true;
5366
+ if (live.length !== want.length) return false;
5367
+ for (let i = 0; i < want.length; i++) {
5368
+ if (live[i] !== want[i]) return false;
5369
+ }
5370
+ return true;
5371
+ }
5372
+ function wakeHint(host, want) {
5373
+ try {
5374
+ return wakeWordsLive(host, want) ? "" : "(重开语音待命后生效)";
5375
+ } catch (e) {
5376
+ return "";
5377
+ }
5378
+ }
5295
5379
  function registerVoiceActions(table) {
5296
5380
  table.define("wake", (host) => {
5297
5381
  host.toggleStandby();
@@ -5396,11 +5480,13 @@
5396
5480
  return;
5397
5481
  }
5398
5482
  const on2 = setCustomWakeWord(String(input));
5399
- host.bubble(on2 ? "唤醒词:" + String(input).trim().slice(0, 12) : "唤醒词:默认(大肥鱼/肥鲸等)");
5483
+ const stored2 = String(input).trim().slice(0, 12);
5484
+ host.bubble(on2 ? "唤醒词:" + stored2 + wakeHint(host, [stored2]) : "唤醒词:默认(大肥鱼/肥鲸等)" + wakeHint(host, WAKE_WORDS));
5400
5485
  return;
5401
5486
  }
5402
5487
  const on = setCustomWakeWord(word);
5403
- host.bubble(on ? "唤醒词:" + word : "唤醒词:默认(大肥鱼/肥鲸等)");
5488
+ const stored = word.slice(0, 12);
5489
+ host.bubble(on ? "唤醒词:" + stored + wakeHint(host, [stored]) : "唤醒词:默认(大肥鱼/肥鲸等)" + wakeHint(host, WAKE_WORDS));
5404
5490
  });
5405
5491
  }
5406
5492
 
@@ -7050,12 +7136,12 @@
7050
7136
  abortStream();
7051
7137
  const d0 = diag();
7052
7138
  if (d0) d0.lastChat = { stage: "entry", busy: host.busy.isBusy(), tts: !!host.ttsPlaying(), at: Date.now() };
7053
- if (host.busy.isBusy() && host.busy.age() < 35e3) {
7139
+ if (host.busy.isBusy() && host.busy.age() < BUSY_STALE_MS) {
7054
7140
  if (d0) d0.lastChat.stage = "blocked-busy";
7055
7141
  host.bubble("本鲸还在想上一句呢,稍等~", 2500);
7056
7142
  return;
7057
7143
  }
7058
- if (host.narrating.current || host.ttsPlaying()) {
7144
+ if (host.narrating.current || host.storyBusy.current || host.ttsPlaying()) {
7059
7145
  try {
7060
7146
  host.stopSpeak();
7061
7147
  } catch (e) {
@@ -7082,10 +7168,6 @@
7082
7168
  host.setMessages((m) => [...m, { who: "me", text }]);
7083
7169
  } catch (e) {
7084
7170
  }
7085
- try {
7086
- if (await tryStream(text, opts, myEpoch)) return;
7087
- } catch (e) {
7088
- }
7089
7171
  try {
7090
7172
  const hit = host.persona.reactTo(text);
7091
7173
  if (hit) {
@@ -7094,6 +7176,11 @@
7094
7176
  }
7095
7177
  } catch (e) {
7096
7178
  }
7179
+ try {
7180
+ if (await tryStream(text, opts, myEpoch)) return;
7181
+ } catch (e) {
7182
+ }
7183
+ if (!host.epoch.isCurrent(myEpoch)) return;
7097
7184
  try {
7098
7185
  if (opts && opts.voice && !host.ttsPlaying() && Math.random() < 0.25) host.say(host.persona.line("waiting"), 3e3);
7099
7186
  } catch (e) {
@@ -8204,15 +8291,16 @@
8204
8291
  const p = clampPos(x, y, W * s, H * s, window.innerWidth, window.innerHeight);
8205
8292
  x = p.x;
8206
8293
  y = p.y;
8294
+ const vs = scaleRef.current || 1;
8207
8295
  const d = dragRef.current;
8208
8296
  if (d && d.dragging) {
8209
8297
  const bx = typeof d.baseX === "number" ? d.baseX : x;
8210
8298
  const by = typeof d.baseY === "number" ? d.baseY : y;
8211
- el.style.transform = "translate3d(" + (x - bx) + "px," + (y - by) + "px,0)";
8299
+ el.style.transform = stageTransform(vs, x - bx, y - by);
8212
8300
  } else {
8213
8301
  el.style.left = x + "px";
8214
8302
  el.style.top = y + "px";
8215
- el.style.transform = "";
8303
+ el.style.transform = stageTransform(vs);
8216
8304
  }
8217
8305
  posRef.current = { x, y };
8218
8306
  }
@@ -1,12 +1,25 @@
1
1
  /**
2
2
  * host/routes/briefing.ts —— 今日简报路由(可复用模块)
3
3
  * 内容:日期/星期 + 北海天气(wttr.in)+ 提醒数 + 成长阶段
4
- * 注意:当前前端未使用,保留备查(C# 版已砍掉每日简报,菜单不再调用)
4
+ *
5
+ * 调用方:菜单动作「今日简报」(client/actions/content.ts 的 briefing-card → getJson('/briefing')),
6
+ * 回包形状固定为 { ok, text },客户端按行切成「日期/天气/成长」与「待办」两段。
7
+ * 只读:只认 GET/HEAD;非 GET 回 405,也不会因为一次误调去外呼天气。
5
8
  */
6
9
  import { growthStage } from '../../shared/growth.js';
7
10
  import { sendJson } from '../respond.js';
11
+ /** 非负整数(经验展示与 /growth 同口径:NaN/负数/小数归零或取整) */
12
+ function nonNegInt(value) {
13
+ const n = Number(value);
14
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
15
+ }
8
16
  export function createBriefingHandlers(opts) {
9
- async function handleBriefing(_req, res) {
17
+ async function handleBriefing(req, res) {
18
+ const method = req && req.method ? String(req.method).toUpperCase() : 'GET';
19
+ if (method !== 'GET' && method !== 'HEAD') {
20
+ sendJson(res, { ok: false, error: 'METHOD_NOT_ALLOWED' }, 405);
21
+ return;
22
+ }
10
23
  const parts = [];
11
24
  try {
12
25
  const now = new Date();
@@ -24,18 +37,21 @@ export function createBriefingHandlers(opts) {
24
37
  }
25
38
  }
26
39
  catch { }
27
- // 成长
40
+ // 成长(与 /growth 同口径:非负整数;g 为 null/怪值时不陪葬其它段)
28
41
  try {
29
- const g = opts.loadGrowth();
30
- const st = growthStage(Number(g.exp) || 0);
31
- parts.push('肥鲸成长:' + st.name + '(' + (Number(g.exp) || 0) + ')');
42
+ const g = opts.loadGrowth() || {};
43
+ const exp = nonNegInt(g.exp);
44
+ const st = growthStage(exp);
45
+ parts.push('肥鲸成长:' + st.name + '(' + exp + ')');
32
46
  }
33
47
  catch { }
34
- // 提醒
48
+ // 提醒(非数组说明读不到,就别编一个"0 条";坏项丢弃,好项照数)
35
49
  try {
36
- const rs = opts.loadReminders() || [];
37
- const pending = rs.filter((r) => !r.done).length;
38
- parts.push('待办提醒:' + pending + ' 条');
50
+ const rs = opts.loadReminders();
51
+ if (Array.isArray(rs)) {
52
+ const pending = rs.filter((r) => Boolean(r) && !r.done).length;
53
+ parts.push('待办提醒:' + pending + ' 条');
54
+ }
39
55
  }
40
56
  catch { }
41
57
  sendJson(res, { ok: true, text: parts.join('\n') });
@@ -10,6 +10,8 @@
10
10
  * hasDeepseekKey, hasSiliconflowKey } // 只报"有没有",绝不回 Key 内容
11
11
  *
12
12
  * 约定:不因读取失败而 500——各字段各自兜底,读不到就给缺省值。
13
+ * 布尔字段统一经 safeBool 出口:即使注入方误把「读 Key」的函数直接接进来(返回 Key 原文),
14
+ * 也只会变成 true/false,配置视图没有任何回显 Key 内容的通路。
13
15
  */
14
16
  import { sendJson } from '../respond.js';
15
17
  export function createConfigViewHandler(opts) {
@@ -25,6 +27,24 @@ export function createConfigViewHandler(opts) {
25
27
  return fallback;
26
28
  }
27
29
  }
30
+ /**
31
+ * 布尔字段的唯一出口:非布尔一律 Boolean 化,绝不把原值原样塞进响应。
32
+ * 这道闸门的意义是「接口契约不许回显 Key 内容」不依赖注入方自觉——即使 hasDeepseekKey
33
+ * 被误接成 readApiKey(返回 "sk-..."),这里也只产出 true;异常/空值按缺省值。
34
+ */
35
+ function safeBool(fn, fallback) {
36
+ try {
37
+ if (typeof fn !== 'function')
38
+ return fallback;
39
+ const v = fn();
40
+ if (v === true || v === false)
41
+ return v;
42
+ return v == null ? fallback : Boolean(v);
43
+ }
44
+ catch {
45
+ return fallback;
46
+ }
47
+ }
28
48
  async function handleConfig(req, res) {
29
49
  try {
30
50
  const method = req && req.method ? String(req.method).toUpperCase() : 'GET';
@@ -34,15 +54,15 @@ export function createConfigViewHandler(opts) {
34
54
  }
35
55
  sendJson(res, {
36
56
  ok: true,
37
- enabled: safe(opts.enabled, true),
57
+ enabled: safeBool(opts.enabled, true),
38
58
  dataSource: String(safe(opts.dataSource, 'local')),
39
59
  model: String(safe(opts.model, '')),
40
- personalityLearning: safe(opts.personalityLearning, true),
41
- personalityWriting: safe(opts.personalityWriting, true),
42
- cacheEnabled: safe(opts.cacheEnabled, true),
43
- // 只报有没有 Key,绝不回内容
44
- hasDeepseekKey: safe(opts.hasDeepseekKey, false),
45
- hasSiliconflowKey: safe(opts.hasSiliconflowKey, false),
60
+ personalityLearning: safeBool(opts.personalityLearning, true),
61
+ personalityWriting: safeBool(opts.personalityWriting, true),
62
+ cacheEnabled: safeBool(opts.cacheEnabled, true),
63
+ // 只报有没有 Key,绝不回内容(safeBool 兜最后一道)
64
+ hasDeepseekKey: safeBool(opts.hasDeepseekKey, false),
65
+ hasSiliconflowKey: safeBool(opts.hasSiliconflowKey, false),
46
66
  });
47
67
  }
48
68
  catch {
@@ -68,9 +68,20 @@ export function createMiscHandlers(opts) {
68
68
  return;
69
69
  }
70
70
  const data = r.data;
71
- const infos = data?.balance_infos || [];
72
- const cny = infos.find((x) => x.currency === 'CNY') || infos[0] || null;
73
- sendJson(res, { ok: true, balance: cny ? cny.total_balance : null, currency: cny ? cny.currency : 'CNY' });
71
+ // 200 但回包形状不对(不是对象 / balance_infos 不是数组)属于上游坏了:
72
+ // 给可诊断的 BAD_RESPONSE,别把 "infos.find is not a function" 这种内部报错当错误文案
73
+ if (!data || typeof data !== 'object' || !Array.isArray(data.balance_infos)) {
74
+ console.error('[misc] BALANCE-SHAPE 回包缺少 balance_infos 数组');
75
+ sendJson(res, { ok: false, error: 'BAD_RESPONSE', balance: null });
76
+ return;
77
+ }
78
+ const infos = data.balance_infos;
79
+ const cny = infos.find((x) => Boolean(x) && x.currency === 'CNY') || infos.find((x) => Boolean(x)) || null;
80
+ // balance / currency 必须恒有值:undefined 会被 JSON.stringify 整个丢掉,
81
+ // 客户端拿到「ok:true 但没有 balance 字段」会当成没刷出来(而不是"没有余额信息")
82
+ const balance = cny && cny.total_balance != null ? cny.total_balance : null;
83
+ const currency = cny && typeof cny.currency === 'string' && cny.currency ? cny.currency : 'CNY';
84
+ sendJson(res, { ok: true, balance, currency });
74
85
  }
75
86
  catch (err) {
76
87
  // 超时/网络错误:照旧回 ok:false + balance:null,日志里留 BALANCE-TIMEOUT 标签
@@ -117,15 +128,20 @@ export function createMiscHandlers(opts) {
117
128
  const pet = opts.feedPet();
118
129
  // 亲密度以 state.json 为准:喂食只负责"加",不再让 pet_state.intimacy 自成一套
119
130
  let intimacy = cleanIntimacy(pet && pet.intimacy);
131
+ let bumped = false;
120
132
  if (opts.bumpIntimacy) {
121
133
  try {
122
134
  const v = Number(opts.bumpIntimacy(2));
123
- if (Number.isFinite(v))
135
+ if (Number.isFinite(v)) {
124
136
  intimacy = cleanIntimacy(v);
137
+ bumped = true;
138
+ }
125
139
  }
126
140
  catch (e) { }
127
141
  }
128
- else if (opts.loadIntimacy) {
142
+ // 加分失败(写 state.json 出错/返回非数字)时仍要显示当前亲密度:
143
+ // 用 loadIntimacy 兜底,否则喂一次食会把面板显示成 0,而 state 里的值其实还在
144
+ if (!bumped && opts.loadIntimacy) {
129
145
  try {
130
146
  const v = Number(opts.loadIntimacy());
131
147
  if (Number.isFinite(v))
@@ -9,11 +9,15 @@
9
9
  * cache: { tts, chat, asr }, // 三个缓存目录的条目数
10
10
  * fishfood: { chatTokens, storyTokens, total }, // total = chat + story(与 /fishfood 同口径)
11
11
  * growth: { exp, stage, nextExp }, // 与 /growth 同口径
12
- * intimacy, // state.json
12
+ * intimacy, // state.json,0..100(与 /state 同口径)
13
13
  * personality: { catchphrases, preferences, notes, selfNarrative },
14
14
  * uptimeMs // 插件本次装配至今
15
15
  * }
16
16
  *
17
+ * 数值口径:所有数字字段都是有限非负整数——NaN / Infinity / 负数 / 数字字符串都在这里归一,
18
+ * 不让 NaN 进 JSON(JSON.stringify 会把 NaN 变成 null,客户端拿到 null 会显示成"没有值")。
19
+ * intimacy 和 /state 一样夹到 [0, 100]:同一份 state.json 不许在两个接口给出两个亲密度。
20
+ *
17
21
  * 数据全部来自现有 store / cache / 人格仓储的读取器,不新增任何持久化文件,也不写盘。
18
22
  */
19
23
  import { growthStage } from '../../shared/growth.js';
@@ -23,6 +27,15 @@ function count(value) {
23
27
  const n = Number(value);
24
28
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
25
29
  }
30
+ /** 亲密度上限(与 host/repos/state.ts、host/routes/misc.ts 同一口径) */
31
+ const INTIMACY_MAX = 100;
32
+ /** 亲密度展示口径:非数字/负数归 0,超 100 截到 100(与 /state 同口径,不许两个接口两个值) */
33
+ function intimacyOf(value) {
34
+ const n = Number(value);
35
+ if (!Number.isFinite(n))
36
+ return 0;
37
+ return Math.max(0, Math.min(INTIMACY_MAX, Math.floor(n)));
38
+ }
26
39
  function strList(value) {
27
40
  return (Array.isArray(value) ? value : []).filter((x) => typeof x === 'string');
28
41
  }
@@ -46,13 +59,18 @@ export function createStatsHandler(opts) {
46
59
  const state = opts.loadState() || {};
47
60
  const p = opts.loadPersonality ? (opts.loadPersonality() || {}) : {};
48
61
  const startedAt = Number(opts.startedAt) || 0;
49
- const uptimeMs = startedAt > 0 ? Math.max(0, Math.floor(nowFn() - startedAt)) : 0;
62
+ const elapsed = nowFn() - startedAt;
63
+ // 时钟坏了(now() 非有限数)时 elapsed 是 NaN,JSON 会把它写成 null;这里给 0,保持数字类型
64
+ const uptimeMs = startedAt > 0 && Number.isFinite(elapsed) ? Math.max(0, Math.floor(elapsed)) : 0;
65
+ // 自检字段是"外部注入"的:形状由调用方决定,这里逐项归一再出包
66
+ const ttsRaw = opts.tts || {};
67
+ const backupRaw = opts.backup || {};
50
68
  return {
51
69
  ok: true,
52
70
  cache: { tts: count(cache.tts), chat: count(cache.chat), asr: count(cache.asr) },
53
71
  fishfood: { chatTokens, storyTokens, total: chatTokens + storyTokens },
54
72
  growth: { exp, stage: stage.name, nextExp: stage.nextExp },
55
- intimacy: count(state.intimacy),
73
+ intimacy: intimacyOf(state.intimacy),
56
74
  personality: {
57
75
  catchphrases: strList(p.catchphrases),
58
76
  preferences: strList(p.preferences),
@@ -60,11 +78,15 @@ export function createStatsHandler(opts) {
60
78
  selfNarrative: typeof p.selfNarrative === 'string' ? p.selfNarrative : '',
61
79
  },
62
80
  uptimeMs,
63
- // 自检:数据源 / 模型 / TTS 引擎 / 备份(缺省不报错)
81
+ // 自检:数据源 / 模型 / TTS 引擎 / 备份(缺省不报错;坏值也不许把 null/负数/怪类型漏出去)
64
82
  dataSource: String(opts.dataSource || 'local'),
65
83
  model: String(opts.model || ''),
66
- tts: opts.tts || { available: false, interpreter: '', cooling: false },
67
- backup: opts.backup || { lastAt: 0, count: 0 },
84
+ tts: {
85
+ available: ttsRaw.available === true,
86
+ interpreter: typeof ttsRaw.interpreter === 'string' ? ttsRaw.interpreter : '',
87
+ cooling: ttsRaw.cooling === true,
88
+ },
89
+ backup: { lastAt: count(backupRaw.lastAt), count: count(backupRaw.count) },
68
90
  };
69
91
  }
70
92
  async function handleStats(req, res) {
@@ -12,14 +12,14 @@
12
12
 
13
13
  | 项 | 值 |
14
14
  | --- | --- |
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 分钟后可见) |
15
+ | `package.json` version | 1.1.53 |
16
+ | `src/client/version.ts` VERSION | 1.1.53(由 `scripts/gen-version.mjs` 生成) |
17
+ | 最近 Gitee tag | `v1.1.53`(提交 `ee4b3dd`) |
18
+ | Gitee `main` HEAD | `ee4b3dd` |
19
+ | GitHub `main` | `1c1d38c`;tag `v1.1.53` → `1c1d38c`(API 解包核对 1.1.53) |
20
+ | npm latest | **1.1.53**(2026-11-07 实读;发布返回 202,约 2–4 分钟后可见)。已发布版本:1.1.48 / 1.1.50 / 1.1.51 / 1.1.53;1.1.49、1.1.52 仅 Gitee+GitHub(跳档不影响 `latest`) |
21
21
  | README 版本 | 无硬编码版本号 |
22
- | `npm test` | **1757 项**:861 单测 + 59 限额 + 58 流式 + 17 说书安全 + 110 行为回归 + 50 语音契约 + 18 客户端冒烟 + 18 宿主冒烟 + 171 语音队列 + 32 TTS 引擎 + 79 数据层 + 112 仓储 + 83 人格 + 57 说书 + 32 平台 |
22
+ | `npm test` | **2166 项**:861 单测 + 59 限额 + 58 流式 + 17 说书安全 + 110 行为回归 + 50 语音契约 + 18 客户端冒烟 + 18 宿主冒烟 + 171 语音队列 + 32 TTS 引擎 + 79 数据层 + 112 仓储 + 83 人格 + 57 说书 + 31 聊天路由 + 33 菜单动作 + 74 说书客户端 + 24 连播开关 + 88 内容路由 + 73 渲染层 + 86 缓存存储 + 32 平台 |
23
23
  | `npm pack --dry-run` | **128 个文件 / 17.1 MB**(包);`data/cache`、运行数据、`config.json`、`src/`、`scripts/`、`tools/` 均不进包 |
24
24
  ## 一、版本号规则
25
25
 
@@ -5,7 +5,7 @@
5
5
  > 架构宪章:**模块只对外暴露这些导出,外部不得读取模块内部实现**。
6
6
  > 新增/修改导出 = 接口变更,必须同步调用点并在提交信息里写明。
7
7
 
8
- > 统计:119 个模块文件,716 个导出项。
8
+ > 统计:119 个模块文件,717 个导出项。
9
9
 
10
10
  ## 宿主(Node)(`src/host/`)
11
11
 
@@ -113,7 +113,7 @@
113
113
  | `stage.ts` | `DragInputHandlers`、`DragState`、`INERTIA_DECAY`、`INERTIA_MAX`、`INERTIA_STOP`、`Pos`、`beginDrag`、`bindDragInput`、`clampPos`、`clampVelocity`、`createDragState`、`decayVelocity`、`endDrag`、`homePos`、`moveDrag`、`pickInputMode`、`pointOf` |
114
114
  | `story-auto.ts` | `STORY_AUTO_KEY`、`hasStoryAutoStorage`、`isStoryAutoOn`、`setStoryAuto` |
115
115
  | `story.ts` | `StoryController`、`StoryHost`、`StoryRefs`、`createStoryController` |
116
- | `styles.ts` | `CH`、`CW`、`H`、`SX`、`SY`、`VERSION`、`W`、`bodyStyle`、`canvasStyle`、`chatPanelStyle`、`headStyle`、`inputRowStyle`、`inputStyle`、`menuItemStyle`、`menuStyle`、`menuSubStyle`、`msgStyle`、`recoverDotStyle`、`sendStyle`、`stageStyle` |
116
+ | `styles.ts` | `CH`、`CW`、`H`、`SX`、`SY`、`VERSION`、`W`、`bodyStyle`、`canvasStyle`、`chatPanelStyle`、`headStyle`、`inputRowStyle`、`inputStyle`、`menuItemStyle`、`menuStyle`、`menuSubStyle`、`msgStyle`、`recoverDotStyle`、`sendStyle`、`stageStyle`、`stageTransform` |
117
117
  | `text.ts` | `Deduper`、`SentenceBuffer`、`collapsePoemBody`、`collapseText`、`createDeduper`、`createSentenceBuffer`、`echoKey`、`estimateSpeakMs`、`isShortEchoFragment`、`normalizePlain`、`stripStageDirections`、`taleParts` |
118
118
  | `util.ts` | `clearI`、`clearT`、`evPrevent`、`evStop`、`str` |
119
119
  | `version.ts` | `VERSION` |
@@ -110,3 +110,8 @@
110
110
  | B65 | 2026-11-07 | 渲染层分身:`stage.dispose` 按 capture=true 撤监听(漏撤 4 个)/ `anim` 单帧异常续帧 + save/restore finally / `styles` 无 window 守卫 + NaN 坐标 + 窄视口面板溢出;新增 `test-render-layer.mjs`(73);版本 1.1.52 | 双 tsc 0 / build 0(+1751B) / 2080 项 0 失败 | 见 git |
111
111
  | B66 | 2026-11-07 | 缓存/存储分身:`memSet` 补拷贝 / `getJson` 命中核对条目身份(防旧值复辟、`memBytes` 漂移)/ `setJson` 与 `writeFileAtomic` 写失败清 `.tmp`;新增 `test-store-cache.mjs`(86);版本 1.1.53 | 双 tsc 0 / build 0 / 2166 项 0 失败 | 见 git |
112
112
  | B67 | 2026-11-07 | 封装基础设施:修 `check-boundaries` B5/B6 引号 bug(规则从未生效,已负向验证会拦)+ 补 `prefs.js`;`hashKey/shortHash/hashBytes` 上移 `shared/hash.ts`,路由不再为 hash 直连 cache;ADR 0002 增补 `chat-core` 受控例外;接口清单 119 模块/716 导出;版本 1.1.53 | 双 tsc 0 / build 0 / 2166 项 0 失败 / 检查器 exit 0(违规探针 exit 1) | 见 git |
113
+ | B68 | 2026-11-07 | 主代理:`styles.stageTransform(scale,tx,ty)` 收口 stage transform,修「先缩放再拖会丢缩放」(`applyPos` 曾用 `translate3d` 覆盖 `scale`、松手清空);`stageStyle` 与 `applyPos` 共用同一构造器;`test-render-layer` 补 7 条(含 client.ts 静态契约);版本 1.1.54 | 双 tsc 0 / build 0 / **2173 项 0 失败** / 119 模块 717 导出 | 见 git |
114
+ | B69 | 2026-11-07 | 动作剩余分身:`content` 叙述锁按代次归属(旧 onDone 不解新锁、朗读起不来当场放锁)+ 选书防连点;`data` 413 归一 `TOO_LARGE`/`too large` + 导入二次确认;`voice` 唤醒词气泡补生效时机;新增 `test-actions-rest.mjs`(37);版本 1.1.56 | 双 tsc 0 / build 0(+2050B) / 2307 项 0 失败 | 见 git |
115
+ | B70 | 2026-11-07 | 杂项路由分身:`/config` 布尔 safeBool(杜绝 Key 回显)/ `/stats` 亲密度同口径 + NaN 归一 / `/briefing` 只认 GET·HEAD + exp 非负 + 坏项守卫 / `/balance` 恒有值 + `BAD_RESPONSE` / 喂食失败回落亲密度;新增 `test-misc-routes.mjs`(97);版本 1.1.56 | 双 tsc 0 / build 0 / 2307 项 0 失败 | 见 git |
116
+ | B71 | 2026-11-07 | 主代理:唤醒词立即生效——`voice-bridge` 判定前同步 `loadCustomWakeWords()`(原地换数组、保留 30s 有效窗),诊断与提示同源;版本 1.1.56 | 双 tsc 0 / build 0 / 2307 项 0 失败 | 见 git |
117
+ | B72 | 2026-11-07 | 客户端聊天分身:`reactTo` 移到流式之前(修「人设情绪在默认路径从不触发」)/ 流式回退前补纪元守卫 / P0 打断条件补 `storyBusy` / 占线阈值改用 `BUSY_STALE_MS`;新增 `test-chat-client.mjs`(96);版本 1.1.56 | 双 tsc 0 / build 0(+85B) / 2403 项 0 失败 | 见 git |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-chubby-whale",
3
- "version": "1.1.53",
3
+ "version": "1.1.56",
4
4
  "description": "DSH 网页版桌面宠物「肥鲸」——蓝发鲸鱼娘,固定在 harness 页面右下角:会眨眼、能拖拽、点击可聊天,支持语音对话、念诗说书、饭点提醒。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -74,7 +74,7 @@
74
74
  "scripts": {
75
75
  "build": "node scripts/gen-version.mjs && node scripts/check-bom.mjs && tsc -p tsconfig.json && tsc -p tsconfig.client.json && esbuild src/client.ts --bundle --format=iife --external:react --charset=utf8 --outfile=dist/client.js",
76
76
  "prepublishOnly": "npm run build",
77
- "test": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/check-imports.mjs && node scripts/gen-module-index.mjs --check && node scripts/check-boundaries.mjs && node scripts/check-diag-keys.mjs && node scripts/check-text.mjs && node scripts/test.mjs && node scripts/test-chat-limits.mjs && node scripts/test-stream-ledger.mjs && node scripts/test-story-security.mjs && node scripts/test-behavior-regressions.mjs && node scripts/verify-voice.mjs && node scripts/smoke-client.mjs && node scripts/ape-bench.mjs && node scripts/smoke-host.mjs && node scripts/test-voice-queue.mjs && node scripts/test-tts-engine.mjs && node scripts/test-data-layer.mjs && node scripts/test-repos.mjs && node scripts/test-personality.mjs && node scripts/test-story-player.mjs && node scripts/test-chat-routes.mjs && node scripts/test-menu-actions.mjs && node scripts/test-story-client.mjs && node scripts/test-story-auto.mjs && node scripts/test-content-routes.mjs && node scripts/test-render-layer.mjs && node scripts/test-store-cache.mjs && node scripts/platform-check.mjs",
77
+ "test": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/check-imports.mjs && node scripts/gen-module-index.mjs --check && node scripts/check-boundaries.mjs && node scripts/check-diag-keys.mjs && node scripts/check-text.mjs && node scripts/test.mjs && node scripts/test-chat-limits.mjs && node scripts/test-stream-ledger.mjs && node scripts/test-story-security.mjs && node scripts/test-behavior-regressions.mjs && node scripts/verify-voice.mjs && node scripts/smoke-client.mjs && node scripts/ape-bench.mjs && node scripts/smoke-host.mjs && node scripts/test-voice-queue.mjs && node scripts/test-tts-engine.mjs && node scripts/test-data-layer.mjs && node scripts/test-repos.mjs && node scripts/test-personality.mjs && node scripts/test-story-player.mjs && node scripts/test-chat-routes.mjs && node scripts/test-menu-actions.mjs && node scripts/test-story-client.mjs && node scripts/test-story-auto.mjs && node scripts/test-content-routes.mjs && node scripts/test-render-layer.mjs && node scripts/test-store-cache.mjs && node scripts/test-actions-rest.mjs && node scripts/test-misc-routes.mjs && node scripts/test-chat-client.mjs && node scripts/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",