tavern-harness 0.1.13 → 0.1.14

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/index.html +1 -1
  3. package/package.json +1 -1
  4. package/persistence-server.mjs +54 -2
  5. package/src/App.tsx +91 -25
  6. package/src/components/AchievementModal.tsx +4 -2
  7. package/src/components/CharactersView.tsx +53 -25
  8. package/src/components/ChatView.tsx +264 -135
  9. package/src/components/Dashboard.tsx +12 -8
  10. package/src/components/DeleteConfirmDialog.tsx +1 -1
  11. package/src/components/FileDisplayModal.tsx +1 -1
  12. package/src/components/GameplayDialogs.tsx +2 -2
  13. package/src/components/NewSessionMenu.tsx +1 -1
  14. package/src/components/SettingsView.tsx +46 -29
  15. package/src/components/Sidebar.tsx +30 -3
  16. package/src/components/StatsView.tsx +55 -45
  17. package/src/components/VersionHistoryModal.tsx +1 -1
  18. package/src/components/WorkspaceFileManagerModal.tsx +1 -1
  19. package/src/components/shared.tsx +84 -24
  20. package/src/core/achievements.ts +22 -4
  21. package/src/core/avatarThumbnail.ts +245 -0
  22. package/src/core/characterBook.ts +2 -1
  23. package/src/core/i18n.ts +42 -6
  24. package/src/core/mobileViewport.ts +18 -3
  25. package/src/core/openai.ts +41 -19
  26. package/src/core/randomId.ts +19 -0
  27. package/src/core/rowFingerprint.ts +75 -0
  28. package/src/core/stats.ts +50 -5
  29. package/src/core/stickyBottom.ts +10 -1
  30. package/src/core/textPreview.ts +16 -0
  31. package/src/db/database.ts +101 -13
  32. package/src/generated/changelog.json +87 -1
  33. package/src/main.tsx +12 -21
  34. package/src/store/store.ts +179 -53
  35. package/src/theme/chat.css +62 -71
  36. package/src/theme/global.css +85 -21
  37. package/src/theme/theme.ts +13 -10
  38. package/src/theme/views.css +82 -5
  39. package/src/types/models.ts +2 -0
  40. package/vite.config.ts +8 -2
@@ -0,0 +1,245 @@
1
+ /** 缓存大尺寸位图头像的展示缩略图。 */
2
+ import { useEffect, useLayoutEffect, useSyncExternalStore } from 'react';
3
+
4
+ /** 超过该长度的 data URL 才值得缩略(约 72 KB 图片;再小的解析成本可忽略)。 */
5
+ export const AVATAR_THUMBNAIL_THRESHOLD = 96 * 1024;
6
+
7
+ /** 缩略图边长:最大展示尺寸 40px,128px 足够覆盖 3x DPR。 */
8
+ const AVATAR_THUMBNAIL_SIZE = 128;
9
+
10
+ /** 入库缩略图边长:比展示尺寸大一档,兼顾高 DPI 与二次导出。 */
11
+ export const AVATAR_STORAGE_SIZE = 256;
12
+
13
+ /** 缓存条数 + 总字符双上限:原图 data URL 作为 key 时,单条也可能很大。 */
14
+ const CACHE_LIMIT = 128;
15
+ const CACHE_MAX_CHARS = 4 * 1024 * 1024;
16
+
17
+ /** 可安全缩略的位图 data URL;GIF/SVG 跳过(保留动图与矢量)。 */
18
+ const THUMBNAILABLE_DATA_URL = /^data:image\/(?:png|jpe?g|webp|bmp|avif);base64,/i;
19
+
20
+ type ThumbnailState =
21
+ | { status: 'pending'; thumb: null }
22
+ | { status: 'ready'; thumb: string }
23
+ | { status: 'original'; thumb: null };
24
+
25
+ const cache = new Map<string, ThumbnailState>();
26
+ /** 当前挂载中的头像 URL:正在显示的条目不允许被预算淘汰,否则会“文字头像 ⇄ 图片头像”反复闪。 */
27
+ const activeUrls = new Map<string, number>();
28
+ const listeners = new Set<() => void>();
29
+ const queue: string[] = [];
30
+ const queued = new Set<string>();
31
+ let cachedChars = 0;
32
+ let pumping = false;
33
+
34
+ /** 是否是值得生成缩略图的超大位图 data URL。 */
35
+ export function isOversizedAvatarUrl(url: string | null | undefined): boolean {
36
+ return Boolean(url && url.length > AVATAR_THUMBNAIL_THRESHOLD && THUMBNAILABLE_DATA_URL.test(url));
37
+ }
38
+
39
+ function subscribe(listener: () => void): () => void {
40
+ listeners.add(listener);
41
+ return () => {
42
+ listeners.delete(listener);
43
+ };
44
+ }
45
+
46
+ function notify(): void {
47
+ for (const listener of listeners) listener();
48
+ }
49
+
50
+ function entryChars(url: string, state: ThumbnailState): number {
51
+ return url.length + (state.thumb?.length ?? 0);
52
+ }
53
+
54
+ function setCacheEntry(url: string, state: ThumbnailState): void {
55
+ const previous = cache.get(url);
56
+ if (previous) cachedChars -= entryChars(url, previous);
57
+ cache.set(url, state);
58
+ cachedChars += entryChars(url, state);
59
+ }
60
+
61
+ function deleteCacheEntry(url: string): void {
62
+ const entry = cache.get(url);
63
+ if (!entry) return;
64
+ cachedChars -= entryChars(url, entry);
65
+ cache.delete(url);
66
+ }
67
+
68
+ function trimCache(): void {
69
+ while ((cache.size > CACHE_LIMIT || cachedChars > CACHE_MAX_CHARS) && cache.size > 1) {
70
+ // 只淘汰已经不在界面上的条目;全部都在用时宁可暂时超出预算,
71
+ // 否则刚生成好的缩略图会被立刻删掉,然后又在同一个组件里重建。
72
+ let victim: string | undefined;
73
+ for (const key of cache.keys()) {
74
+ if (!activeUrls.has(key)) {
75
+ victim = key;
76
+ break;
77
+ }
78
+ }
79
+ if (victim == null) break;
80
+ deleteCacheEntry(victim);
81
+ }
82
+ }
83
+
84
+ /** 命中即移到队尾(Map 的插入顺序就是淘汰顺序)。 */
85
+ function touchCacheEntry(url: string): void {
86
+ const entry = cache.get(url);
87
+ if (!entry) return;
88
+ cache.delete(url);
89
+ cache.set(url, entry);
90
+ }
91
+
92
+ function requestIdle(callback: () => void): void {
93
+ const idle = (globalThis as { requestIdleCallback?: (cb: () => void, options?: { timeout: number }) => number }).requestIdleCallback;
94
+ if (typeof idle === 'function') {
95
+ idle(callback, { timeout: 500 });
96
+ return;
97
+ }
98
+ globalThis.setTimeout(callback, 50);
99
+ }
100
+
101
+ function enqueue(url: string): void {
102
+ if (cache.has(url) || queued.has(url)) return;
103
+ setCacheEntry(url, { status: 'pending', thumb: null });
104
+ trimCache();
105
+ queue.push(url);
106
+ queued.add(url);
107
+ if (!pumping) {
108
+ pumping = true;
109
+ requestIdle(() => void pump());
110
+ }
111
+ }
112
+
113
+ async function pump(): Promise<void> {
114
+ const url = queue.shift();
115
+ if (url) queued.delete(url);
116
+ if (!url) {
117
+ pumping = false;
118
+ return;
119
+ }
120
+ // 排队期间可能已被缓存淘汰:补回 pending,避免同一 URL 永远没有结果
121
+ if (!cache.has(url)) {
122
+ setCacheEntry(url, { status: 'pending', thumb: null });
123
+ trimCache();
124
+ }
125
+ try {
126
+ const thumb = await renderThumbnail(url, AVATAR_THUMBNAIL_SIZE);
127
+ setCacheEntry(url, { status: 'ready', thumb });
128
+ } catch {
129
+ // 解码失败(损坏图片 / 不支持的格式)就退回原图,不反复重试
130
+ setCacheEntry(url, { status: 'original', thumb: null });
131
+ }
132
+ notify();
133
+ trimCache();
134
+ if (queue.length > 0) {
135
+ // 逐个错开:连续生成多张大图时给浏览器留出渲染/交互的间隙
136
+ globalThis.setTimeout(() => requestIdle(() => void pump()), 80);
137
+ return;
138
+ }
139
+ pumping = false;
140
+ }
141
+
142
+ /**
143
+ * 生成缩略图 data URL(仅浏览器可用)。
144
+ * 用 createImageBitmap 的 resize 选项把缩放交给解码器,避免 drawImage 大幅缩小
145
+ * 时在主线程上做昂贵的重采样;不支持时退回 drawImage。
146
+ */
147
+ async function renderThumbnail(dataUrl: string, size: number): Promise<string> {
148
+ const image = new Image();
149
+ image.decoding = 'async';
150
+ image.src = dataUrl;
151
+ await image.decode();
152
+
153
+ // 与 CSS 的 background-size: cover 一致:先按短边居中裁出正方形,再缩放。
154
+ // 直接拉成 128×128 会把非正方形头像压扁/拉长。
155
+ const width = image.naturalWidth || image.width;
156
+ const height = image.naturalHeight || image.height;
157
+ const side = Math.max(1, Math.min(width, height));
158
+ const sx = Math.max(0, Math.round((width - side) / 2));
159
+ const sy = Math.max(0, Math.round((height - side) / 2));
160
+
161
+ const canvas = document.createElement('canvas');
162
+ canvas.width = size;
163
+ canvas.height = size;
164
+ // 必须使用软件(CPU)画布:GPU 画布第一次 toDataURL 需要同步读回,
165
+ // 实测单张 128px 缩略图会阻塞主线程 80–250 ms,几张卡叠起来就是将近 1 s。
166
+ const context = canvas.getContext('2d', { willReadFrequently: true });
167
+ if (!context) throw new Error('canvas unavailable');
168
+ context.imageSmoothingEnabled = true;
169
+ context.imageSmoothingQuality = 'high';
170
+
171
+ // 用 createImageBitmap 的裁剪 + resize 选项把缩放交给解码器,避免 drawImage
172
+ // 在主线程上对 4096px 原图做昂贵的重采样;不支持时退回 drawImage 的裁剪重载。
173
+ let bitmap: ImageBitmap | null = null;
174
+ try {
175
+ bitmap = await createImageBitmap(image, sx, sy, side, side, { resizeWidth: size, resizeHeight: size, resizeQuality: 'high' });
176
+ } catch {
177
+ bitmap = null;
178
+ }
179
+ if (bitmap) {
180
+ context.drawImage(bitmap, 0, 0, size, size);
181
+ bitmap.close();
182
+ } else {
183
+ context.drawImage(image, sx, sy, side, side, 0, 0, size, size);
184
+ }
185
+
186
+ // 优先 WebP(体积小且支持透明);浏览器不支持时会自动退回 PNG
187
+ const webp = canvas.toDataURL('image/webp', 0.82);
188
+ return webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/png');
189
+ }
190
+
191
+ /**
192
+ * 把超大头像压成缩略图;非超大 / 非位图 / 失败时原样返回。
193
+ * 用于导入角色卡、上传头像,避免把整张原图继续写进数据库。
194
+ */
195
+ export async function shrinkAvatarDataUrl(dataUrl: string | null | undefined, size = AVATAR_STORAGE_SIZE): Promise<string | null> {
196
+ if (!dataUrl || !isOversizedAvatarUrl(dataUrl)) return dataUrl ?? null;
197
+ try {
198
+ return await renderThumbnail(dataUrl, size);
199
+ } catch {
200
+ return dataUrl;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * 展示用头像 URL:
206
+ * - 普通头像直接返回原 URL;
207
+ * - 超大 data URL 返回缩略图;缩略图还没生成好时返回 null(调用方显示备用头像)。
208
+ */
209
+ export function useAvatarImageUrl(imageUrl: string | null | undefined): string | null {
210
+ const oversized = isOversizedAvatarUrl(imageUrl);
211
+ const displayUrl = useSyncExternalStore(
212
+ subscribe,
213
+ () => {
214
+ if (!imageUrl) return null;
215
+ if (!oversized) return imageUrl;
216
+ const entry = cache.get(imageUrl);
217
+ if (entry?.status === 'ready') return entry.thumb;
218
+ if (entry?.status === 'original') return imageUrl;
219
+ return null;
220
+ },
221
+ () => null,
222
+ );
223
+
224
+ // 在布局阶段登记「仍在使用」:所有卡片的登记都完成后,被动 effect 才开始
225
+ // 请求缩略图,避免批量挂载时把尚未登记的条目误当成可淘汰对象。
226
+ useLayoutEffect(() => {
227
+ if (!imageUrl || !oversized) return;
228
+ activeUrls.set(imageUrl, (activeUrls.get(imageUrl) ?? 0) + 1);
229
+ return () => {
230
+ const count = activeUrls.get(imageUrl);
231
+ if (count == null) return;
232
+ if (count <= 1) activeUrls.delete(imageUrl);
233
+ else activeUrls.set(imageUrl, count - 1);
234
+ };
235
+ }, [imageUrl, oversized]);
236
+
237
+ useEffect(() => {
238
+ if (!imageUrl || !oversized) return;
239
+ // 命中后移到队尾;被淘汰后再补一次生成请求(displayUrl 也在依赖里)。
240
+ if (cache.has(imageUrl)) touchCacheEntry(imageUrl);
241
+ else enqueue(imageUrl);
242
+ }, [imageUrl, oversized, displayUrl]);
243
+
244
+ return displayUrl;
245
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ChatCompletionTool, NetworkMessage } from '../types/models';
2
+ import { randomId } from './randomId';
2
3
 
3
4
  export const CHARACTER_BOOK_TOOL_NAME = 'recall_memory';
4
5
 
@@ -263,7 +264,7 @@ export function injectCharacterBookToolContext(messages: NetworkMessage[], chara
263
264
  const recalled = retrieveCharacterBook(characterBook, messages);
264
265
  if (!recalled) return messages;
265
266
  const keywords = [...new Set(recalled.entries.flatMap((entry) => entry.matchedKeywords))];
266
- const id = `character_book_ctx_${crypto.randomUUID()}`;
267
+ const id = `character_book_ctx_${randomId()}`;
267
268
  return [...messages,
268
269
  {
269
270
  role: 'assistant',
package/src/core/i18n.ts CHANGED
@@ -39,8 +39,10 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
39
39
  'control.takeOverFailed': '接管控制权失败,请稍后重试',
40
40
 
41
41
  // ---- 初始化 ----
42
+ 'init.loading': '正在准备酒馆',
43
+ 'init.loadingHint': '正在加载本地数据…',
42
44
  'init.failed': '初始化失败',
43
- 'init.failedHint': '加载本地数据失败,请确认持久化服务已启动后重试。',
45
+ 'init.failedHint': '加载本地数据失败或超时,请确认持久化服务已启动后重试。',
44
46
  'init.retry': '重试',
45
47
 
46
48
  // ---- 侧边栏 / 导航 ----
@@ -192,6 +194,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
192
194
  'settings.yoloMode': 'YOLO 模式',
193
195
  'settings.yoloModeDesc': '自动批准工具修改和 Shell 执行,无需逐次确认。仅在信任角色和技能时开启。',
194
196
  'settings.genParams': '🎛 生成参数',
197
+ 'settings.genParamsReset': '重置',
198
+ 'settings.genParamsResetTitle': '将生成参数恢复为默认值',
195
199
  'settings.tempDesc': '创造性与随机性',
196
200
  'settings.topPDesc': '核采样',
197
201
  'settings.topKDesc': '候选数量',
@@ -337,9 +341,11 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
337
341
  'workshop.skillBadJson': '(JSON 解析失败)',
338
342
 
339
343
  // ---- 成就 / 统计 ----
340
- 'stats.title': '🏆 成就',
344
+ 'stats.title': '成就',
341
345
  'stats.sub': '你的成就奖杯会陈列在这里',
342
346
  'stats.resetBtn': '重置统计',
347
+ 'stats.resetTitle': '重置生涯统计',
348
+ 'stats.resetDesc': '清除 Token、对话轮次与角色活跃度统计;已解锁的成就奖杯将被保留。',
343
349
  'stats.resetConfirm': '确定重置生涯统计?(不会清除已解锁的成就奖杯)',
344
350
  'stats.shelfTitle': '🎖️ 奖杯陈列柜',
345
351
  'stats.unlocked': '已解锁',
@@ -372,6 +378,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
372
378
  'ach.nightOwl.desc': '累计使用 1,000,000 Token',
373
379
  'ach.legend.name': '酒馆传奇',
374
380
  'ach.legend.desc': '累计使用 100,000,000 Token',
381
+ 'ach.thShareholder.name': 'TH股东',
382
+ 'ach.thShareholder.desc': '5 秒内连续点击首页图标 5 次',
375
383
 
376
384
  // ---- 成就解锁弹窗 ----
377
385
  'achModal.title': '🎉 成就解锁!',
@@ -421,6 +429,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
421
429
  'toast.sessionReset': '会话已重置',
422
430
  'toast.sessionPinned': '已置顶对话',
423
431
  'toast.sessionUnpinned': '已取消置顶',
432
+ 'toast.genParamsReset': '生成参数已恢复为默认值',
424
433
  'toast.exportCanceled': '已取消导出',
425
434
  'toast.gameplayExported': '游戏已导出',
426
435
  'toast.gameplayImported': '已导入游戏「{title}」:新角色 {npc} 个、世界书 {wb}、技能 {tools} 个、对话 {msgs} 条',
@@ -430,6 +439,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
430
439
  'toast.mentionRequired': '仅 @ 模式下,请先 @ 一位角色再发送消息',
431
440
  'toast.noEndpoint': '请在「模型服务 Provider」中添加并启用一个端点,并选择模型',
432
441
  'toast.genFailed': '生成失败: {msg}',
442
+ 'toast.emptyModelResponse': '模型未返回内容。请确认所选模型可用后重试。',
443
+ 'chat.interrupted': '生成已中断,已保留部分内容。',
433
444
  'toast.contextCompressionFailed': '上下文压缩失败,将继续使用原始历史: {msg}',
434
445
  'toast.rawExported': '已导出原始日志(自动脱敏)',
435
446
  'toast.copied': '已复制到剪贴板',
@@ -470,6 +481,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
470
481
 
471
482
  // ---- 工具执行输出(用户可见部分) ----
472
483
  'tool.timeout': '请求超时',
484
+ 'tool.networkError': '无法连接到模型服务,请检查网络与 Provider 的 Base URL',
473
485
  'tool.diceRoll': '掷骰 {expr}: [{rolls}] = {total}{crit}',
474
486
  'tool.diceCrit20': ' 大成功!',
475
487
  'tool.diceCrit1': ' 大失败!',
@@ -537,8 +549,10 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
537
549
  'control.takeOverHere': '點擊接管控制權',
538
550
  'control.takeOverFailed': '接管控制權失敗,請稍後重試',
539
551
 
552
+ 'init.loading': '正在準備酒館',
553
+ 'init.loadingHint': '正在載入本機資料…',
540
554
  'init.failed': '初始化失敗',
541
- 'init.failedHint': '載入本機資料失敗,請確認持久化服務已啟動後重試。',
555
+ 'init.failedHint': '載入本機資料失敗或逾時,請確認持久化服務已啟動後重試。',
542
556
  'init.retry': '重試',
543
557
 
544
558
  'nav.chat': '對話',
@@ -685,6 +699,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
685
699
  'settings.yoloMode': 'YOLO 模式',
686
700
  'settings.yoloModeDesc': '自動批准工具修改和 Shell 執行,無需逐次確認。僅在信任角色和技能時開啟。',
687
701
  'settings.genParams': '🎛 產生參數',
702
+ 'settings.genParamsReset': '重設',
703
+ 'settings.genParamsResetTitle': '將產生參數恢復為預設值',
688
704
  'settings.tempDesc': '創造性與隨機性',
689
705
  'settings.topPDesc': '核採樣',
690
706
  'settings.topKDesc': '候選數量',
@@ -828,9 +844,11 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
828
844
  'workshop.skillNoParams': '(無參數)',
829
845
  'workshop.skillBadJson': '(JSON 解析失敗)',
830
846
 
831
- 'stats.title': '🏆 成就',
847
+ 'stats.title': '成就',
832
848
  'stats.sub': '你的成就獎盃會陳列在這裡',
833
849
  'stats.resetBtn': '重置統計',
850
+ 'stats.resetTitle': '重置生涯統計',
851
+ 'stats.resetDesc': '清除 Token、對話輪次與角色活躍度統計;已解鎖的成就獎盃將被保留。',
834
852
  'stats.resetConfirm': '確定重置生涯統計?(不會清除已解鎖的成就獎盃)',
835
853
  'stats.shelfTitle': '🎖️ 獎盃陳列櫃',
836
854
  'stats.unlocked': '已解鎖',
@@ -862,6 +880,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
862
880
  'ach.nightOwl.desc': '累計使用 1,000,000 Token',
863
881
  'ach.legend.name': '酒館傳奇',
864
882
  'ach.legend.desc': '累計使用 100,000,000 Token',
883
+ 'ach.thShareholder.name': 'TH股東',
884
+ 'ach.thShareholder.desc': '5 秒內連續點擊首頁圖示 5 次',
865
885
 
866
886
  'achModal.title': '🎉 成就解鎖!',
867
887
  'achModal.total': '生涯已累計 {n} Tokens',
@@ -909,6 +929,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
909
929
  'toast.sessionReset': '會話已重置',
910
930
  'toast.sessionPinned': '已置頂對話',
911
931
  'toast.sessionUnpinned': '已取消置頂',
932
+ 'toast.genParamsReset': '產生參數已恢復為預設值',
912
933
  'toast.exportCanceled': '已取消匯出',
913
934
  'toast.exportFailed': '匯出失敗: {msg}',
914
935
  'toast.busy': '正在產生中,請稍候',
@@ -916,6 +937,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
916
937
  'toast.mentionRequired': '僅 @ 模式下,請先 @ 一位角色再傳送訊息',
917
938
  'toast.noEndpoint': '請在「模型服務 Provider」中新增並啟用一個端點,並選擇模型',
918
939
  'toast.genFailed': '產生失敗: {msg}',
940
+ 'toast.emptyModelResponse': '模型未回傳內容。請確認所選模型可用後重試。',
941
+ 'chat.interrupted': '產生已中斷,已保留部分內容。',
919
942
  'toast.contextCompressionFailed': '上下文壓縮失敗,將繼續使用原始歷史: {msg}',
920
943
  'toast.rawExported': '已匯出原始日誌(自動脫敏)',
921
944
  'toast.copied': '已複製到剪貼簿',
@@ -955,6 +978,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
955
978
  'toast.canceled': 'CANCELLED: 使用者取消了 {name}',
956
979
 
957
980
  'tool.timeout': '請求逾時',
981
+ 'tool.networkError': '無法連線到模型服務,請檢查網路與 Provider 的 Base URL',
958
982
  'tool.diceRoll': '擲骰 {expr}: [{rolls}] = {total}{crit}',
959
983
  'tool.diceCrit20': ' 大成功!',
960
984
  'tool.diceCrit1': ' 大失敗!',
@@ -1021,8 +1045,10 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1021
1045
  'control.takeOverHere': 'Click to take control',
1022
1046
  'control.takeOverFailed': 'Failed to take control. Please try again.',
1023
1047
 
1048
+ 'init.loading': 'Preparing the tavern',
1049
+ 'init.loadingHint': 'Loading local data…',
1024
1050
  'init.failed': 'Initialization failed',
1025
- 'init.failedHint': 'Failed to load local data. Make sure the persistence service is running, then try again.',
1051
+ 'init.failedHint': 'Loading local data failed or timed out. Make sure the persistence service is running, then try again.',
1026
1052
  'init.retry': 'Retry',
1027
1053
 
1028
1054
  'nav.chat': 'Chat',
@@ -1169,6 +1195,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1169
1195
  'settings.yoloMode': 'YOLO mode',
1170
1196
  'settings.yoloModeDesc': 'Automatically approve tool changes and Shell commands without asking each time. Only enable for trusted characters and skills.',
1171
1197
  'settings.genParams': '🎛 Generation parameters',
1198
+ 'settings.genParamsReset': 'Reset',
1199
+ 'settings.genParamsResetTitle': 'Restore generation parameters to their default values',
1172
1200
  'settings.tempDesc': 'Creativity & randomness',
1173
1201
  'settings.topPDesc': 'Nucleus sampling',
1174
1202
  'settings.topKDesc': 'Candidate count',
@@ -1312,9 +1340,11 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1312
1340
  'workshop.skillNoParams': '(No parameters)',
1313
1341
  'workshop.skillBadJson': '(Failed to parse JSON)',
1314
1342
 
1315
- 'stats.title': '🏆 Achievements',
1343
+ 'stats.title': 'Achievements',
1316
1344
  'stats.sub': 'Your achievement trophies are displayed here',
1317
1345
  'stats.resetBtn': 'Reset stats',
1346
+ 'stats.resetTitle': 'Reset career stats',
1347
+ 'stats.resetDesc': 'Clear token, conversation, and character activity statistics. Unlocked trophies will be kept.',
1318
1348
  'stats.resetConfirm': 'Reset career stats? (Unlocked trophies won\'t be cleared)',
1319
1349
  'stats.shelfTitle': '🎖️ Trophy Shelf',
1320
1350
  'stats.unlocked': 'Unlocked',
@@ -1346,6 +1376,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1346
1376
  'ach.nightOwl.desc': 'Cumulatively used 1,000,000 tokens',
1347
1377
  'ach.legend.name': 'Tavern Legend',
1348
1378
  'ach.legend.desc': 'Cumulatively used 100,000,000 tokens',
1379
+ 'ach.thShareholder.name': 'TH Shareholder',
1380
+ 'ach.thShareholder.desc': 'Clicked the home icon 5 times within 5 seconds',
1349
1381
 
1350
1382
  'achModal.title': '🎉 Achievement unlocked!',
1351
1383
  'achModal.total': 'Lifetime total {n} tokens',
@@ -1391,6 +1423,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1391
1423
  'toast.sessionReset': 'Conversation reset',
1392
1424
  'toast.sessionPinned': 'Conversation pinned',
1393
1425
  'toast.sessionUnpinned': 'Conversation unpinned',
1426
+ 'toast.genParamsReset': 'Generation parameters restored to defaults',
1394
1427
  'toast.exportCanceled': 'Export canceled',
1395
1428
  'toast.gameplayExported': 'Gameplay exported',
1396
1429
  'toast.gameplayImported': 'Imported gameplay "{title}": {npc} new characters, Lorebook {wb}, {tools} skills, {msgs} messages',
@@ -1400,6 +1433,8 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1400
1433
  'toast.mentionRequired': 'Mention-only mode requires @ a character before sending',
1401
1434
  'toast.noEndpoint': 'Add and enable an endpoint under "Model Providers" and select a model',
1402
1435
  'toast.genFailed': 'Generation failed: {msg}',
1436
+ 'toast.emptyModelResponse': 'The model returned no content. Check that the selected model is available, then try again.',
1437
+ 'chat.interrupted': 'Generation interrupted; partial content was kept.',
1403
1438
  'toast.contextCompressionFailed': 'Context compression failed; using the original history: {msg}',
1404
1439
  'toast.rawExported': 'Raw log exported (auto-sanitized)',
1405
1440
  'toast.copied': 'Copied to clipboard',
@@ -1439,6 +1474,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1439
1474
  'toast.canceled': 'CANCELLED: user canceled {name}',
1440
1475
 
1441
1476
  'tool.timeout': 'Request timed out',
1477
+ 'tool.networkError': 'Unable to reach the model service. Check your network and the provider Base URL',
1442
1478
  'tool.diceRoll': '{expr}: [{rolls}] = {total}{crit}',
1443
1479
  'tool.diceCrit20': ' Critical hit!',
1444
1480
  'tool.diceCrit1': ' Critical fail!',
@@ -59,6 +59,8 @@ let openTimer = 0;
59
59
  let opening = false;
60
60
  let openingStart = 0;
61
61
  let appliedHeight: number | null = null;
62
+ /** 输入框获得焦点后,键盘展开完成前临时锁住壳层高度。 */
63
+ let focusHeightLock = false;
62
64
  /** 是否用显式像素高度控制壳层(不跟随 100%)。 */
63
65
  let explicitHeight = false;
64
66
 
@@ -71,7 +73,7 @@ function isEdgeAndroid(): boolean {
71
73
  }
72
74
 
73
75
  function usesExplicitHeight(): boolean {
74
- return explicitHeight || isEdgeAndroid();
76
+ return focusHeightLock || explicitHeight || isEdgeAndroid();
75
77
  }
76
78
 
77
79
  function applyHeight(px: number | null): void {
@@ -119,6 +121,7 @@ function reset(): void {
119
121
  keyboardOpen = false;
120
122
  focused = false;
121
123
  keyboardAtLastTouch = false;
124
+ focusHeightLock = false;
122
125
  pendingSmall = null;
123
126
  holdBaseHeight = 0;
124
127
  // 需要显式像素高度时(Edge Android,或已观察到工具栏延迟归位)按当前高度排版;
@@ -153,6 +156,7 @@ function scheduleCloseSettle(): void {
153
156
 
154
157
  function closeSettled(height: number): void {
155
158
  keyboardOpen = false;
159
+ focusHeightLock = false;
156
160
  focused = false;
157
161
  closedHeight = height;
158
162
  closedCandidate = height;
@@ -299,6 +303,7 @@ function onResize(): void {
299
303
  pendingSmall = null;
300
304
  holdBaseHeight = 0;
301
305
  keyboardOpen = true;
306
+ focusHeightLock = false;
302
307
  // 键盘高度可能还会二次变化(候选栏/工具栏收起),等一小段再一次性落位,
303
308
  // 避免输入框先弹到较高位置、再落回键盘上方。
304
309
  opening = true;
@@ -345,14 +350,24 @@ export function initMobileViewport(): void {
345
350
  document.addEventListener(
346
351
  'focusin',
347
352
  (event) => {
348
- if (isEditableTarget(event.target)) focused = true;
353
+ if (!isEditableTarget(event.target)) return;
354
+ focused = true;
355
+ // Android 会先让 `dvh` 收缩一帧、随后才派发足够大的 resize 来识别键盘。
356
+ // 在这段间隙锁住壳层,编辑弹窗不会先缩小下沉再上移。
357
+ focusHeightLock = isMobileViewport() && !keyboardOpen;
358
+ if (focusHeightLock) applyHeight(window.innerHeight);
349
359
  },
350
360
  true,
351
361
  );
352
362
  document.addEventListener(
353
363
  'focusout',
354
364
  (event) => {
355
- if (isEditableTarget(event.target)) focused = false;
365
+ if (!isEditableTarget(event.target)) return;
366
+ focused = false;
367
+ if (focusHeightLock && !keyboardOpen) {
368
+ focusHeightLock = false;
369
+ followViewport(window.innerHeight);
370
+ }
356
371
  },
357
372
  true,
358
373
  );
@@ -8,7 +8,13 @@ import { fallbackToolCallId } from './turnLoop';
8
8
  import { isNetworkLikeError, toProxyUrl } from './proxy';
9
9
  import { translate } from './i18n';
10
10
 
11
- const OPENAI_TIMEOUTS = { read: 60_000 };
11
+ /**
12
+ * 连接超时与读超时分开:
13
+ * - 连接超时覆盖「还没拿到响应头」的阶段。手机通过局域网打开应用时,Provider
14
+ * 若只在内网 / 本机可达,直连会一直卡在连接上;这时应该尽快改走同源代理。
15
+ * - 读超时覆盖「已建立连接、等待下一个数据块」,思考模型首 token 可能很慢。
16
+ */
17
+ const OPENAI_TIMEOUTS = { connect: 20_000, read: 60_000 };
12
18
 
13
19
  interface ToolDelta {
14
20
  id: string;
@@ -33,6 +39,11 @@ function* urlCandidates(baseUrl: string): Generator<string> {
33
39
  // SSE 流式解析器 — 支持 thinking / tool_calls / usage
34
40
  // ============================================================
35
41
 
42
+ type StreamAttempt =
43
+ | { status: 'done' }
44
+ /** 本轮没有收到响应(连接失败 / 连接超时),可以改试下一个候选地址 */
45
+ | { status: 'retry' };
46
+
36
47
  export async function streamChatCompletions(
37
48
  baseUrl: string,
38
49
  apiKey: string,
@@ -40,13 +51,17 @@ export async function streamChatCompletions(
40
51
  onChunk: (chunk: ChatStreamChunk) => void,
41
52
  signal?: AbortSignal
42
53
  ): Promise<void> {
43
- // 依次尝试:直连 → 同源代理(CORS 被拦截时自动回退)
54
+ // 依次尝试:直连 → 同源代理(CORS / 内网不可达时自动回退)
44
55
  for (const url of urlCandidates(baseUrl)) {
45
- const ok = await attemptStream(url, apiKey, request, onChunk, signal);
46
- if (ok) return;
47
- // 直连失败且是网络层错误(CORS 等)→ 继续尝试下一个候选(代理)
48
- // 若是业务错误(HTTP 4xx/5xx、解析失败)则不重试
56
+ const attempt = await attemptStream(url, apiKey, request, onChunk, signal);
57
+ if (attempt.status === 'done') return;
58
+ }
59
+ // 所有候选都连不上:必须显式报错。否则调用方会拿到一次「空回复」并反复重试。
60
+ if (signal?.aborted) {
61
+ onChunk({ type: 'done' });
62
+ return;
49
63
  }
64
+ onChunk({ type: 'error', message: translate('tool.networkError') });
50
65
  }
51
66
 
52
67
  async function attemptStream(
@@ -55,7 +70,7 @@ async function attemptStream(
55
70
  request: ChatCompletionRequest,
56
71
  onChunk: (chunk: ChatStreamChunk) => void,
57
72
  signal?: AbortSignal
58
- ): Promise<boolean> {
73
+ ): Promise<StreamAttempt> {
59
74
  const controller = new AbortController();
60
75
  const onAbort = () => controller.abort();
61
76
  if (signal) {
@@ -63,11 +78,13 @@ async function attemptStream(
63
78
  else signal.addEventListener('abort', onAbort);
64
79
  }
65
80
  let timer: ReturnType<typeof setTimeout> | null = null;
66
- const resetReadTimeout = () => {
81
+ const resetTimeout = (ms: number) => {
67
82
  if (timer) clearTimeout(timer);
68
- timer = setTimeout(() => controller.abort(), OPENAI_TIMEOUTS.read);
83
+ timer = setTimeout(() => controller.abort(), ms);
69
84
  };
70
- resetReadTimeout();
85
+ /** 是否已经拿到响应头:拿到之后就不再回退代理,避免同一次生成被请求两次 */
86
+ let responded = false;
87
+ resetTimeout(OPENAI_TIMEOUTS.connect);
71
88
 
72
89
  try {
73
90
  const resp = await fetch(url, {
@@ -79,6 +96,7 @@ async function attemptStream(
79
96
  body: JSON.stringify(request),
80
97
  signal: controller.signal,
81
98
  });
99
+ responded = true;
82
100
 
83
101
  if (!resp.ok) {
84
102
  let detail = '';
@@ -89,12 +107,12 @@ async function attemptStream(
89
107
  /* ignore */
90
108
  }
91
109
  onChunk({ type: 'error', message: `HTTP ${resp.status}: ${detail || resp.statusText}` });
92
- return true; // 业务性错误,不再重试代理
110
+ return { status: 'done' }; // 业务性错误,不再重试代理
93
111
  }
94
112
 
95
113
  if (!resp.body) {
96
114
  onChunk({ type: 'error', message: 'Response body is empty (streaming not supported?)' });
97
- return true;
115
+ return { status: 'done' };
98
116
  }
99
117
 
100
118
  const reader = resp.body.getReader();
@@ -103,10 +121,11 @@ async function attemptStream(
103
121
  // Delta 工具调用按 index 组装
104
122
  const toolDeltas = new Map<number, ToolDelta>();
105
123
  let buffer = '';
124
+ resetTimeout(OPENAI_TIMEOUTS.read);
106
125
  while (true) {
107
126
  const { done, value } = await reader.read();
108
127
  if (done) break;
109
- resetReadTimeout();
128
+ resetTimeout(OPENAI_TIMEOUTS.read);
110
129
  buffer += decoder.decode(value, { stream: true });
111
130
 
112
131
  // 按行切分 SSE
@@ -134,22 +153,25 @@ async function attemptStream(
134
153
  // 收尾:补发未完成的工具调用
135
154
  emitFinalToolCalls(toolDeltas, onChunk);
136
155
  onChunk({ type: 'done' });
137
- return true;
156
+ return { status: 'done' };
138
157
  } catch (e) {
139
158
  if (signal?.aborted) {
140
159
  onChunk({ type: 'done' });
141
- return true;
160
+ return { status: 'done' };
142
161
  }
143
162
  if ((e as Error).name === 'AbortError' || (e as Error).name === 'TimeoutError') {
163
+ // 连接阶段就超时(手机直连内网地址不可达)→ 交给外层换代理再试;
164
+ // 已经收到响应头则属于读超时,重试会造成重复生成。
165
+ if (!responded) return { status: 'retry' };
144
166
  onChunk({ type: 'error', message: translate('tool.timeout') });
145
- return true;
167
+ return { status: 'done' };
146
168
  }
147
- // 网络层错误:若还有代理候选则返回 false 让外层重试;否则报错
169
+ // 网络层错误:若还没拿到响应则换下一个候选(代理),否则按错误上报
148
170
  if (isNetworkLikeError(e)) {
149
- return false;
171
+ if (!responded) return { status: 'retry' };
150
172
  }
151
173
  onChunk({ type: 'error', message: (e as Error).message || String(e) });
152
- return true;
174
+ return { status: 'done' };
153
175
  } finally {
154
176
  if (timer) clearTimeout(timer);
155
177
  if (signal) signal.removeEventListener('abort', onAbort);