tavern-harness 0.1.21 → 0.1.23

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
@@ -3,6 +3,54 @@
3
3
  > 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
4
4
  > 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
5
5
 
6
+ ## [0.1.23] - 2026-10-26
7
+
8
+ ### 中文
9
+
10
+ #### 改进
11
+
12
+ - 生成参数滑杆不再需要先点「解锁」:直接拖动即可调整,只有明显的横向滑动才会改变数值,上下滚动设置页时不会再误改温度、上下文压缩等参数
13
+ - 「重置」按钮不再受锁定状态限制,只要参数不是默认值就能一键恢复
14
+ - 酒馆老板的内置文案本地化明显加快:只读取与它相关的消息和参与者,并一次性批量写入,历史记录较多时也不会再逐条卡顿
15
+
16
+ #### 修复
17
+
18
+ - 移动端中文衬线字体在缺少对应字重的系统上也能正常显示加粗,不再一律退化成常规字重
19
+ - 移动端点按按钮、滑杆、开关时不再出现系统默认的蓝色高亮块
20
+
21
+ ### English
22
+
23
+ #### Changed
24
+
25
+ - Generation parameter sliders no longer need to be unlocked first: drag them directly, and only a clearly horizontal drag changes the value, so scrolling the settings page can no longer alter temperature, context compression, and other values by accident
26
+ - The "Reset" button is no longer tied to a lock state and is available whenever the parameters differ from their defaults
27
+ - Localizing the built-in Tavern Keeper text is much faster: only its own messages and participants are read, and they are written in a single batch, so large chat histories no longer stall
28
+
29
+ #### Fixed
30
+
31
+ - Chinese serif text on mobile now renders bold correctly on systems whose fallback fonts lack the requested weight, instead of silently falling back to regular
32
+ - Tapping buttons, sliders, and switches on mobile no longer flashes the system's default blue highlight
33
+
34
+ ## [0.1.22] - 2026-10-26
35
+
36
+ ### 中文
37
+
38
+ #### 改进
39
+
40
+ - 生成参数默认锁定:「设置 → 生成参数」里的滑杆和下拉框要先点「解锁」才能调整,离开设置页后会自动重新锁定,避免滑动页面时误改温度、上下文压缩等参数
41
+ - 「重置」按钮在锁定状态下不可点击,解锁后才能恢复默认值
42
+ - 会话列表的标题和副标题只在内容确实溢出时才做末尾渐隐,短标题不再被淡边影响
43
+ - 移动端顶部导航按钮之间的间距略微加大,点击时更不容易误触相邻按钮
44
+
45
+ ### English
46
+
47
+ #### Changed
48
+
49
+ - Generation parameters are now locked by default: the sliders and dropdowns under Settings → Generation parameters stay read-only until you tap "Unlock", and they lock again when you leave the settings page, so scrolling can no longer change temperature, context compression, and other values by accident
50
+ - The "Reset" button is disabled while parameters are locked and becomes available after unlocking
51
+ - Session list titles and subtitles only fade out at the end when the text actually overflows, so short titles are no longer dimmed by the mask
52
+ - Mobile top-bar navigation buttons have a little more spacing, reducing accidental taps on neighbours
53
+
6
54
  ## [0.1.21] - 2026-10-26
7
55
 
8
56
  ### 中文
@@ -608,6 +656,8 @@
608
656
  - Vite + React + Zustand + Dexie stack with browser-side local persistence
609
657
 
610
658
  <!-- 链接定义 -->
659
+ [0.1.23]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.22...v0.1.23
660
+ [0.1.22]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.21...v0.1.22
611
661
  [0.1.21]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.20...v0.1.21
612
662
  [0.1.20]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.19...v0.1.20
613
663
  [0.1.19]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.18...v0.1.19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Tavern Harness — 酒馆模式下的本地 AI Agent 助手(MyAgent-Android 的 Web 复刻)",
5
5
  "private": false,
6
6
  "type": "module",
@@ -141,6 +141,17 @@ function validKey(value) {
141
141
  return typeof value === 'string' && value.length > 0 && value.length <= 255;
142
142
  }
143
143
 
144
+ function validBulkUpdates(value) {
145
+ return Array.isArray(value)
146
+ && value.length <= 10_000
147
+ && value.every((update) => update
148
+ && typeof update === 'object'
149
+ && validKey(update.key)
150
+ && update.changes
151
+ && typeof update.changes === 'object'
152
+ && !Array.isArray(update.changes));
153
+ }
154
+
144
155
  function lockState(clientId) {
145
156
  return {
146
157
  writable: Boolean(writeLock && writeLock.clientId === clientId),
@@ -188,6 +199,20 @@ const listStartupRecords = database.prepare(`
188
199
  `);
189
200
  const deleteRecord = database.prepare('DELETE FROM app_records WHERE collection = ? AND record_key = ?');
190
201
  const clearCollection = database.prepare('DELETE FROM app_records WHERE collection = ?');
202
+ function bulkUpdateRecords(collection, updates) {
203
+ database.exec('BEGIN IMMEDIATE');
204
+ try {
205
+ for (const { key, changes } of updates) {
206
+ const record = getRecord.get(collection, key);
207
+ if (!record) continue;
208
+ saveRecord(collection, key, { ...JSON.parse(record.value_json), ...changes });
209
+ }
210
+ database.exec('COMMIT');
211
+ } catch (error) {
212
+ database.exec('ROLLBACK');
213
+ throw error;
214
+ }
215
+ }
191
216
  const nextNumericRecordKey = database.prepare(`
192
217
  SELECT COALESCE(MAX(CAST(record_key AS INTEGER)), 0) + 1 AS next_key
193
218
  FROM app_records
@@ -351,6 +376,16 @@ const server = createServer(async (req, res) => {
351
376
  json(res, 200, { ok: true, id: allocateNumericRecordId(collection) });
352
377
  return;
353
378
  }
379
+ if (pathname === '/records/bulk-update') {
380
+ if (!validBulkUpdates(body.updates)) {
381
+ json(res, 400, { ok: false, message: 'Invalid record updates' });
382
+ return;
383
+ }
384
+ requireWriter(clientId);
385
+ bulkUpdateRecords(collection, body.updates);
386
+ json(res, 200, { ok: true });
387
+ return;
388
+ }
354
389
  if (pathname === '/records/put') {
355
390
  if (!validKey(body.key)) {
356
391
  json(res, 400, { ok: false, message: 'Invalid record key' });
@@ -1,4 +1,4 @@
1
- import { memo, useState } from 'react';
1
+ import { memo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
2
2
  import { useStore } from '../store/store';
3
3
  import { db, DEFAULT_GENERATION_SETTINGS } from '../db/database';
4
4
  import { type ThemeMode } from '../theme/theme';
@@ -24,21 +24,72 @@ export const SettingsView = memo(function SettingsView() {
24
24
  const [showResetConfirm, setShowResetConfirm] = useState(false);
25
25
  const [resetting, setResetting] = useState(false);
26
26
  const [showVersionHistory, setShowVersionHistory] = useState(false);
27
+ const generationSliderGesture = useRef<{
28
+ pointerId: number;
29
+ startX: number;
30
+ startY: number;
31
+ startValue: number;
32
+ direction: 'pending' | 'horizontal' | 'vertical';
33
+ } | null>(null);
27
34
  const t = useT();
28
35
  const hasUnseenVersion = useHasUnseenVersion();
29
36
 
30
37
  if (!settings) return null;
31
38
 
39
+ const startGenerationSliderGesture = (event: ReactPointerEvent<HTMLInputElement>) => {
40
+ if (event.pointerType !== 'touch') return;
41
+ const input = event.currentTarget;
42
+ generationSliderGesture.current = {
43
+ pointerId: event.pointerId,
44
+ startX: event.clientX,
45
+ startY: event.clientY,
46
+ startValue: Number(input.value),
47
+ direction: 'pending',
48
+ };
49
+ };
50
+
51
+ const classifyGenerationSliderGesture = (event: ReactPointerEvent<HTMLInputElement>) => {
52
+ const gesture = generationSliderGesture.current;
53
+ if (!gesture || gesture.pointerId !== event.pointerId || gesture.direction !== 'pending') return gesture?.direction;
54
+
55
+ const deltaX = Math.abs(event.clientX - gesture.startX);
56
+ const deltaY = Math.abs(event.clientY - gesture.startY);
57
+ if (Math.max(deltaX, deltaY) < 10) return 'pending';
58
+
59
+ gesture.direction = deltaX > deltaY ? 'horizontal' : 'vertical';
60
+ return gesture.direction;
61
+ };
62
+
63
+ const endGenerationSliderGesture = (event: ReactPointerEvent<HTMLInputElement>) => {
64
+ if (generationSliderGesture.current?.pointerId === event.pointerId) generationSliderGesture.current = null;
65
+ };
66
+
67
+ const updateGenerationSlider = (key: keyof typeof settings, value: number | 'off') => {
68
+ const gesture = generationSliderGesture.current;
69
+ if (!gesture) {
70
+ setSettings({ [key]: value });
71
+ } else if (gesture.direction === 'horizontal') {
72
+ setSettings({ [key]: value });
73
+ } else if (gesture.direction === 'pending') {
74
+ setSettings({ [key]: gesture.startValue });
75
+ }
76
+ };
77
+
32
78
  // 数字滑杆——数值显示固定在行内,不溢出卡片
33
79
  const slider = (key: keyof typeof settings, min: number, max: number, step: number, fmt?: (v: number) => string) => (
34
80
  <label className="range-row" style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 10, alignItems: 'center' }}>
35
81
  <input
36
82
  type="range"
83
+ className="generation-slider"
37
84
  min={min}
38
85
  max={max}
39
86
  step={step}
40
87
  value={settings[key] as number}
41
- onChange={(e) => setSettings({ [key]: parseFloat(e.target.value) })}
88
+ onPointerDown={startGenerationSliderGesture}
89
+ onPointerMove={classifyGenerationSliderGesture}
90
+ onPointerUp={endGenerationSliderGesture}
91
+ onPointerCancel={endGenerationSliderGesture}
92
+ onChange={(e) => updateGenerationSlider(key, parseFloat(e.target.value))}
42
93
  />
43
94
  <span className="range-val">{fmt ? fmt(settings[key] as number) : (settings[key] as number)}</span>
44
95
  </label>
@@ -52,7 +103,6 @@ export const SettingsView = memo(function SettingsView() {
52
103
  await setSettings({ ...generationDefaults });
53
104
  addToast(t('toast.genParamsReset'));
54
105
  };
55
-
56
106
  return (
57
107
  <div className="view-page">
58
108
  <div className="view-col">
@@ -174,11 +224,16 @@ export const SettingsView = memo(function SettingsView() {
174
224
  <label className="range-row" style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 10, alignItems: 'center' }}>
175
225
  <input
176
226
  type="range"
227
+ className="generation-slider"
177
228
  min="0"
178
229
  max="100"
179
230
  step="1"
180
231
  value={settings.contextCompressionThreshold === 'off' ? 0 : settings.contextCompressionThreshold}
181
- onChange={(e) => setSettings({ contextCompressionThreshold: Number(e.target.value) || 'off' })}
232
+ onPointerDown={startGenerationSliderGesture}
233
+ onPointerMove={classifyGenerationSliderGesture}
234
+ onPointerUp={endGenerationSliderGesture}
235
+ onPointerCancel={endGenerationSliderGesture}
236
+ onChange={(e) => updateGenerationSlider('contextCompressionThreshold', Number(e.target.value) || 'off')}
182
237
  />
183
238
  <span className="range-val">
184
239
  {settings.contextCompressionThreshold === 'off' ? t('settings.off') : settings.contextCompressionThreshold}
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react';
1
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import { QRCodeSVG } from 'qrcode.react';
4
4
  import { useStore } from '../store/store';
@@ -19,6 +19,23 @@ const SHAREHOLDER_CLICK_COUNT = 5;
19
19
  const SHAREHOLDER_CLICK_WINDOW_MS = 5_000;
20
20
  const SIDEBAR_MAX = 520;
21
21
 
22
+ function FadeOverflowText({ className, children, ...props }: ComponentPropsWithoutRef<'span'> & { children: ReactNode }) {
23
+ const ref = useRef<HTMLSpanElement>(null);
24
+ const [overflowing, setOverflowing] = useState(false);
25
+
26
+ useLayoutEffect(() => {
27
+ const element = ref.current;
28
+ if (!element) return;
29
+ const updateOverflow = () => setOverflowing(element.scrollWidth > element.clientWidth);
30
+ updateOverflow();
31
+ const observer = new ResizeObserver(updateOverflow);
32
+ observer.observe(element);
33
+ return () => observer.disconnect();
34
+ }, [children]);
35
+
36
+ return <span ref={ref} className={`${className ?? ''}${overflowing ? ' has-overflow' : ''}`} {...props}>{children}</span>;
37
+ }
38
+
22
39
  /** 触发 App 级全局游戏导出弹窗(会话右键菜单共用) */
23
40
  function openGlobalGameplayExport(sessionId: number, includeHistory: boolean) {
24
41
  window.dispatchEvent(
@@ -409,10 +426,10 @@ export function Sidebar() {
409
426
  {!!(s as { pinned?: unknown }).pinned && (
410
427
  <span className="spinned" title={t('nav.pinSession')}><Icon name="pin" size={11} /></span>
411
428
  )}
412
- <span className="stitle-text">{s.title}</span>
429
+ <FadeOverflowText className="stitle-text">{s.title}</FadeOverflowText>
413
430
  {hasActivity && <span className="session-activity-dot" aria-label={t('nav.sessionActive')} />}
414
431
  </div>
415
- <div className="sprev">{s.lastMessage || t('nav.newConversation')}</div>
432
+ <FadeOverflowText className="sprev">{s.lastMessage || t('nav.newConversation')}</FadeOverflowText>
416
433
  </div>
417
434
  </button>
418
435
  );
@@ -409,6 +409,8 @@ const ICONS: Record<string, React.ReactNode> = {
409
409
  upload: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m17 8-5-5-5 5M12 3v12" /></>,
410
410
  import: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 14l-5 5-5-5M12 19V7" /></>,
411
411
  pin: <><path d="M9 4h6M12 4v3l4 3v3h-8v-3l4-3" /><path d="M12 13v7" /></>,
412
+ lock: <><rect x="5" y="10" width="14" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></>,
413
+ unlock: <><rect x="5" y="10" width="14" height="11" rx="2" /><path d="M8 10V7a4 4 0 0 1 7.4-1.9" /></>,
412
414
  'git-branch': <><path d="M6 3v12" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" /></>,
413
415
  'zoom-in': <><circle cx="11" cy="11" r="7" /><path d="m21 21-4.35-4.35M11 8v6M8 11h6" /></>,
414
416
  'zoom-out': <><circle cx="11" cy="11" r="7" /><path d="m21 21-4.35-4.35M8 11h6" /></>,
package/src/core/i18n.ts CHANGED
@@ -249,7 +249,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
249
249
  'builtinNpc.prompt': '神秘的酒馆老板,可以响应客人的任何需求',
250
250
  'builtinNpc.greeting': '你来啦!快坐下~',
251
251
  'builtinWorldbook.name': '酒馆经营手册(精华版)',
252
- 'settings.providerTitle': '🔌 模型服务 Provider(多端点)',
252
+ 'settings.providerTitle': '🔌 模型服务 Provider',
253
253
  'settings.noProvider': '还没有配置 Provider,点击下方按钮添加',
254
254
  'settings.enable': '启用',
255
255
  'settings.disable': '停用',
@@ -772,7 +772,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
772
772
  'builtinNpc.prompt': '神秘的酒館老闆,可以回應客人的任何需求',
773
773
  'builtinNpc.greeting': '你來啦!快坐下~',
774
774
  'builtinWorldbook.name': '酒館經營手冊(精華版)',
775
- 'settings.providerTitle': '🔌 模型服務 Provider(多端點)',
775
+ 'settings.providerTitle': '🔌 模型服務 Provider',
776
776
  'settings.noProvider': '還沒有設定 Provider,點擊下方按鈕新增',
777
777
  'settings.enable': '啟用',
778
778
  'settings.disable': '停用',
@@ -1286,7 +1286,7 @@ const MESSAGES: Record<LangKey, Record<string, string>> = {
1286
1286
  'builtinNpc.prompt': 'A mysterious tavern keeper who can fulfill any request from guests',
1287
1287
  'builtinNpc.greeting': 'Welcome! Have a seat~',
1288
1288
  'builtinWorldbook.name': 'Tavern Management Handbook (Essentials)',
1289
- 'settings.providerTitle': '🔌 Model Providers (multiple endpoints)',
1289
+ 'settings.providerTitle': '🔌 Model Providers',
1290
1290
  'settings.noProvider': 'No providers configured yet — add one below',
1291
1291
  'settings.enable': 'Enabled',
1292
1292
  'settings.disable': 'Disabled',
@@ -530,6 +530,17 @@ class RemoteTable<T extends object> {
530
530
  }
531
531
  async bulkAdd(values: T[]): Promise<void> { for (const value of values) await this.add(value); }
532
532
  async bulkPut(values: T[]): Promise<void> { for (const value of values) await this.put(value); }
533
+ async bulkUpdate(updates: Array<{ key: unknown; changes: Partial<T> }>): Promise<void> {
534
+ if (updates.length === 0) return;
535
+ await requireWriteLock();
536
+ await persistenceRequest('/records/bulk-update', {
537
+ collection: this.collection,
538
+ updates: updates.map(({ key, changes }) => ({
539
+ key: Array.isArray(key) ? key.join(':') : String(key),
540
+ changes,
541
+ })),
542
+ });
543
+ }
533
544
  async put(value: T): Promise<number> { return this.add(value); }
534
545
  async update(key: unknown, changes: Partial<T>): Promise<number> {
535
546
  await requireWriteLock();
@@ -797,40 +808,33 @@ export async function localizeBuiltinNpc(): Promise<void> {
797
808
  if (isPristine(boss.greeting || '')) updates.greeting = tGreeting;
798
809
  if (Object.keys(updates).length > 0) {
799
810
  await db.npcs.update(boss.id!, updates);
800
- // 会话标题 / 最后一条消息 / 参与者 / 消息里的旧内置文案一并更新(仅限仍是内置原文的记录)
801
- const sessions = await db.sessions.toArray();
802
- for (const s of sessions) {
803
- const changes: Partial<ChatSession> = {};
804
- if (s.title === '酒馆老板' || s.title === '酒館老闆' || s.title === 'Tavern Keeper') {
805
- changes.title = tName;
806
- }
807
- if (s.lastMessage && isPristine(s.lastMessage)) {
808
- changes.lastMessage = tGreeting;
809
- }
810
- if (Object.keys(changes).length > 0) {
811
- await db.sessions.update(s.id!, changes);
812
- }
813
- }
814
- // 参与者 / 消息里的旧内置文案一并更新(仅限仍是内置原文的记录)
815
- const participants = await db.participants.toArray();
816
- for (const p of participants) {
817
- if (
818
- p.kind === 'NPC' &&
819
- p.npcId === boss.id &&
820
- (p.displayName === '酒馆老板' || p.displayName === '酒館老闆' || p.displayName === 'Tavern Keeper')
821
- ) {
822
- await db.participants.update(p, { displayName: tName });
823
- }
824
- }
825
- const messages = await db.messages.toArray();
826
- for (const m of messages) {
827
- if (
828
- m.speakerParticipantId === boss.id &&
829
- (m.speakerName === '酒馆老板' || m.speakerName === '酒館老闆' || m.speakerName === 'Tavern Keeper')
830
- ) {
831
- await db.messages.update(m.id!, { speakerName: tName });
832
- }
833
- }
811
+ // 仅请求与酒馆老板关联的记录;批量写入可避免大量历史消息产生逐条网络往返。
812
+ const [sessions, participants, messages] = await Promise.all([
813
+ db.sessions.toArray(),
814
+ db.participants.where('npcId').equals(boss.id!).toArray(),
815
+ db.messages.where('speakerParticipantId').equals(boss.id!).toArray(),
816
+ ]);
817
+ await Promise.all([
818
+ db.sessions.bulkUpdate(sessions.flatMap((session) => {
819
+ const changes: Partial<ChatSession> = {};
820
+ if (session.title === '酒馆老板' || session.title === '酒館老闆' || session.title === 'Tavern Keeper') {
821
+ changes.title = tName;
822
+ }
823
+ if (session.lastMessage && isPristine(session.lastMessage)) changes.lastMessage = tGreeting;
824
+ return Object.keys(changes).length > 0 ? [{ key: session.id!, changes }] : [];
825
+ })),
826
+ db.participants.bulkUpdate(participants.flatMap((participant) => (
827
+ participant.kind === 'NPC' &&
828
+ (participant.displayName === '酒馆老板' || participant.displayName === '酒館老闆' || participant.displayName === 'Tavern Keeper')
829
+ ? [{ key: [participant.sessionId, participant.participantId], changes: { displayName: tName } }]
830
+ : []
831
+ ))),
832
+ db.messages.bulkUpdate(messages.flatMap((message) => (
833
+ message.speakerName === '酒馆老板' || message.speakerName === '酒館老闆' || message.speakerName === 'Tavern Keeper'
834
+ ? [{ key: message.id!, changes: { speakerName: tName } }]
835
+ : []
836
+ ))),
837
+ ]);
834
838
  }
835
839
 
836
840
  if (prevLang !== lang) {
@@ -1,7 +1,79 @@
1
1
  {
2
- "latest": "0.1.21",
2
+ "latest": "0.1.23",
3
3
  "repo": "https://github.com/Rannichan/Tavern-Harness",
4
4
  "entries": [
5
+ {
6
+ "version": "0.1.23",
7
+ "date": "2026-10-26",
8
+ "breaking": false,
9
+ "compare": "v0.1.22...v0.1.23",
10
+ "groups": {
11
+ "zh-CN": [
12
+ {
13
+ "kind": "changed",
14
+ "items": [
15
+ "生成参数滑杆不再需要先点「解锁」:直接拖动即可调整,只有明显的横向滑动才会改变数值,上下滚动设置页时不会再误改温度、上下文压缩等参数",
16
+ "「重置」按钮不再受锁定状态限制,只要参数不是默认值就能一键恢复",
17
+ "酒馆老板的内置文案本地化明显加快:只读取与它相关的消息和参与者,并一次性批量写入,历史记录较多时也不会再逐条卡顿"
18
+ ]
19
+ },
20
+ {
21
+ "kind": "fixed",
22
+ "items": [
23
+ "移动端中文衬线字体在缺少对应字重的系统上也能正常显示加粗,不再一律退化成常规字重",
24
+ "移动端点按按钮、滑杆、开关时不再出现系统默认的蓝色高亮块"
25
+ ]
26
+ }
27
+ ],
28
+ "en": [
29
+ {
30
+ "kind": "changed",
31
+ "items": [
32
+ "Generation parameter sliders no longer need to be unlocked first: drag them directly, and only a clearly horizontal drag changes the value, so scrolling the settings page can no longer alter temperature, context compression, and other values by accident",
33
+ "The \"Reset\" button is no longer tied to a lock state and is available whenever the parameters differ from their defaults",
34
+ "Localizing the built-in Tavern Keeper text is much faster: only its own messages and participants are read, and they are written in a single batch, so large chat histories no longer stall"
35
+ ]
36
+ },
37
+ {
38
+ "kind": "fixed",
39
+ "items": [
40
+ "Chinese serif text on mobile now renders bold correctly on systems whose fallback fonts lack the requested weight, instead of silently falling back to regular",
41
+ "Tapping buttons, sliders, and switches on mobile no longer flashes the system's default blue highlight"
42
+ ]
43
+ }
44
+ ]
45
+ }
46
+ },
47
+ {
48
+ "version": "0.1.22",
49
+ "date": "2026-10-26",
50
+ "breaking": false,
51
+ "compare": "v0.1.21...v0.1.22",
52
+ "groups": {
53
+ "zh-CN": [
54
+ {
55
+ "kind": "changed",
56
+ "items": [
57
+ "生成参数默认锁定:「设置 → 生成参数」里的滑杆和下拉框要先点「解锁」才能调整,离开设置页后会自动重新锁定,避免滑动页面时误改温度、上下文压缩等参数",
58
+ "「重置」按钮在锁定状态下不可点击,解锁后才能恢复默认值",
59
+ "会话列表的标题和副标题只在内容确实溢出时才做末尾渐隐,短标题不再被淡边影响",
60
+ "移动端顶部导航按钮之间的间距略微加大,点击时更不容易误触相邻按钮"
61
+ ]
62
+ }
63
+ ],
64
+ "en": [
65
+ {
66
+ "kind": "changed",
67
+ "items": [
68
+ "Generation parameters are now locked by default: the sliders and dropdowns under Settings → Generation parameters stay read-only until you tap \"Unlock\", and they lock again when you leave the settings page, so scrolling can no longer change temperature, context compression, and other values by accident",
69
+ "The \"Reset\" button is disabled while parameters are locked and becomes available after unlocking",
70
+ "Session list titles and subtitles only fade out at the end when the text actually overflows, so short titles are no longer dimmed by the mask",
71
+ "Mobile top-bar navigation buttons have a little more spacing, reducing accidental taps on neighbours"
72
+ ]
73
+ }
74
+ ]
75
+ }
76
+ },
5
77
  {
6
78
  "version": "0.1.21",
7
79
  "date": "2026-10-26",
@@ -23,6 +23,7 @@
23
23
 
24
24
  * {
25
25
  box-sizing: border-box;
26
+ -webkit-tap-highlight-color: transparent;
26
27
  }
27
28
 
28
29
  html,
@@ -85,14 +86,6 @@ button {
85
86
  white-space: nowrap;
86
87
  }
87
88
 
88
- /* Android WebView adds a blue overlay to non-button controls such as switch labels. */
89
- button,
90
- a,
91
- label,
92
- [role='button'] {
93
- -webkit-tap-highlight-color: transparent;
94
- }
95
-
96
89
  input,
97
90
  textarea,
98
91
  select {
@@ -575,6 +568,9 @@ body.resizing .sidebar-resizer {
575
568
  overflow: hidden;
576
569
  white-space: nowrap;
577
570
  min-width: 0;
571
+ }
572
+ .session-item .stitle-text.has-overflow,
573
+ .session-item .sprev.has-overflow {
578
574
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
579
575
  mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
580
576
  }
@@ -612,12 +608,11 @@ body.resizing .sidebar-resizer {
612
608
  }
613
609
  }
614
610
  .session-item .sprev {
611
+ display: block;
615
612
  font-size: 11.5px;
616
613
  color: var(--text-faint);
617
614
  white-space: nowrap;
618
615
  overflow: hidden;
619
- -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
620
- mask-image: linear-gradient(to right, #000 calc(100% - 18px), transparent);
621
616
  }
622
617
  /* ---------- 会话区域(头部 + 滚动列表) ---------- */
623
618
  .session-section {
@@ -1680,7 +1675,7 @@ hr.sep {
1680
1675
  display: flex;
1681
1676
  flex: 1;
1682
1677
  min-width: 0;
1683
- gap: 4px;
1678
+ gap: 8px;
1684
1679
  margin: -12px -10px;
1685
1680
  padding: 12px 10px;
1686
1681
  overflow-x: auto;
@@ -1805,6 +1800,11 @@ hr.sep {
1805
1800
  height: 36px;
1806
1801
  }
1807
1802
 
1803
+ :root {
1804
+ /* Android 中文衬线回退字体常缺少所需字重;移动端统一允许合成字重。 */
1805
+ font-synthesis: weight;
1806
+ }
1807
+
1808
1808
  .chat-header .ttitle {
1809
1809
  max-width: min(52vw, 260px);
1810
1810
  font-size: 15px;
@@ -2,6 +2,10 @@
2
2
  弹窗 / 覆盖层 / 视图页 / 卡片列表
3
3
  ============================================================ */
4
4
 
5
+ .generation-slider {
6
+ touch-action: pan-y;
7
+ }
8
+
5
9
  .overlay {
6
10
  position: fixed;
7
11
  inset: 0;