dsh-chubby-whale 1.1.50 → 1.1.53
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 +45 -0
- package/dist/client.js +178 -91
- package/dist/host/atomic.js +13 -2
- package/dist/host/cache.js +23 -21
- package/dist/host/chat-core.js +1 -1
- package/dist/host/routes/asr.js +1 -1
- package/dist/host/routes/culture.js +11 -2
- package/dist/host/routes/poem.js +16 -1
- package/dist/host/routes/tts.js +1 -1
- package/dist/shared/hash.js +25 -0
- package/docs/adr/0002-/346/250/241/345/235/227/346/216/245/345/217/243/345/245/221/347/272/246.md +4 -0
- package/docs//346/250/241/345/235/227/346/216/245/345/217/243/346/270/205/345/215/225.md +3 -1
- package/docs//350/267/250/345/271/263/345/217/260/344/270/216/345/210/206/345/267/245.md +5 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,51 @@
|
|
|
4
4
|
|
|
5
5
|
## [未发布]
|
|
6
6
|
|
|
7
|
+
## [1.1.53] - 2026-11-07
|
|
8
|
+
|
|
9
|
+
### 修复(B66 缓存/存储分身)
|
|
10
|
+
- **`memSet` 别名泄漏**:内存缓存直接存调用方对象引用,外部改一下就改到了缓存里(坑 27 同类);现在入缓存即拷贝
|
|
11
|
+
- **内存命中把并发写入的新值顶掉**:`getJson` 在 `await stat` 期间若被并发 `setJson` 改写,旧条目会被写回;现在命中后核对条目身份,顺带修掉 `memBytes` 漂移
|
|
12
|
+
- **写失败残留 `.tmp`**:`atomic.writeFileAtomic` 的 `finally` 只 close 不删临时文件,`cache.setJson` 也只覆盖 rename 失败分支;现在写/rename 失败都会清掉半截临时文件
|
|
13
|
+
|
|
14
|
+
### 修复(B67 主代理:封装基础设施)
|
|
15
|
+
- **封装检查器假绿(坑 25 复发)**:`check-boundaries.mjs` 的 B5/B6 禁令清单把路径写成带引号的 `"'../cache.js'"`,却拿去掉引号的 `m[1]` 做 `indexOf`,恒为 -1——**规则从来没生效过**。已去引号并补 `prefs.js`;负向验证:临时塞一个 `import '../store.js'` 的路由会让检查器以 exit 1 拦下
|
|
16
|
+
- **hash 纯函数上移到 `shared/hash.ts`**:`routes/asr.ts`、`routes/tts.ts` 只为 `hashBytes`/`hashKey` 就直连 `cache.js`,违反 ADR 0002;现在路由从 shared 取,`cache.ts` 与 `chat-core.ts` 也改从 shared 取(旧路径保留 re-export)
|
|
17
|
+
- **ADR 0002 增补受控例外**:`chat-core.js` 是无状态纯函数模块(常量/归一化/消息拼装),明确列为允许;仍禁止 store / repos / cache / prefs / story-repo / data 实现
|
|
18
|
+
|
|
19
|
+
### 测试
|
|
20
|
+
- 新增 `scripts/test-store-cache.mjs`(86 条:cache 拷贝隔离/LRU/字节上限/TTL/并发/坏条目/BOM/prune + atomic 原子写/重试/隔离 + store 门面往返),接入 `npm test`
|
|
21
|
+
- 全链 **2166 项 0 失败**(2080 + 86);接口清单 119 模块 / 716 导出
|
|
22
|
+
|
|
23
|
+
## [1.1.52] - 2026-11-07
|
|
24
|
+
|
|
25
|
+
### 修复(B64 内容路由分身)
|
|
26
|
+
- **坏数据能把念诗拖死(P1)**:`poem.total` 来自数据文件,被直接当成 `for (i < total)` 的循环上界;数据若给出 `1e9`/`Infinity`,每次念诗要空转上亿次、请求永不返回。现在 `total` 限 1..64 且必须有限(越界打 `POEM-TOTAL-CAP`)
|
|
27
|
+
- **`?title=` 未命中缺少标记**:注释承诺「回落随机并告诉客户端 `matched:false`」,实际没下发;已补齐
|
|
28
|
+
- **池空重来会撞上一则**:`culture` 一轮播完重置时不排除上一则,有 1/n 概率「刚讲完又立刻同一则」;已与念诗同口径排除
|
|
29
|
+
|
|
30
|
+
### 修复(B65 渲染层分身)
|
|
31
|
+
- **每次卸载漏撤 4 个 window 监听**:`addEventListener(..., true)` 捕获监听配的 `removeEventListener` 少传 `capture`,DOM 按三元组匹配,撤不掉;闭包一直攥着旧 stage 与回调(重挂载/HMR 会叠加)
|
|
32
|
+
- **一帧绘制异常 = 鱼永久冻结**:`loop` 只在末尾排下一帧,任何异常都会断掉 rAF 链且无日志;现在单帧绘制抽 `drawFrame` + try/catch,连续 300 帧异常才停表,首帧 warn 一次
|
|
33
|
+
- **画布状态栈不配平**:`save()` 与 `restore()` 之间没有 `finally`,异常后旋转/位移留在栈顶、恢复后逐帧叠加;已用 `finally` 配平
|
|
34
|
+
- **无 window 环境直接 ReferenceError**:`menuStyle` 在 `menu` 非空时裸读 `window`(同函数里 `maxHeight` 却做了守卫);`menu.x` 非数还会生成 `"NaNpx"` 被整条忽略。已收敛到 `viewportSize()` + NaN 回退
|
|
35
|
+
- **375px 视口聊天面板落在屏外**:固定 `right:236/width:340` 时左边界 = −201px;现在按视口收缩(≥584×526 时取值与旧版完全一致)
|
|
36
|
+
|
|
37
|
+
### 测试
|
|
38
|
+
- 新增 `scripts/test-content-routes.mjs`(88 条)与 `scripts/test-render-layer.mjs`(73 条,基线快照上 14 项红),均接入 `npm test`
|
|
39
|
+
- 全链 **2080 项 0 失败**(1919 + 88 + 73)
|
|
40
|
+
|
|
41
|
+
## [1.1.51] - 2026-11-07
|
|
42
|
+
|
|
43
|
+
### 修复(B63 主代理:连播开关收口)
|
|
44
|
+
- **`chubby-story-auto` 三套口径并存**:`chat-route.ts` 写 `'on'`、`actions/culture.ts` 写 `'1'/'0'`、`render.ts` 读 `!== '0'`、`story.ts` 整本讲完写 `'0'`。功能上侥幸能跑(`'on'` 恰好被当成开),但以后谁改成 `=== '1'` 就会把 `'on'` 判成关
|
|
45
|
+
- 新增 `client/story-auto.ts` 作为唯一读写口:读侧兼容 `'0'/'off'/'false'` 与历史 `'on'`,写侧统一 `'1'/'0'`;`isStoryAutoOn()` 在任何异常下都不抛(读不到按「开」)
|
|
46
|
+
- 新增 `hasStoryAutoStorage()`:让开关能区分「开」与「读不到」——禁存储/隐私模式下退回内存的一次性开关,避免点一下方向翻反(这条是被 `test-menu-actions` 抓出来的回归,已修)
|
|
47
|
+
|
|
48
|
+
### 测试
|
|
49
|
+
- 新增 `scripts/test-story-auto.mjs`(24 条:键名/默认开/9 种脏值/写读回/存储抛错/无 localStorage),接入 `npm test`
|
|
50
|
+
- 全链 **1919 项 0 失败**(1895 + 24);接口清单 118 模块 / 713 导出
|
|
51
|
+
|
|
7
52
|
## [1.1.50] - 2026-11-07
|
|
8
53
|
|
|
9
54
|
### 修复(B61 菜单/动作分身)
|
package/dist/client.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
// src/client/version.ts
|
|
17
|
-
var VERSION = "1.1.
|
|
17
|
+
var VERSION = "1.1.53";
|
|
18
18
|
|
|
19
19
|
// src/client/styles.ts
|
|
20
20
|
var W = 240;
|
|
@@ -62,24 +62,44 @@
|
|
|
62
62
|
boxShadow: "0 2px 8px rgba(0,0,0,0.35)",
|
|
63
63
|
zIndex: 10003
|
|
64
64
|
};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
65
|
+
function viewportSize() {
|
|
66
|
+
try {
|
|
67
|
+
if (typeof window === "undefined") return { w: 0, h: 0 };
|
|
68
|
+
const w = Number(window.innerWidth);
|
|
69
|
+
const h = Number(window.innerHeight);
|
|
70
|
+
return { w: isFinite(w) && w > 0 ? w : 0, h: isFinite(h) && h > 0 ? h : 0 };
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { w: 0, h: 0 };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function finiteOr(v, fallback) {
|
|
76
|
+
const n = Number(v);
|
|
77
|
+
return isFinite(n) ? n : fallback;
|
|
78
|
+
}
|
|
79
|
+
var menuStyle = (menu) => {
|
|
80
|
+
const vp = viewportSize();
|
|
81
|
+
const mx = finiteOr(menu && menu.x, 8);
|
|
82
|
+
const my = finiteOr(menu && menu.y, 8);
|
|
83
|
+
return {
|
|
84
|
+
position: "fixed",
|
|
85
|
+
// 窄视口(<220px)时 innerWidth-220 为负会把菜单甩出左边;夹到 8px 保底
|
|
86
|
+
left: (menu ? Math.max(8, Math.min(mx, vp.w > 0 ? vp.w - 220 : 8)) : 0) + "px",
|
|
87
|
+
top: (menu ? Math.max(8, Math.min(my, vp.h > 0 ? vp.h - 520 : 8)) : 0) + "px",
|
|
88
|
+
width: 210,
|
|
89
|
+
// 无 window 时用固定上限;有 window 时至少 200,避免窄视口把菜单压没
|
|
90
|
+
maxHeight: vp.h > 0 ? Math.max(200, Math.min(480, vp.h - 32)) : 480,
|
|
91
|
+
overflowY: "auto",
|
|
92
|
+
paddingBottom: 10,
|
|
93
|
+
background: "rgba(12,28,52,0.98)",
|
|
94
|
+
border: "1px solid #2E6BB8",
|
|
95
|
+
borderRadius: 10,
|
|
96
|
+
boxShadow: "0 12px 32px rgba(0,0,0,0.5)",
|
|
97
|
+
zIndex: 10001,
|
|
98
|
+
font: '13px/1.5 "Microsoft YaHei", sans-serif',
|
|
99
|
+
display: menu ? "block" : "none",
|
|
100
|
+
color: "#cfe9ff"
|
|
101
|
+
};
|
|
102
|
+
};
|
|
83
103
|
var menuItemStyle = (active) => ({
|
|
84
104
|
padding: "6px 14px",
|
|
85
105
|
cursor: "pointer",
|
|
@@ -95,21 +115,28 @@
|
|
|
95
115
|
display: "flex",
|
|
96
116
|
justifyContent: "space-between"
|
|
97
117
|
});
|
|
98
|
-
var
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
var CHAT_PANEL_W = 340;
|
|
119
|
+
var CHAT_PANEL_H = 380;
|
|
120
|
+
var chatPanelStyle = (open) => {
|
|
121
|
+
const vp = viewportSize();
|
|
122
|
+
const right = vp.w > 0 ? Math.min(236, Math.max(8, vp.w - CHAT_PANEL_W - 8)) : 236;
|
|
123
|
+
const bottom = vp.h > 0 ? Math.min(146, Math.max(8, vp.h - CHAT_PANEL_H)) : 146;
|
|
124
|
+
return {
|
|
125
|
+
position: "fixed",
|
|
126
|
+
right,
|
|
127
|
+
bottom,
|
|
128
|
+
width: CHAT_PANEL_W,
|
|
129
|
+
background: "linear-gradient(180deg, rgba(18,40,74,0.98), rgba(10,24,46,0.98))",
|
|
130
|
+
border: "1px solid rgba(93,160,240,0.55)",
|
|
131
|
+
borderRadius: 18,
|
|
132
|
+
boxShadow: "0 18px 48px rgba(0,0,0,0.55)",
|
|
133
|
+
font: '14px/1.6 "Microsoft YaHei", sans-serif',
|
|
134
|
+
color: "#dbeaff",
|
|
135
|
+
overflow: "hidden",
|
|
136
|
+
zIndex: 1e4,
|
|
137
|
+
display: open ? "block" : "none"
|
|
138
|
+
};
|
|
139
|
+
};
|
|
113
140
|
var headStyle = {
|
|
114
141
|
display: "flex",
|
|
115
142
|
justifyContent: "space-between",
|
|
@@ -3395,16 +3422,12 @@
|
|
|
3395
3422
|
}
|
|
3396
3423
|
ctx.restore();
|
|
3397
3424
|
}
|
|
3398
|
-
function
|
|
3399
|
-
if (!running) return;
|
|
3425
|
+
function drawFrame(t) {
|
|
3400
3426
|
const dt = Math.max(0, Math.min(50, t - last));
|
|
3401
3427
|
last = t;
|
|
3402
3428
|
maintCountdown -= 1;
|
|
3403
3429
|
if (maintCountdown <= 0) maintain();
|
|
3404
|
-
if (!visible)
|
|
3405
|
-
raf = requestAnimationFrame(loop);
|
|
3406
|
-
return;
|
|
3407
|
-
}
|
|
3430
|
+
if (!visible) return;
|
|
3408
3431
|
ctx.clearRect(0, 0, cw, ch);
|
|
3409
3432
|
if (t >= nextWagAt) {
|
|
3410
3433
|
wagStart = t;
|
|
@@ -3413,38 +3436,67 @@
|
|
|
3413
3436
|
}
|
|
3414
3437
|
const wagP = wagStart >= 0 && t >= wagStart && t <= wagStart + WAG_MS ? (t - wagStart) / WAG_MS : -1;
|
|
3415
3438
|
ctx.save();
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
blinkClosed
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
blinkClosed
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3439
|
+
try {
|
|
3440
|
+
if (wagP >= 0) {
|
|
3441
|
+
const env = Math.sin(Math.PI * wagP);
|
|
3442
|
+
const angle = Math.sin(wagP * Math.PI * 6) * 0.012 * env;
|
|
3443
|
+
const bob = Math.sin(wagP * Math.PI * 4) * 1.6 * env;
|
|
3444
|
+
ctx.translate(cw / 2, ch);
|
|
3445
|
+
ctx.rotate(angle);
|
|
3446
|
+
ctx.translate(0, bob);
|
|
3447
|
+
ctx.translate(-cw / 2, -ch);
|
|
3448
|
+
}
|
|
3449
|
+
drawHalo(t);
|
|
3450
|
+
if (img.complete && img.naturalWidth > 0) {
|
|
3451
|
+
ctx.drawImage(img, 0, 0, 1696, 1504, 0, 0, cw, ch);
|
|
3452
|
+
}
|
|
3453
|
+
drawFinDots(t);
|
|
3454
|
+
blinkT += dt;
|
|
3455
|
+
if (!blinkClosed && blinkT > blinkGap) {
|
|
3456
|
+
blinkClosed = true;
|
|
3457
|
+
blinkT = 0;
|
|
3458
|
+
}
|
|
3459
|
+
if (blinkClosed && blinkT > 150) {
|
|
3460
|
+
blinkClosed = false;
|
|
3461
|
+
blinkT = 0;
|
|
3462
|
+
blinkGap = 3200 + Math.random() * 1800;
|
|
3463
|
+
}
|
|
3464
|
+
if (blinkClosed) {
|
|
3465
|
+
drawEye(EYE_L, true);
|
|
3466
|
+
drawEye(EYE_R, true);
|
|
3467
|
+
}
|
|
3468
|
+
if (t < state.talkUntil) drawMouth(Math.sin(t / 90) > 0);
|
|
3469
|
+
drawSplashes(dt);
|
|
3470
|
+
} finally {
|
|
3471
|
+
ctx.restore();
|
|
3472
|
+
}
|
|
3447
3473
|
drawHearts(dt / 1e3);
|
|
3474
|
+
}
|
|
3475
|
+
const FRAME_ERROR_LIMIT = 300;
|
|
3476
|
+
let frameErrors = 0;
|
|
3477
|
+
function loop(t) {
|
|
3478
|
+
if (!running) return;
|
|
3479
|
+
try {
|
|
3480
|
+
drawFrame(t);
|
|
3481
|
+
frameErrors = 0;
|
|
3482
|
+
} catch (e) {
|
|
3483
|
+
frameErrors += 1;
|
|
3484
|
+
if (frameErrors === 1) {
|
|
3485
|
+
try {
|
|
3486
|
+
console.warn("[chubby-whale] canvas 绘制异常,已继续排帧重试:", e);
|
|
3487
|
+
} catch (e2) {
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
if (frameErrors >= FRAME_ERROR_LIMIT) {
|
|
3491
|
+
try {
|
|
3492
|
+
console.warn("[chubby-whale] canvas 连续 " + FRAME_ERROR_LIMIT + " 帧绘制异常,停止动画(已画出的画面保留)");
|
|
3493
|
+
} catch (e2) {
|
|
3494
|
+
}
|
|
3495
|
+
stop();
|
|
3496
|
+
return;
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
if (!running) return;
|
|
3448
3500
|
raf = requestAnimationFrame(loop);
|
|
3449
3501
|
}
|
|
3450
3502
|
function start() {
|
|
@@ -3893,6 +3945,49 @@
|
|
|
3893
3945
|
};
|
|
3894
3946
|
}
|
|
3895
3947
|
|
|
3948
|
+
// src/client/story-auto.ts
|
|
3949
|
+
var STORY_AUTO_KEY = "chubby-story-auto";
|
|
3950
|
+
function storage() {
|
|
3951
|
+
try {
|
|
3952
|
+
return typeof localStorage === "undefined" ? null : localStorage;
|
|
3953
|
+
} catch (e) {
|
|
3954
|
+
return null;
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
function isStoryAutoOn() {
|
|
3958
|
+
const s = storage();
|
|
3959
|
+
if (!s) return true;
|
|
3960
|
+
let raw = null;
|
|
3961
|
+
try {
|
|
3962
|
+
raw = s.getItem(STORY_AUTO_KEY);
|
|
3963
|
+
} catch (e) {
|
|
3964
|
+
return true;
|
|
3965
|
+
}
|
|
3966
|
+
if (raw === null) return true;
|
|
3967
|
+
const v = String(raw).trim().toLowerCase();
|
|
3968
|
+
return !(v === "0" || v === "off" || v === "false");
|
|
3969
|
+
}
|
|
3970
|
+
function hasStoryAutoStorage() {
|
|
3971
|
+
const s = storage();
|
|
3972
|
+
if (!s) return false;
|
|
3973
|
+
try {
|
|
3974
|
+
s.getItem(STORY_AUTO_KEY);
|
|
3975
|
+
return true;
|
|
3976
|
+
} catch (e) {
|
|
3977
|
+
return false;
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
function setStoryAuto(on) {
|
|
3981
|
+
const s = storage();
|
|
3982
|
+
if (!s) return false;
|
|
3983
|
+
try {
|
|
3984
|
+
s.setItem(STORY_AUTO_KEY, on ? "1" : "0");
|
|
3985
|
+
return true;
|
|
3986
|
+
} catch (e) {
|
|
3987
|
+
return false;
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3896
3991
|
// src/client/http.ts
|
|
3897
3992
|
var API_BASE = "/api/chubby-whale";
|
|
3898
3993
|
function apiUrl(path) {
|
|
@@ -4256,12 +4351,10 @@
|
|
|
4256
4351
|
host.story.reset();
|
|
4257
4352
|
});
|
|
4258
4353
|
function storyAutoPref(host) {
|
|
4259
|
-
|
|
4260
|
-
const s = localStorage.getItem("chubby-story-auto");
|
|
4261
|
-
return s === null ? true : s !== "0";
|
|
4262
|
-
} catch (e) {
|
|
4354
|
+
if (!hasStoryAutoStorage()) {
|
|
4263
4355
|
return host && host.storyState && host.storyState.auto ? !!host.storyState.auto.current : true;
|
|
4264
4356
|
}
|
|
4357
|
+
return isStoryAutoOn();
|
|
4265
4358
|
}
|
|
4266
4359
|
table.define("story-auto", (host) => {
|
|
4267
4360
|
const next = !storyAutoPref(host);
|
|
@@ -4269,10 +4362,7 @@
|
|
|
4269
4362
|
if (host.storyState && host.storyState.auto) host.storyState.auto.current = next;
|
|
4270
4363
|
} catch (e) {
|
|
4271
4364
|
}
|
|
4272
|
-
|
|
4273
|
-
localStorage.setItem("chubby-story-auto", next ? "1" : "0");
|
|
4274
|
-
} catch (e) {
|
|
4275
|
-
}
|
|
4365
|
+
setStoryAuto(next);
|
|
4276
4366
|
host.bubble(next ? "连播:开——讲完这段自动接下一段" : "连播:关——讲完这段就停", 4e3);
|
|
4277
4367
|
});
|
|
4278
4368
|
table.definePrefix("narration-rate:", (host, suffix) => {
|
|
@@ -5999,9 +6089,9 @@
|
|
|
5999
6089
|
} catch (e) {
|
|
6000
6090
|
}
|
|
6001
6091
|
};
|
|
6002
|
-
const del = (el, t, fn) => {
|
|
6092
|
+
const del = (el, t, fn, opts) => {
|
|
6003
6093
|
try {
|
|
6004
|
-
el.removeEventListener(t, fn);
|
|
6094
|
+
el.removeEventListener(t, fn, opts);
|
|
6005
6095
|
} catch (e) {
|
|
6006
6096
|
}
|
|
6007
6097
|
};
|
|
@@ -6143,10 +6233,10 @@
|
|
|
6143
6233
|
const dispose = () => {
|
|
6144
6234
|
if (target && win) {
|
|
6145
6235
|
if (mode === "pointer") {
|
|
6146
|
-
del(win, "pointerdown", onPointerDownCapture);
|
|
6147
|
-
del(win, "pointermove", onPointerMoveCapture);
|
|
6148
|
-
del(win, "pointerup", onPointerEndCapture);
|
|
6149
|
-
del(win, "pointercancel", onPointerEndCapture);
|
|
6236
|
+
del(win, "pointerdown", onPointerDownCapture, true);
|
|
6237
|
+
del(win, "pointermove", onPointerMoveCapture, true);
|
|
6238
|
+
del(win, "pointerup", onPointerEndCapture, true);
|
|
6239
|
+
del(win, "pointercancel", onPointerEndCapture, true);
|
|
6150
6240
|
del(win, "pointermove", onMove);
|
|
6151
6241
|
del(win, "pointerup", onEnd);
|
|
6152
6242
|
del(win, "pointercancel", onEnd);
|
|
@@ -6339,10 +6429,7 @@
|
|
|
6339
6429
|
}
|
|
6340
6430
|
} else {
|
|
6341
6431
|
refs.auto.current = false;
|
|
6342
|
-
|
|
6343
|
-
localStorage.setItem("chubby-story-auto", "0");
|
|
6344
|
-
} catch (e) {
|
|
6345
|
-
}
|
|
6432
|
+
setStoryAuto(false);
|
|
6346
6433
|
host.bubble(host.persona.line("poemDone") + "(《" + String(d && d.book || refs.book.current || "这本") + "》讲完啦,连播已关;想接着听点「接着说」)", 8e3);
|
|
6347
6434
|
}
|
|
6348
6435
|
}
|
|
@@ -6612,7 +6699,7 @@
|
|
|
6612
6699
|
host.bubble("(点播)接着说", 3e3);
|
|
6613
6700
|
try {
|
|
6614
6701
|
host.storyState.auto.current = true;
|
|
6615
|
-
|
|
6702
|
+
setStoryAuto(true);
|
|
6616
6703
|
} catch (e) {
|
|
6617
6704
|
}
|
|
6618
6705
|
host.story.start(host.storyState.book.current || "");
|
|
@@ -7776,7 +7863,7 @@
|
|
|
7776
7863
|
let ttsVoice = "zh-CN-XiaoyiNeural";
|
|
7777
7864
|
let chatLang = "auto";
|
|
7778
7865
|
try {
|
|
7779
|
-
storyAuto =
|
|
7866
|
+
storyAuto = isStoryAutoOn();
|
|
7780
7867
|
streamOn = localStorage.getItem("chubby-stream") !== "off";
|
|
7781
7868
|
ttsVoice = localStorage.getItem("chubby-tts-voice") || "zh-CN-XiaoyiNeural";
|
|
7782
7869
|
chatLang = localStorage.getItem("chubby-chatlang") || "auto";
|
package/dist/host/atomic.js
CHANGED
|
@@ -126,7 +126,7 @@ export function writeFileAtomic(file, data) {
|
|
|
126
126
|
mkdirSync(dirname(file), { recursive: true });
|
|
127
127
|
const tmp = file + '.' + process.pid + '.' + Math.random().toString(36).slice(2, 8) + '.tmp';
|
|
128
128
|
// POSIX:创建即 0600(umask 无法把权限放回去);Windows 忽略 mode 参数
|
|
129
|
-
const fd = openSync(tmp, 'w', 0o600);
|
|
129
|
+
const fd = openSync(tmp, 'w', 0o600); // 打开就失败(EACCES/ENOSPC):临时文件还没建出来,无需清理
|
|
130
130
|
try {
|
|
131
131
|
if (typeof data === 'string')
|
|
132
132
|
writeFileSync(fd, data, 'utf8');
|
|
@@ -140,12 +140,23 @@ export function writeFileAtomic(file, data) {
|
|
|
140
140
|
catch { } // 个别文件系统不支持 fsync,失败不当错误
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
-
|
|
143
|
+
catch (err) {
|
|
144
|
+
// 写到一半失败(磁盘满 / data 参数非法):半截临时文件必须删掉。
|
|
145
|
+
// 文件头承诺「失败清掉临时文件」,不走 rename 的失败路径同样适用。
|
|
144
146
|
try {
|
|
145
147
|
closeSync(fd);
|
|
146
148
|
}
|
|
147
149
|
catch { }
|
|
150
|
+
try {
|
|
151
|
+
unlinkSync(tmp);
|
|
152
|
+
}
|
|
153
|
+
catch { }
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
closeSync(fd);
|
|
148
158
|
}
|
|
159
|
+
catch { }
|
|
149
160
|
chmodPrivateFile(tmp); // 兜底:临时文件若已存在,'w' 会保留旧权限
|
|
150
161
|
try {
|
|
151
162
|
renameWithRetry(() => renameSync(tmp, file));
|
package/dist/host/cache.js
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
* FAT / exFAT 只有 2 秒精度,会出现「刚写就被判过期/排序并列」的偶发现象,属已知限制,
|
|
15
15
|
* 只影响缓存命中率,不影响正确性(宁可重算,不返回错内容)。
|
|
16
16
|
*/
|
|
17
|
-
import { createHash } from 'node:crypto';
|
|
18
17
|
import { readFile, writeFile, mkdir, readdir, unlink, stat, rename } from 'node:fs/promises';
|
|
19
18
|
import { join } from 'node:path';
|
|
20
19
|
import { isRetriableRenameError, chmodPrivateDir, stripBom } from './atomic.js';
|
|
@@ -23,17 +22,9 @@ const RENAME_RETRY_LIMIT = 3;
|
|
|
23
22
|
const RENAME_RETRY_DELAY_MS = 20;
|
|
24
23
|
/** prune 节流窗口:每次写都全量 readdir+stat 会把磁盘扫描串进回复延迟 */
|
|
25
24
|
const PRUNE_INTERVAL_MS = 30000;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
/** 短指纹(md5 前 8 位):拼缓存 key 用(如长期记忆摘要的指纹) */
|
|
30
|
-
export function shortHash(text) {
|
|
31
|
-
return createHash('md5').update(String(text || '')).digest('hex').slice(0, 8);
|
|
32
|
-
}
|
|
33
|
-
/** 二进制内容 hash(ASR 音频用完整字节,避免只取前 4000 字符撞 key) */
|
|
34
|
-
export function hashBytes(buf) {
|
|
35
|
-
return createHash('sha1').update(buf).digest('hex');
|
|
36
|
-
}
|
|
25
|
+
// hash 纯函数已移到 shared/hash.ts(路由也能直接 import,不必为了算 hash 依赖缓存实现)。
|
|
26
|
+
// 这里保留同名 re-export:旧导入路径(历史代码 / 测试)继续可用。
|
|
27
|
+
export { hashKey, shortHash, hashBytes } from '../shared/hash.js';
|
|
37
28
|
function sizeOf(value) {
|
|
38
29
|
if (value == null)
|
|
39
30
|
return 0;
|
|
@@ -162,13 +153,22 @@ export function createCache(opts) {
|
|
|
162
153
|
catch {
|
|
163
154
|
alive = false;
|
|
164
155
|
}
|
|
156
|
+
// await 期间可能有并发 setJson 改写了同一条目(记住:它先同步写内存、再落盘)。
|
|
157
|
+
// 这时绝不能拿手里的旧 hit 去 delete/set —— 那会把刚写进去的新值顶回旧值,
|
|
158
|
+
// 而且绕过了 forget 的字节扣减,memBytes 从此漂移(容量越用越小)。
|
|
159
|
+
const cur = mem.get(key);
|
|
160
|
+
if (cur !== hit) {
|
|
161
|
+
if (!cur || !fresh(cur, maxAgeMs))
|
|
162
|
+
return null;
|
|
163
|
+
return cloneHit(cur.v); // 只认新条目,旧值绝不回写
|
|
164
|
+
}
|
|
165
165
|
}
|
|
166
166
|
if (alive) {
|
|
167
167
|
mem.delete(key);
|
|
168
168
|
mem.set(key, hit); // 命中后挪到队尾(LRU)
|
|
169
169
|
return cloneHit(hit.v);
|
|
170
170
|
}
|
|
171
|
-
forget(key);
|
|
171
|
+
forget(key); // 走到这里 mem.get(key) 仍是 hit(没有 await:同步段内不会变)
|
|
172
172
|
}
|
|
173
173
|
try {
|
|
174
174
|
const file = join(opts.dir, key + '.' + ext);
|
|
@@ -200,19 +200,21 @@ export function createCache(opts) {
|
|
|
200
200
|
// 原子写:先写唯一临时文件再 rename 覆盖(并发同 key 不会交错产出坏文件)
|
|
201
201
|
const target = join(opts.dir, key + '.' + ext);
|
|
202
202
|
const tmp = target + '.' + process.pid + '.' + Math.random().toString(36).slice(2, 8) + '.tmp';
|
|
203
|
-
// POSIX:创建即 0600;Windows 忽略 mode。写出统一 UTF-8 无 BOM
|
|
204
|
-
if (ext === 'json')
|
|
205
|
-
await writeFile(tmp, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 });
|
|
206
|
-
else
|
|
207
|
-
await writeFile(tmp, value, { mode: 0o600 });
|
|
208
203
|
try {
|
|
204
|
+
// POSIX:创建即 0600;Windows 忽略 mode。写出统一 UTF-8 无 BOM
|
|
205
|
+
if (ext === 'json')
|
|
206
|
+
await writeFile(tmp, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 });
|
|
207
|
+
else
|
|
208
|
+
await writeFile(tmp, value, { mode: 0o600 });
|
|
209
209
|
await renameWithRetry(tmp, target);
|
|
210
210
|
}
|
|
211
211
|
catch (e) {
|
|
212
|
+
// 写失败(磁盘满 / 参数非法)与改名失败一样不能留半截临时文件:它会被
|
|
213
|
+
// 目录计数(prune 上限)和事后排障当成"缓存条目"。
|
|
212
214
|
try {
|
|
213
215
|
await unlink(tmp);
|
|
214
216
|
}
|
|
215
|
-
catch { }
|
|
217
|
+
catch { }
|
|
216
218
|
throw e;
|
|
217
219
|
}
|
|
218
220
|
await prune();
|
|
@@ -277,8 +279,8 @@ export function createCache(opts) {
|
|
|
277
279
|
pruning = false;
|
|
278
280
|
}
|
|
279
281
|
}
|
|
280
|
-
// 内存直写:和 setJson 一样经 remember 打 at(可选 ttl
|
|
281
|
-
function memSet(key, value, ttlMs = 0) { remember(key, value, ttlMs); }
|
|
282
|
+
// 内存直写:和 setJson 一样经 remember 打 at(可选 ttl)并拷贝入缓存(坑 27:绝不把调用方对象存进内部)
|
|
283
|
+
function memSet(key, value, ttlMs = 0) { remember(key, cloneHit(value), ttlMs); }
|
|
282
284
|
/** 清空缓存(内存 + 磁盘文件);用于「清空聊天缓存」「清除记忆」 */
|
|
283
285
|
async function clear() {
|
|
284
286
|
let n = 0;
|
package/dist/host/chat-core.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* · 历史截断 / 上下文指纹 / prompt 片段与消息组装
|
|
9
9
|
* 不涉及 HTTP、不涉及 fetch、不持有任何状态,便于离线断言(scripts/selftest.mjs 仍用 chat.ts 的再导出)。
|
|
10
10
|
*/
|
|
11
|
-
import { shortHash, hashKey } from '
|
|
11
|
+
import { shortHash, hashKey } from '../shared/hash.js';
|
|
12
12
|
import { fetchJson } from './http.js';
|
|
13
13
|
/** DeepSeek 对话接口地址(聊天与摘要共用) */
|
|
14
14
|
export const CHAT_URL = 'https://api.deepseek.com/chat/completions';
|
package/dist/host/routes/asr.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Key 由入口注入(getKey),路由不再自己读 config.json / 环境变量。
|
|
5
5
|
* 体积:请求体(base64)上限 12MB → 413;解码后音频上限 8MB;外呼超时 60 秒
|
|
6
6
|
*/
|
|
7
|
-
import { hashBytes } from '
|
|
7
|
+
import { hashBytes } from '../../shared/hash.js';
|
|
8
8
|
import { isInterruptWord, isNoiseText } from '../../shared/noise.js';
|
|
9
9
|
import { fetchJson, isTimeoutError, sendTooLarge } from '../http.js';
|
|
10
10
|
import { readJsonBody } from '../body.js';
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* {"allusions":[...],"tales":[...],"idioms":[...],"minsu":[...]}
|
|
5
5
|
* 元素为「名称」或「书名|标题」;民间故事同时写 tales(C# 读它)和 minsu。
|
|
6
6
|
* 读到旧格式 {"chengyu:名称": true} 时自动迁移成新结构。
|
|
7
|
-
*
|
|
7
|
+
* 一轮讲完只清空同一类别的记录,别的类别进度保留;一轮重来时先排除上一则,
|
|
8
|
+
* 避免「刚讲完又立刻同一则」(与念诗同口径,见 poem.ts 的同名处理)。
|
|
8
9
|
* 数据经 DataPort 取(本地/云端由 index.ts 注入);数据源读不到只记 CULTURE-LOAD-FAIL,按空池处理,绝不整类报错。
|
|
9
10
|
* 原子写/坏文件隔离走 host/atomic.ts,JSON 响应走 host/respond.ts。
|
|
10
11
|
*/
|
|
@@ -16,6 +17,8 @@ import { sendJson, sendError } from '../respond.js';
|
|
|
16
17
|
export function createCultureHandlers(opts) {
|
|
17
18
|
const SEEN_FILE = join(opts.runtimeDir || opts.dataDir, 'culture-seen.json');
|
|
18
19
|
let seen = null;
|
|
20
|
+
/** 上一次讲出来的条目键(按类别):一轮重来时先排除它,避免「刚讲完又立刻同一则」 */
|
|
21
|
+
const lastServed = new Map();
|
|
19
22
|
/** 文化数据经数据源端口取(本地/云端由 index.ts 注入);实现已兜底,这里只防注入实现意外抛错 */
|
|
20
23
|
async function loadCulture(kind) {
|
|
21
24
|
try {
|
|
@@ -132,6 +135,8 @@ export function createCultureHandlers(opts) {
|
|
|
132
135
|
/**
|
|
133
136
|
* 随机挑一条没讲过的。一轮讲完只清掉同类前缀的记录,
|
|
134
137
|
* 别的类别(典故/故事/成语)的进度不受影响。
|
|
138
|
+
* 一轮重来后先排除刚讲过的上一条(池子只有一条时只能照旧重复):
|
|
139
|
+
* 否则新一轮第一则有 1/n 概率与上一则完全相同,用户看到的就是「刚讲完又讲一遍」。
|
|
135
140
|
*/
|
|
136
141
|
function pickUnseen(arr, prefix, keyOf) {
|
|
137
142
|
if (!arr || arr.length === 0)
|
|
@@ -145,10 +150,14 @@ export function createCultureHandlers(opts) {
|
|
|
145
150
|
delete sn[k];
|
|
146
151
|
}
|
|
147
152
|
saveSeen();
|
|
148
|
-
|
|
153
|
+
const last = lastServed.get(prefix) || '';
|
|
154
|
+
pool = arr.filter((x) => full(x) !== last);
|
|
155
|
+
if (pool.length === 0)
|
|
156
|
+
pool = arr.slice();
|
|
149
157
|
}
|
|
150
158
|
const item = pool[Math.floor(Math.random() * pool.length)];
|
|
151
159
|
sn[full(item)] = true;
|
|
160
|
+
lastServed.set(prefix, full(item));
|
|
152
161
|
saveSeen();
|
|
153
162
|
return item;
|
|
154
163
|
}
|
package/dist/host/routes/poem.js
CHANGED
|
@@ -11,6 +11,12 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { writeJsonAtomic } from '../atomic.js';
|
|
12
12
|
import { sendJson, sendError } from '../respond.js';
|
|
13
13
|
import { poemKeyOf } from '../../shared/poem.js';
|
|
14
|
+
/**
|
|
15
|
+
* 分片数上限:正常数据是云端 poems-00..14 共 15 片(本地固定 1 片),这里留足余量。
|
|
16
|
+
* total 来自数据文件(外部输入),**绝不能直接当循环上界**:坏数据里的 1e9 / Infinity
|
|
17
|
+
* 会让每次念诗都空转上亿次、请求永不返回(分片越界时数据层快速返回空片,await 也拦不住)。
|
|
18
|
+
*/
|
|
19
|
+
const POEM_MAX_CHUNKS = 64;
|
|
14
20
|
export function createPoemHandler(opts) {
|
|
15
21
|
const SEEN_FILE = join(opts.runtimeDir || opts.dataDir || '.', 'poem-seen.json');
|
|
16
22
|
let seen = null;
|
|
@@ -25,7 +31,12 @@ export function createPoemHandler(opts) {
|
|
|
25
31
|
const first = await opts.dataPort.readPoemsChunk(0);
|
|
26
32
|
// 数据层(local/cloud)已经归一化过去重过,这里不再重复归一化(737 首诗每请求扫一遍纯属浪费)
|
|
27
33
|
const out = Array.isArray(first && first.items) ? first.items.slice() : [];
|
|
28
|
-
|
|
34
|
+
// 只认 1..POEM_MAX_CHUNKS 的有限数;NaN(缺字段/坏字符串)按 1 片,避免循环上界不可控
|
|
35
|
+
const declared = Number(first && first.total);
|
|
36
|
+
const total = Number.isFinite(declared) ? Math.min(Math.max(1, Math.trunc(declared)), POEM_MAX_CHUNKS) : 1;
|
|
37
|
+
if (declared > POEM_MAX_CHUNKS || declared === Infinity) {
|
|
38
|
+
console.error('POEM-TOTAL-CAP raw=' + String(declared) + ' used=' + total);
|
|
39
|
+
}
|
|
29
40
|
for (let i = 1; i < total; i++) {
|
|
30
41
|
const chunk = await opts.dataPort.readPoemsChunk(i);
|
|
31
42
|
if (chunk && Array.isArray(chunk.items))
|
|
@@ -80,6 +91,7 @@ export function createPoemHandler(opts) {
|
|
|
80
91
|
}
|
|
81
92
|
// 指定标题:按「完整标题 = 」优先,其次「包含」,命中就直接背这一首(不参与随机池)
|
|
82
93
|
let want = '';
|
|
94
|
+
let titleMissed = false;
|
|
83
95
|
try {
|
|
84
96
|
const u = new URL(String(req?.url || ''), 'http://local');
|
|
85
97
|
want = String(u.searchParams.get('title') || '').trim();
|
|
@@ -104,6 +116,7 @@ export function createPoemHandler(opts) {
|
|
|
104
116
|
return;
|
|
105
117
|
}
|
|
106
118
|
// 没匹配到就回落随机(并告诉客户端 matched:false)
|
|
119
|
+
titleMissed = true;
|
|
107
120
|
}
|
|
108
121
|
const sn = loadSeen();
|
|
109
122
|
let pool = list.filter((p) => !sn.has(poemKeyOf(p)));
|
|
@@ -122,6 +135,8 @@ export function createPoemHandler(opts) {
|
|
|
122
135
|
saveSeen();
|
|
123
136
|
sendJson(res, {
|
|
124
137
|
ok: true,
|
|
138
|
+
// 带了 ?title= 但库里没有:明确告诉客户端这是随机回落(不带 title 时维持原响应,不多出字段)
|
|
139
|
+
...(titleMissed ? { matched: false } : {}),
|
|
125
140
|
item: {
|
|
126
141
|
title: p.title,
|
|
127
142
|
author: p.author,
|
package/dist/host/routes/tts.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 缓存:文本+音色+语速+音调 hash → 复用 mp3(省时间、省调用)
|
|
5
5
|
* 语速由请求体 rate 决定(说书 -15%,普通聊天 +0%),服务端不再写死。
|
|
6
6
|
*/
|
|
7
|
-
import { hashKey } from '
|
|
7
|
+
import { hashKey } from '../../shared/hash.js';
|
|
8
8
|
import { sendTooLarge } from '../http.js';
|
|
9
9
|
import { readJsonBody } from '../body.js';
|
|
10
10
|
import { sendJson, sendBuffer } from '../respond.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/hash.ts —— 纯 hash 工具(host / client / routes 共用)
|
|
3
|
+
*
|
|
4
|
+
* 为什么从 host/cache.ts 搬出来(2026-11-07):`routes/asr.ts`、`routes/tts.ts` 只需要
|
|
5
|
+
* `hashBytes`/`hashKey` 这两个**纯函数**,却因此直接 import 了缓存实现,违反 ADR 0002
|
|
6
|
+
* 「路由只经端口依赖能力」。把 hash 放到 shared 后:
|
|
7
|
+
* · 路由 import shared/hash.js —— 合法(shared 是公共层);
|
|
8
|
+
* · host/cache.ts 继续 re-export 同名函数,旧导入路径不受影响。
|
|
9
|
+
*
|
|
10
|
+
* 纯函数、无 I/O、无平台分支:Node 与浏览器(只要 bundler 提供 node:crypto polyfill)都可用。
|
|
11
|
+
* 客户端目前不用它;留在 shared 是为了以后客户端要「内容指纹」时不至于再抄一份。
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
/** 内容 sha1(十六进制,40 字符):缓存 key 用 */
|
|
15
|
+
export function hashKey(text) {
|
|
16
|
+
return createHash('sha1').update(text).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
/** 短指纹(md5 前 8 位):拼缓存 key 用(如长期记忆摘要的指纹) */
|
|
19
|
+
export function shortHash(text) {
|
|
20
|
+
return createHash('md5').update(String(text || '')).digest('hex').slice(0, 8);
|
|
21
|
+
}
|
|
22
|
+
/** 二进制内容 hash(ASR 音频用完整字节,避免只取前 4000 字符撞 key) */
|
|
23
|
+
export function hashBytes(buf) {
|
|
24
|
+
return createHash('sha1').update(buf).digest('hex');
|
|
25
|
+
}
|
package/docs/adr/0002-/346/250/241/345/235/227/346/216/245/345/217/243/345/245/221/347/272/246.md
CHANGED
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
- 反向依赖:`host` 不引 `client`;`shared` 不引 `client/host`;`client` 不引 `host`;
|
|
24
24
|
- 深引用/绝对路径:不得引用 `dist/**`、`file:`、绝对路径;
|
|
25
25
|
- **路由越界**(本次新增):`src/host/routes/**` 不得直接 import `repos/`、`store.js`、`cache.js`、`chat-core.js`、`prefs.js`——必须经 `ports` 注入;
|
|
26
|
+
> 2026-11-07 修订:`chat-core.js` 列为**受控例外**。它是无状态的 chat 领域纯函数
|
|
27
|
+
> (常量 / 归一化 / 消息拼装 / 读请求体),不持有存储、不做 I/O 以外的事;把 12 个纯函数
|
|
28
|
+
> 塞进端口注入只增加仪式、不增加隔离收益。仍在禁令内的是 **store / repos / cache / prefs /
|
|
29
|
+
> story-repo / data 实现**——这些才是「改实现会波及路由」的真依赖。
|
|
26
30
|
- **循环风险**(本次新增):`src/client/**` 子模块不得反向 import 组件入口 `client.ts`。
|
|
27
31
|
|
|
28
32
|
### 3. 打包白名单(`package.json.files` 精确化)
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
> 架构宪章:**模块只对外暴露这些导出,外部不得读取模块内部实现**。
|
|
6
6
|
> 新增/修改导出 = 接口变更,必须同步调用点并在提交信息里写明。
|
|
7
7
|
|
|
8
|
-
> 统计:
|
|
8
|
+
> 统计:119 个模块文件,716 个导出项。
|
|
9
9
|
|
|
10
10
|
## 宿主(Node)(`src/host/`)
|
|
11
11
|
|
|
@@ -111,6 +111,7 @@
|
|
|
111
111
|
| `render.ts` | `ChatPanelCtx`、`HFunction`、`MenuEntry`、`MenuItemRenderCtx`、`MenuListCtx`、`MenuRuntime`、`listMenuEntries`、`menuRootProps`、`readMenuRuntime`、`renderChatPanel`、`renderMenuHeader`、`renderMenuItems` |
|
|
112
112
|
| `sim.ts` | `RepeatOptions`、`bigrams`、`isRepeat`、`normalizeText`、`similarity` |
|
|
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
|
+
| `story-auto.ts` | `STORY_AUTO_KEY`、`hasStoryAutoStorage`、`isStoryAutoOn`、`setStoryAuto` |
|
|
114
115
|
| `story.ts` | `StoryController`、`StoryHost`、`StoryRefs`、`createStoryController` |
|
|
115
116
|
| `styles.ts` | `CH`、`CW`、`H`、`SX`、`SY`、`VERSION`、`W`、`bodyStyle`、`canvasStyle`、`chatPanelStyle`、`headStyle`、`inputRowStyle`、`inputStyle`、`menuItemStyle`、`menuStyle`、`menuSubStyle`、`msgStyle`、`recoverDotStyle`、`sendStyle`、`stageStyle` |
|
|
116
117
|
| `text.ts` | `Deduper`、`SentenceBuffer`、`collapsePoemBody`、`collapseText`、`createDeduper`、`createSentenceBuffer`、`echoKey`、`estimateSpeakMs`、`isShortEchoFragment`、`normalizePlain`、`stripStageDirections`、`taleParts` |
|
|
@@ -132,6 +133,7 @@
|
|
|
132
133
|
| 模块 | 导出(接口) |
|
|
133
134
|
| --- | --- |
|
|
134
135
|
| `growth.ts` | `GROWTH_STAGES`、`growthStage` |
|
|
136
|
+
| `hash.ts` | `hashBytes`、`hashKey`、`shortHash` |
|
|
135
137
|
| `immutable.ts` | `deepFreeze` |
|
|
136
138
|
| `noise.ts` | `INTERRUPT_WORDS`、`NOISE_TEXTS`、`isInterruptWord`、`isNoiseText`、`normalizeNoiseKey` |
|
|
137
139
|
| `poem.ts` | `poemKeyOf` |
|
|
@@ -105,3 +105,8 @@
|
|
|
105
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
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
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 |
|
|
108
|
+
| B63 | 2026-11-07 | 主代理收口:新增 `client/story-auto.ts` 统一连播开关(读侧兼容 `on`/`1`/`0`/`off`/`false`,写侧统一 `1`/`0`),4 处调用改走单一入口;新增 `hasStoryAutoStorage()` 区分「开」与「读不到」,保住禁存储时的内存兜底;新增 `test-story-auto.mjs`(24) 并入 npm test;版本 1.1.51 | 双 tsc 0 / build 0 / **1919 项 0 失败** / 接口清单 118 模块 713 导出 | 见 git |
|
|
109
|
+
| B64 | 2026-11-07 | 内容路由分身:`poem.total` 加 1..64 上界(防坏数据把请求空转死)/ `?title=` 未命中下发 `matched:false` / `culture` 池空重来排除上一则;新增 `test-content-routes.mjs`(88);版本 1.1.52 | 双 tsc 0 / build 0 / 2080 项 0 失败 | 见 git |
|
|
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
|
+
| 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
|
+
| 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 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-chubby-whale",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.53",
|
|
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/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/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",
|