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 +76 -0
- package/package.json +1 -1
- package/persistence-server.mjs +35 -0
- package/sandbox-server.mjs +3 -2
- package/src/App.tsx +13 -3
- package/src/components/CharactersView.tsx +3 -3
- package/src/components/NewSessionMenu.tsx +48 -43
- package/src/components/SettingsView.tsx +68 -34
- package/src/components/Sidebar.tsx +18 -10
- package/src/components/WorkspaceFileManagerModal.tsx +19 -21
- package/src/core/i18n.ts +15 -21
- package/src/core/tools/builtinTools.ts +3 -5
- package/src/core/tools/generatedSkillExecutor.ts +3 -3
- package/src/core/tools/toolExecutor.ts +13 -14
- package/src/db/database.ts +38 -34
- package/src/generated/changelog.json +107 -1
- package/src/store/store.ts +42 -32
- package/src/theme/chat.css +15 -2
- package/src/theme/global.css +6 -8
- package/src/theme/views.css +27 -10
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
package/persistence-server.mjs
CHANGED
|
@@ -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' });
|
package/sandbox-server.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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"
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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(
|
|
98
|
-
addToast(t('toast.sessionUpdated'));
|
|
126
|
+
useStore.getState().setActiveSession(sid);
|
|
99
127
|
onClose();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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,
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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-
|
|
190
|
-
|
|
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">
|