tavern-harness 0.1.22 → 0.1.24

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,80 @@
3
3
  > 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
4
4
  > 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
5
5
 
6
+ ## [0.1.24] - 2026-10-27
7
+
8
+ ### 中文
9
+
10
+ #### 改进
11
+
12
+ - 工作区文件管理器现在可以直接进入 public 目录,并预览其中的网页、图片等文件
13
+ - 文件管理器的路径导航移到文件列表上方,窄屏下标题栏不再换行,右侧按钮也不会被挤掉
14
+ - 手机上点击顶部导航的「聊天 / 角色 / 成就 / 设置」或返回首页后,侧边栏会自动收起
15
+ - 手机端的工具调用卡片改用等宽字体并加粗,长工具名和参数更容易看清
16
+ - 输入框提示精简为「输入消息...」,不再把指令说明塞进占位文字
17
+ - 技能列表与原文编辑器的字体、名称截断和「内置」标签对齐统一
18
+ - 工具返回给模型的结果统一使用英文,切换界面语言不再影响模型读取;工具卡片中的结果行也随之显示英文
19
+
20
+ #### 修复
21
+
22
+ - 删除会话与会话设置保存改为事务写入,中途出错不会再留下只删了一半的数据
23
+ - 连续点击「删除」「保存」不会再重复提交,也不会卡在加载状态
24
+ - 创建、编辑、删除会话失败时会提示具体原因,而不是静默失败
25
+
26
+ #### 内部
27
+
28
+ - 清理工具执行路径中不再需要的界面文案依赖
29
+
30
+ ### English
31
+
32
+ #### Changed
33
+
34
+ - The workspace file manager can now browse into `public` and preview pages, images, and other files stored there
35
+ - The file manager's path breadcrumb now sits above the file list, and the header no longer wraps on narrow screens or pushes its buttons aside
36
+ - On mobile, tapping a top navigation tab (Chat, Characters, Achievements, Settings) or going home now closes the sidebar
37
+ - Tool call cards on mobile use a monospace, bolder typeface, so long tool names and arguments are easier to read
38
+ - The composer placeholder is now simply "Type a message..." instead of packing command hints into it
39
+ - The skill list and raw log editor now share consistent typography, name truncation, and Built-in tag alignment
40
+ - Tool results returned to the model are always English now, so switching the UI language no longer affects what the model reads; the result rows in tool cards show English as well
41
+
42
+ #### Fixed
43
+
44
+ - Deleting a session and saving session settings are now transactional, so a failure can no longer leave half-deleted data behind
45
+ - Repeatedly clicking delete or save no longer sends duplicate requests or leaves the dialog stuck loading
46
+ - Creating, editing, or deleting a session now reports the actual error instead of failing silently
47
+
48
+ #### Internal
49
+
50
+ - Removed the no-longer-needed UI string dependency from the tool execution path
51
+
52
+ ## [0.1.23] - 2026-10-26
53
+
54
+ ### 中文
55
+
56
+ #### 改进
57
+
58
+ - 生成参数滑杆不再需要先点「解锁」:直接拖动即可调整,只有明显的横向滑动才会改变数值,上下滚动设置页时不会再误改温度、上下文压缩等参数
59
+ - 「重置」按钮不再受锁定状态限制,只要参数不是默认值就能一键恢复
60
+ - 酒馆老板的内置文案本地化明显加快:只读取与它相关的消息和参与者,并一次性批量写入,历史记录较多时也不会再逐条卡顿
61
+
62
+ #### 修复
63
+
64
+ - 移动端中文衬线字体在缺少对应字重的系统上也能正常显示加粗,不再一律退化成常规字重
65
+ - 移动端点按按钮、滑杆、开关时不再出现系统默认的蓝色高亮块
66
+
67
+ ### English
68
+
69
+ #### Changed
70
+
71
+ - 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
72
+ - The "Reset" button is no longer tied to a lock state and is available whenever the parameters differ from their defaults
73
+ - 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
74
+
75
+ #### Fixed
76
+
77
+ - 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
78
+ - Tapping buttons, sliders, and switches on mobile no longer flashes the system's default blue highlight
79
+
6
80
  ## [0.1.22] - 2026-10-26
7
81
 
8
82
  ### 中文
@@ -628,6 +702,8 @@
628
702
  - Vite + React + Zustand + Dexie stack with browser-side local persistence
629
703
 
630
704
  <!-- 链接定义 -->
705
+ [0.1.24]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.23...v0.1.24
706
+ [0.1.23]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.22...v0.1.23
631
707
  [0.1.22]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.21...v0.1.22
632
708
  [0.1.21]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.20...v0.1.21
633
709
  [0.1.20]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.19...v0.1.20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tavern-harness",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
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' });
@@ -596,10 +596,11 @@ const server = createServer(async (req, res) => {
596
596
  const payload = JSON.parse(body);
597
597
  const base = resolveSessionBase(payload?.session);
598
598
  const rel = sanitizeWorkspaceRelativePath(payload?.path);
599
- if (resolvesOutsideSession(rel, base)) throw new Error('Path is outside the workspace');
599
+ const readingPublicLink = base !== PUBLIC_WORKSPACE && (rel === 'public' || rel.startsWith('public/'));
600
+ if (!readingPublicLink && resolvesOutsideSession(rel, base)) throw new Error('Path is outside the workspace');
600
601
  const file = resolve(base, rel);
601
602
  if (!existsSync(file) || !lstatSync(file).isFile()) throw new Error('File not found');
602
- assertInside(base, realpathSync(file));
603
+ assertInside(readingPublicLink ? PUBLIC_WORKSPACE : base, realpathSync(file));
603
604
  const content = await readFilePromise(file);
604
605
  if (content.length > 8 * 1024 * 1024) throw new Error('Preview file exceeds the 8MB limit');
605
606
  res.end(JSON.stringify({ ok: true, content: content.toString('base64') }));
package/src/App.tsx CHANGED
@@ -325,6 +325,7 @@ function SessionHeader({
325
325
  const [showEdit, setShowEdit] = useState(false);
326
326
  const [confirmReset, setConfirmReset] = useState(false);
327
327
  const [confirmDelete, setConfirmDelete] = useState(false);
328
+ const [deletingSession, setDeletingSession] = useState(false);
328
329
  const [exportOpen, setExportOpen] = useState(false);
329
330
  const [showWorkspace, setShowWorkspace] = useState(false);
330
331
  const [showMobileActions, setShowMobileActions] = useState(false);
@@ -482,10 +483,19 @@ function SessionHeader({
482
483
  <button className="btn" onClick={() => setConfirmDelete(false)}>{t('common.cancel')}</button>
483
484
  <button
484
485
  className="btn btn-danger"
486
+ disabled={deletingSession}
485
487
  onClick={async () => {
486
- await deleteSession(session.id!);
487
- setConfirmDelete(false);
488
- addToast(t('toast.sessionDeleted'));
488
+ if (deletingSession) return;
489
+ setDeletingSession(true);
490
+ try {
491
+ await deleteSession(session.id!);
492
+ setConfirmDelete(false);
493
+ addToast(t('toast.sessionDeleted'));
494
+ } catch (error) {
495
+ addToast(t('toast.sessionDeleteFailed', { msg: (error as Error).message }), 'error');
496
+ } finally {
497
+ setDeletingSession(false);
498
+ }
489
499
  }}
490
500
  >
491
501
  <Icon name="trash" size={13} /> {t('common.delete')}
@@ -556,7 +556,7 @@ function WorldBookList({ books, onChanged }: {
556
556
 
557
557
  return (
558
558
  <div>
559
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
559
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
560
560
  <span className="group-label" style={{ margin: 0 }}>{t('workshop.wbCount', { n: books.length })}</span>
561
561
  <div style={{ display: 'flex', gap: 8 }}>
562
562
  <button className="btn btn-sm" onClick={() => jsonFileRef.current?.click()} title={t('workshop.jsonImportTip')}>
@@ -963,8 +963,8 @@ function SkillSortItem({
963
963
  <div style={{ fontSize: 18 }}>{tool.isBuiltIn ? '🧰' : '⚙️'}</div>
964
964
  <div className="l-main">
965
965
  <div className="l-title">
966
- {tool.name}
967
- {tool.isBuiltIn && <span className="tag" style={{ marginLeft: 8 }}>{t('common.builtin')}</span>}
966
+ <span className="skill-card-name">{tool.name}</span>
967
+ {tool.isBuiltIn && <span className="tag">{t('common.builtin')}</span>}
968
968
  </div>
969
969
  <div className="l-sub">{desc}</div>
970
970
  {tool.isBuiltIn && impl !== 'native' && <div style={{ fontSize: 10.5, marginTop: 2, color: 'var(--warn)' }}>{t('workshop.implType', { t: impl })}</div>}
@@ -52,6 +52,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
52
52
  const [turnOrderMode, setTurnOrderMode] = useState<'PRESET' | 'RANDOM'>(editingSession?.turnOrderMode ?? 'PRESET');
53
53
  const [mentionOnlyMode, setMentionOnlyMode] = useState(editingSession?.mentionOnlyMode === true);
54
54
  const [enableGreeting, setEnableGreeting] = useState(editingSession?.enableGreeting !== false);
55
+ const [saving, setSaving] = useState(false);
55
56
  const selectedNpcIds = participantOrder.filter((id) => id !== -1);
56
57
  const modeLabel = selectedNpcIds.length === 0 ? t('newSession.notSelected') : selectedNpcIds.length === 1 ? t('newSession.npcChat') : t('newSession.groupChat', { n: selectedNpcIds.length });
57
58
  const sensors = useSortableSensors();
@@ -77,55 +78,59 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
77
78
  }, [editingSession?.id]);
78
79
 
79
80
  const create = async () => {
81
+ if (saving) return;
80
82
  if (selectedNpcIds.length === 0) {
81
83
  addToast(t('toast.pickOneNpc'), 'error');
82
84
  return;
83
85
  }
84
- const finalTitle = title.trim() || selectedNpcIds.map((id) => npcs.find((n) => n.id === id)?.name).filter(Boolean).join('、');
85
- if (isEditing && editingSession?.id) {
86
- await updateSessionSettings(editingSession.id, {
87
- title: finalTitle,
88
- npcIds: selectedNpcIds,
89
- worldBookId,
90
- userPersonaNpcId,
91
- turnOrderMode,
92
- participantOrder,
93
- mentionOnlyMode,
94
- enableGreeting,
95
- });
86
+ setSaving(true);
87
+ try {
88
+ const finalTitle = title.trim() || selectedNpcIds.map((id) => npcs.find((n) => n.id === id)?.name).filter(Boolean).join('、');
89
+ if (isEditing && editingSession?.id) {
90
+ await updateSessionSettings(editingSession.id, {
91
+ title: finalTitle,
92
+ npcIds: selectedNpcIds,
93
+ worldBookId,
94
+ userPersonaNpcId,
95
+ turnOrderMode,
96
+ participantOrder,
97
+ mentionOnlyMode,
98
+ enableGreeting,
99
+ });
100
+ addToast(t('toast.sessionUpdated'));
101
+ onClose();
102
+ return;
103
+ }
104
+ const sid = selectedNpcIds.length === 1
105
+ ? await createSession('NPC', {
106
+ associatedId: selectedNpcIds[0],
107
+ title: finalTitle,
108
+ worldBookId,
109
+ userPersonaNpcId,
110
+ turnOrderMode,
111
+ participantOrder,
112
+ mentionOnlyMode,
113
+ enableGreeting,
114
+ })
115
+ : await createSession('GROUP', {
116
+ npcIds: selectedNpcIds,
117
+ title: finalTitle,
118
+ worldBookId,
119
+ userPersonaNpcId,
120
+ turnOrderMode,
121
+ participantOrder,
122
+ mentionOnlyMode,
123
+ enableGreeting,
124
+ });
96
125
  await useStore.getState().refreshSessions();
97
- useStore.getState().setActiveSession(editingSession.id);
98
- addToast(t('toast.sessionUpdated'));
126
+ useStore.getState().setActiveSession(sid);
99
127
  onClose();
100
- return;
101
- }
102
- let sid: number;
103
- if (selectedNpcIds.length === 1) {
104
- sid = await createSession('NPC', {
105
- associatedId: selectedNpcIds[0],
106
- title: finalTitle,
107
- worldBookId,
108
- userPersonaNpcId,
109
- turnOrderMode,
110
- participantOrder,
111
- mentionOnlyMode,
112
- enableGreeting,
113
- });
114
- } else {
115
- sid = await createSession('GROUP', {
116
- npcIds: selectedNpcIds,
117
- title: finalTitle,
118
- worldBookId,
119
- userPersonaNpcId,
120
- turnOrderMode,
121
- participantOrder,
122
- mentionOnlyMode,
123
- enableGreeting,
124
- });
128
+ } catch (error) {
129
+ if (isEditing) addToast(t('toast.sessionSaveFailed', { msg: (error as Error).message }), 'error');
130
+ else addToast(t('toast.genFailed', { msg: (error as Error).message }), 'error');
131
+ } finally {
132
+ setSaving(false);
125
133
  }
126
- await useStore.getState().refreshSessions();
127
- useStore.getState().setActiveSession(sid);
128
- onClose();
129
134
  };
130
135
 
131
136
  const addParticipant = (npcId: number) => {
@@ -273,7 +278,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
273
278
  </div>
274
279
  <div className="modal-foot">
275
280
  <button className="btn" onClick={onClose}>{t('common.cancel')}</button>
276
- <button className="btn btn-primary" disabled={!canCreate} onClick={create}>{isEditing ? t('common.save') : t('common.ok')}</button>
281
+ <button className="btn btn-primary" disabled={!canCreate || saving} onClick={create}>{isEditing ? t('common.save') : t('common.ok')}</button>
277
282
  </div>
278
283
  </Modal>
279
284
  );
@@ -1,4 +1,4 @@
1
- import { memo, useEffect, 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,24 +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 [generationParamsLocked, setGenerationParamsLocked] = useState(true);
28
- const activeView = useStore((s) => s.activeView);
27
+ const generationSliderGesture = useRef<{
28
+ pointerId: number;
29
+ startX: number;
30
+ startY: number;
31
+ startValue: number;
32
+ direction: 'pending' | 'horizontal' | 'vertical';
33
+ } | null>(null);
29
34
  const t = useT();
30
35
  const hasUnseenVersion = useHasUnseenVersion();
31
36
 
32
37
  if (!settings) return null;
33
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
+
34
78
  // 数字滑杆——数值显示固定在行内,不溢出卡片
35
79
  const slider = (key: keyof typeof settings, min: number, max: number, step: number, fmt?: (v: number) => string) => (
36
80
  <label className="range-row" style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 10, alignItems: 'center' }}>
37
81
  <input
38
82
  type="range"
83
+ className="generation-slider"
39
84
  min={min}
40
85
  max={max}
41
86
  step={step}
42
87
  value={settings[key] as number}
43
- disabled={generationParamsLocked}
44
- 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))}
45
93
  />
46
94
  <span className="range-val">{fmt ? fmt(settings[key] as number) : (settings[key] as number)}</span>
47
95
  </label>
@@ -55,12 +103,6 @@ export const SettingsView = memo(function SettingsView() {
55
103
  await setSettings({ ...generationDefaults });
56
104
  addToast(t('toast.genParamsReset'));
57
105
  };
58
- const toggleGenerationParamsLock = () => setGenerationParamsLocked((locked) => !locked);
59
-
60
- useEffect(() => {
61
- if (activeView !== 'settings') setGenerationParamsLocked(true);
62
- }, [activeView]);
63
-
64
106
  return (
65
107
  <div className="view-page">
66
108
  <div className="view-col">
@@ -112,26 +154,15 @@ export const SettingsView = memo(function SettingsView() {
112
154
  <div className="set-section">
113
155
  <div className="settings-section-head">
114
156
  <h3>{t('settings.genParams')}</h3>
115
- <div className="settings-gen-actions">
116
- <button
117
- className="btn btn-sm settings-gen-lock"
118
- title={t(generationParamsLocked ? 'settings.genParamsUnlock' : 'settings.genParamsLock')}
119
- aria-label={t(generationParamsLocked ? 'settings.genParamsUnlock' : 'settings.genParamsLock')}
120
- aria-pressed={generationParamsLocked}
121
- onClick={toggleGenerationParamsLock}
122
- >
123
- <Icon name={generationParamsLocked ? 'lock' : 'unlock'} size={12} /> {t(generationParamsLocked ? 'settings.genParamsLocked' : 'settings.genParamsUnlocked')}
124
- </button>
125
- <button
126
- className="btn btn-sm settings-gen-reset"
127
- title={t('settings.genParamsResetTitle')}
128
- aria-label={t('settings.genParamsResetTitle')}
129
- disabled={generationParamsLocked || isGenerationDefault}
130
- onClick={() => void resetGenerationParams()}
131
- >
132
- <Icon name="refresh" size={12} /> {t('settings.genParamsReset')}
133
- </button>
134
- </div>
157
+ <button
158
+ className="btn btn-sm settings-gen-reset"
159
+ title={t('settings.genParamsResetTitle')}
160
+ aria-label={t('settings.genParamsResetTitle')}
161
+ disabled={isGenerationDefault}
162
+ onClick={() => void resetGenerationParams()}
163
+ >
164
+ <Icon name="refresh" size={12} /> {t('settings.genParamsReset')}
165
+ </button>
135
166
  </div>
136
167
  <table className="table param-table">
137
168
  <tbody>
@@ -193,12 +224,16 @@ export const SettingsView = memo(function SettingsView() {
193
224
  <label className="range-row" style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 10, alignItems: 'center' }}>
194
225
  <input
195
226
  type="range"
227
+ className="generation-slider"
196
228
  min="0"
197
229
  max="100"
198
230
  step="1"
199
231
  value={settings.contextCompressionThreshold === 'off' ? 0 : settings.contextCompressionThreshold}
200
- disabled={generationParamsLocked}
201
- 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')}
202
237
  />
203
238
  <span className="range-val">
204
239
  {settings.contextCompressionThreshold === 'off' ? t('settings.off') : settings.contextCompressionThreshold}
@@ -215,7 +250,6 @@ export const SettingsView = memo(function SettingsView() {
215
250
  <select
216
251
  className="select"
217
252
  value={settings.reasoningEffort}
218
- disabled={generationParamsLocked}
219
253
  onChange={(e) => setSettings({ reasoningEffort: e.target.value as ReasoningEffort })}
220
254
  >
221
255
  {['auto', 'off', 'low', 'medium', 'xhigh'].map((r) => <option key={r} value={r}>{r}</option>)}
@@ -62,6 +62,7 @@ export function Sidebar() {
62
62
 
63
63
  const [showNew, setShowNew] = useState(false);
64
64
  const [confirmDelete, setConfirmDelete] = useState<ChatSession | null>(null);
65
+ const [deletingSession, setDeletingSession] = useState(false);
65
66
  const [editTarget, setEditTarget] = useState<ChatSession | null>(null);
66
67
  const [sessionMenu, setSessionMenu] = useState<{ session: ChatSession; x: number; y: number } | null>(null);
67
68
  const [query, setQuery] = useState('');
@@ -178,11 +179,17 @@ export function Sidebar() {
178
179
  const npcById = (id: number | null) => (id != null ? npcs.find((n) => n.id === id) : null);
179
180
 
180
181
  const handleDelete = async () => {
181
- if (!confirmDelete) return;
182
- const sid = confirmDelete.id!;
183
- await deleteSession(sid);
184
- addToast(t('toast.sessionDeleted'));
185
- setConfirmDelete(null);
182
+ if (!confirmDelete || deletingSession) return;
183
+ setDeletingSession(true);
184
+ try {
185
+ await deleteSession(confirmDelete.id!);
186
+ addToast(t('toast.sessionDeleted'));
187
+ setConfirmDelete(null);
188
+ } catch (error) {
189
+ addToast(t('toast.sessionDeleteFailed', { msg: (error as Error).message }), 'error');
190
+ } finally {
191
+ setDeletingSession(false);
192
+ }
186
193
  };
187
194
 
188
195
  const openEdit = (session: ChatSession) => {
@@ -281,6 +288,7 @@ export function Sidebar() {
281
288
  }));
282
289
 
283
290
  const returnHome = () => {
291
+ setMobileOpen(false);
284
292
  const now = Date.now();
285
293
  homeClickTimes.current = [...homeClickTimes.current, now]
286
294
  .filter((time) => now - time <= SHAREHOLDER_CLICK_WINDOW_MS)
@@ -315,16 +323,16 @@ export function Sidebar() {
315
323
  >
316
324
  <img src="/tavern-harness-logo.png" alt="" />
317
325
  </button>
318
- <button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => setActiveView('chat')}>
326
+ <button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => { setActiveView('chat'); setMobileOpen(false); }}>
319
327
  <Icon name="send" size={15} /> <span>{t('nav.chat')}</span>
320
328
  </button>
321
- <button className={`nav-chip ${activeView === 'characters' ? 'active' : ''}`} onClick={() => setActiveView('characters')}>
329
+ <button className={`nav-chip ${activeView === 'characters' ? 'active' : ''}`} onClick={() => { setActiveView('characters'); setMobileOpen(false); }}>
322
330
  <Icon name="users" size={15} /> <span>{t('nav.characters')}</span>
323
331
  </button>
324
- <button className={`nav-chip ${activeView === 'stats' ? 'active' : ''}`} onClick={() => setActiveView('stats')}>
332
+ <button className={`nav-chip ${activeView === 'stats' ? 'active' : ''}`} onClick={() => { setActiveView('stats'); setMobileOpen(false); }}>
325
333
  <Icon name="trophy" size={15} /> <span>{t('nav.achievements')}</span>
326
334
  </button>
327
- <button className={`nav-chip ${activeView === 'settings' ? 'active' : ''}`} onClick={() => setActiveView('settings')}>
335
+ <button className={`nav-chip ${activeView === 'settings' ? 'active' : ''}`} onClick={() => { setActiveView('settings'); setMobileOpen(false); }}>
328
336
  <Icon name="settings" size={15} /> <span>{t('nav.settings')}</span>
329
337
  {hasUnseenVersion && <span className="unseen-dot" title={t('changelog.unseen')} />}
330
338
  </button>
@@ -531,7 +539,7 @@ export function Sidebar() {
531
539
  </div>
532
540
  <div className="modal-foot">
533
541
  <button className="btn" onClick={() => setConfirmDelete(null)}>{t('common.cancel')}</button>
534
- <button className="btn btn-danger" onClick={handleDelete}>
542
+ <button className="btn btn-danger" disabled={deletingSession} onClick={handleDelete}>
535
543
  <Icon name="trash" size={13} /> {t('common.delete')}
536
544
  </button>
537
545
  </div>
@@ -92,8 +92,9 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
92
92
  // 当前目录的直接子项(目录与文件同级)
93
93
  const visible = useMemo(() => {
94
94
  const directChild = (p: string) => {
95
- if (dir === '') return !p.replace(/\/$/, '').includes('/');
96
- return p.startsWith(dir) && p !== dir && !p.slice(dir.length).includes('/');
95
+ const normalized = p.replace(/\/$/, '');
96
+ if (dir === '') return !normalized.includes('/');
97
+ return normalized.startsWith(dir) && normalized !== dir.slice(0, -1) && !normalized.slice(dir.length).includes('/');
97
98
  };
98
99
  return nodes.filter((n) => directChild(n.path));
99
100
  }, [nodes, dir]);
@@ -154,23 +155,6 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
154
155
  <span className="ws-fm-title">
155
156
  <Icon name="folder" size={15} />
156
157
  {t('header.fileManagerTitle')}
157
- <span className="mono ws-fm-crumb">
158
- {crumbs.length === 0 ? rootLabel : (
159
- <>
160
- <span className="ws-fm-crumb-link" onClick={() => setDir('')}>{rootLabel}</span>
161
- {crumbs.map((c, i) => (
162
- <span key={i}>
163
- <span className="ws-fm-crumb-sep">/</span>
164
- {i === crumbs.length - 1 ? (
165
- <span className="ws-fm-crumb-cur">{c}</span>
166
- ) : (
167
- <span className="ws-fm-crumb-link" onClick={() => setDir(crumbs.slice(0, i + 1).join('/') + '/')}>{c}</span>
168
- )}
169
- </span>
170
- ))}
171
- </>
172
- )}
173
- </span>
174
158
  </span>
175
159
  <span className="ws-fm-head-actions">
176
160
  <button
@@ -186,8 +170,22 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
186
170
  </span>
187
171
  </div>
188
172
  <div className="modal-body ws-fm-body">
189
- <div className="ws-fm-note">
190
- <Icon name="eye" size={12} /> {t('header.fileManagerHint')}
173
+ <div className="mono ws-fm-crumb">
174
+ {crumbs.length === 0 ? rootLabel : (
175
+ <>
176
+ <span className="ws-fm-crumb-link" onClick={() => setDir('')}>{rootLabel}</span>
177
+ {crumbs.map((c, i) => (
178
+ <span key={i}>
179
+ <span className="ws-fm-crumb-sep">/</span>
180
+ {i === crumbs.length - 1 ? (
181
+ <span className="ws-fm-crumb-cur">{c}</span>
182
+ ) : (
183
+ <span className="ws-fm-crumb-link" onClick={() => setDir(crumbs.slice(0, i + 1).join('/') + '/')}>{c}</span>
184
+ )}
185
+ </span>
186
+ ))}
187
+ </>
188
+ )}
191
189
  </div>
192
190
  {loadState === 'loading' && (
193
191
  <div className="ws-fm-empty">