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.
- package/CHANGELOG.md +65 -0
- package/index.html +1 -1
- package/package.json +1 -1
- package/persistence-server.mjs +54 -2
- package/src/App.tsx +91 -25
- package/src/components/AchievementModal.tsx +4 -2
- package/src/components/CharactersView.tsx +53 -25
- package/src/components/ChatView.tsx +264 -135
- package/src/components/Dashboard.tsx +12 -8
- package/src/components/DeleteConfirmDialog.tsx +1 -1
- package/src/components/FileDisplayModal.tsx +1 -1
- package/src/components/GameplayDialogs.tsx +2 -2
- package/src/components/NewSessionMenu.tsx +1 -1
- package/src/components/SettingsView.tsx +46 -29
- package/src/components/Sidebar.tsx +30 -3
- package/src/components/StatsView.tsx +55 -45
- package/src/components/VersionHistoryModal.tsx +1 -1
- package/src/components/WorkspaceFileManagerModal.tsx +1 -1
- package/src/components/shared.tsx +84 -24
- package/src/core/achievements.ts +22 -4
- package/src/core/avatarThumbnail.ts +245 -0
- package/src/core/characterBook.ts +2 -1
- package/src/core/i18n.ts +42 -6
- package/src/core/mobileViewport.ts +18 -3
- package/src/core/openai.ts +41 -19
- package/src/core/randomId.ts +19 -0
- package/src/core/rowFingerprint.ts +75 -0
- package/src/core/stats.ts +50 -5
- package/src/core/stickyBottom.ts +10 -1
- package/src/core/textPreview.ts +16 -0
- package/src/db/database.ts +101 -13
- package/src/generated/changelog.json +87 -1
- package/src/main.tsx +12 -21
- package/src/store/store.ts +179 -53
- package/src/theme/chat.css +62 -71
- package/src/theme/global.css +85 -21
- package/src/theme/theme.ts +13 -10
- package/src/theme/views.css +82 -5
- package/src/types/models.ts +2 -0
- package/vite.config.ts +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,70 @@
|
|
|
3
3
|
> 本文件由 `scripts/changelog.mjs` 从 `changelog/v*.md` 生成,请勿手动编辑。
|
|
4
4
|
> 版本对比链接见文末;源文件位于仓库 `changelog/` 目录。
|
|
5
5
|
|
|
6
|
+
## [0.1.14] - 2026-10-24
|
|
7
|
+
|
|
8
|
+
### 中文
|
|
9
|
+
|
|
10
|
+
#### 新增
|
|
11
|
+
|
|
12
|
+
- 设置 → 生成参数新增「重置」按钮,一键恢复温度、Top P、Top K 等默认值;已是默认值时按钮不可用
|
|
13
|
+
|
|
14
|
+
#### 改进
|
|
15
|
+
|
|
16
|
+
- 启动体验:界面先于数据加载显示,启动期间展示加载动画而非空白页;启动数据合并为一次快照请求,长列表通过增量渲染与缓存保持流畅
|
|
17
|
+
- 成就页重新排版:奖杯陈列柜置顶,生涯统计与「重置统计」分区展示
|
|
18
|
+
- 统一弹窗尺寸(紧凑 / 标准 / 宽屏三档)并适配移动端;弹窗不再响应点击遮罩关闭,避免编辑内容时误触丢失
|
|
19
|
+
- 流式生成更稳定:自动跟随最新内容,向上回看历史时不会被拉回底部;键盘弹出时思考与回复内容不再被输入框遮挡
|
|
20
|
+
- 移动端消息编辑器:键盘开合过渡更平滑,消息操作与编辑时的附件按钮更易用
|
|
21
|
+
- 聊天附件、思考面板与工具卡片的样式更清晰,浅色主题配色更柔和统一
|
|
22
|
+
|
|
23
|
+
#### 修复
|
|
24
|
+
|
|
25
|
+
- 修复生成失败或模型返回空内容时界面停留在「生成中」的问题:现在会回滚本轮并提示;已收到的部分正文会保留并标注「生成已中断」
|
|
26
|
+
- 修复通过局域网地址(侧栏二维码)在手机上打开时,回合可能一直卡住没有回复的问题;连接超时会自动改用同源代理,全部失败时明确提示网络错误
|
|
27
|
+
- 修复刷新或切换标签页时短暂闪出错误主题的问题,首帧即应用正确的深浅色背景
|
|
28
|
+
- 修复首页角色卡片不显示自定义头像的问题
|
|
29
|
+
- 修复移动端消息操作按钮、编辑附件动作与聊天附件样式的问题
|
|
30
|
+
- 修复启动时模型服务列表可能为空、旧会话消息加载较慢的问题
|
|
31
|
+
|
|
32
|
+
#### 内部
|
|
33
|
+
|
|
34
|
+
- 持久化服务新增启动快照与按字段查询接口,减少启动请求数量,并补充相应测试
|
|
35
|
+
- 新增头像缩略图与行指纹缓存,降低长列表重复渲染开销
|
|
36
|
+
- 统一弹窗组件与尺寸定义,移除重复的遮罩点击逻辑
|
|
37
|
+
- 补全 yolo 集成测试所需的持久化接口 mock
|
|
38
|
+
|
|
39
|
+
### English
|
|
40
|
+
|
|
41
|
+
#### Added
|
|
42
|
+
|
|
43
|
+
- New "Reset" button in Settings → Generation parameters restores the default temperature, Top P, Top K, and related values; it is disabled when everything is already at defaults
|
|
44
|
+
|
|
45
|
+
#### Changed
|
|
46
|
+
|
|
47
|
+
- Startup: the UI mounts before data loads and shows a loading indicator instead of a blank page; startup data is fetched in a single snapshot request, and long lists stay smooth through incremental rendering and caching
|
|
48
|
+
- Achievements page reorganized: the trophy shelf comes first, with career stats and a separate "Reset stats" section below
|
|
49
|
+
- Modals now share three sizes (compact, standard, wide) and adapt to mobile; clicking the backdrop no longer dismisses them, preventing accidental loss of edits
|
|
50
|
+
- Streaming is more stable: the view follows new content automatically but stops following when you scroll up to read history, and the keyboard no longer covers thinking or reply content
|
|
51
|
+
- Mobile message editor: smoother keyboard transitions and easier access to attachment actions while editing
|
|
52
|
+
- Clearer styling for chat attachments, thinking panels, and tool cards, with softer, more consistent light-theme colors
|
|
53
|
+
|
|
54
|
+
#### Fixed
|
|
55
|
+
|
|
56
|
+
- Fixed the UI getting stuck in "generating" when generation failed or the model returned no content: the turn now rolls back with a clear message, while partial text is kept and marked as interrupted
|
|
57
|
+
- Fixed turns occasionally hanging without a reply when the app is opened on a phone through its LAN address (sidebar QR code); connection timeouts now fall back to the same-origin proxy and report a network error when every candidate fails
|
|
58
|
+
- Fixed a brief flash of the wrong theme when refreshing or switching tabs; the correct light/dark background is applied on the first frame
|
|
59
|
+
- Fixed character cards on the home page not showing custom avatars
|
|
60
|
+
- Fixed mobile message action buttons, attachment actions while editing, and chat attachment styling
|
|
61
|
+
- Fixed the model provider list sometimes being empty at startup and slow loading of older session messages
|
|
62
|
+
|
|
63
|
+
#### Internal
|
|
64
|
+
|
|
65
|
+
- Added startup snapshot and list-by-field persistence endpoints to reduce startup requests, with tests
|
|
66
|
+
- Added avatar thumbnail and row fingerprint caches to cut repeated rendering work in long lists
|
|
67
|
+
- Unified the modal component and sizing, removing duplicate backdrop-click logic
|
|
68
|
+
- Filled in the persistence endpoint mocks required by the yolo integration test
|
|
69
|
+
|
|
6
70
|
## [0.1.13] - 2026-10-21
|
|
7
71
|
|
|
8
72
|
### 中文
|
|
@@ -330,6 +394,7 @@
|
|
|
330
394
|
- Vite + React + Zustand + Dexie stack with browser-side local persistence
|
|
331
395
|
|
|
332
396
|
<!-- 链接定义 -->
|
|
397
|
+
[0.1.14]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.13...v0.1.14
|
|
333
398
|
[0.1.13]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.12...v0.1.13
|
|
334
399
|
[0.1.12]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.11...v0.1.12
|
|
335
400
|
[0.1.11]: https://github.com/Rannichan/Tavern-Harness/compare/v0.1.10...v0.1.11
|
package/index.html
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
15
15
|
const dark = mode === 'dark' || (mode !== 'light' && systemDark);
|
|
16
16
|
const root = document.documentElement;
|
|
17
|
-
const background = dark ? '#20160E' : '#
|
|
17
|
+
const background = dark ? '#20160E' : '#F7F0E4';
|
|
18
18
|
root.dataset.theme = dark ? 'dark' : 'light';
|
|
19
19
|
root.style.colorScheme = dark ? 'dark' : 'light';
|
|
20
20
|
root.style.backgroundColor = background;
|
package/package.json
CHANGED
package/persistence-server.mjs
CHANGED
|
@@ -152,6 +152,18 @@ function saveRecord(collection, key, value) {
|
|
|
152
152
|
|
|
153
153
|
const getRecord = database.prepare('SELECT value_json, updated_at FROM app_records WHERE collection = ? AND record_key = ?');
|
|
154
154
|
const listRecords = database.prepare('SELECT record_key, value_json, updated_at FROM app_records WHERE collection = ? ORDER BY record_key');
|
|
155
|
+
const listRecordsByJsonField = database.prepare(`
|
|
156
|
+
SELECT record_key, value_json, updated_at
|
|
157
|
+
FROM app_records
|
|
158
|
+
WHERE collection = ? AND json_extract(value_json, ?) = ?
|
|
159
|
+
ORDER BY record_key
|
|
160
|
+
`);
|
|
161
|
+
const listStartupRecords = database.prepare(`
|
|
162
|
+
SELECT collection, record_key, value_json, updated_at
|
|
163
|
+
FROM app_records
|
|
164
|
+
WHERE collection IN ('settings', 'providers', 'npcs', 'sessions', 'worldBooks', 'tools', 'achievementUnlocks')
|
|
165
|
+
ORDER BY collection, record_key
|
|
166
|
+
`);
|
|
155
167
|
const deleteRecord = database.prepare('DELETE FROM app_records WHERE collection = ? AND record_key = ?');
|
|
156
168
|
const clearCollection = database.prepare('DELETE FROM app_records WHERE collection = ?');
|
|
157
169
|
const nextNumericRecordKey = database.prepare(`
|
|
@@ -220,6 +232,31 @@ const server = createServer(async (req, res) => {
|
|
|
220
232
|
json(res, 200, { ok: true, leaseId: writeLock.leaseId, lock: lockState(clientId) });
|
|
221
233
|
return;
|
|
222
234
|
}
|
|
235
|
+
if (pathname === '/startup/snapshot') {
|
|
236
|
+
if (!clientId || clientId.length > 255) {
|
|
237
|
+
json(res, 400, { ok: false, message: 'A valid clientId is required' });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!writeLock) setWriteLock({ clientId, leaseId: randomBytes(24).toString('base64url') });
|
|
241
|
+
const collections = {
|
|
242
|
+
settings: [],
|
|
243
|
+
providers: [],
|
|
244
|
+
npcs: [],
|
|
245
|
+
sessions: [],
|
|
246
|
+
worldBooks: [],
|
|
247
|
+
tools: [],
|
|
248
|
+
achievementUnlocks: [],
|
|
249
|
+
};
|
|
250
|
+
for (const row of listStartupRecords.all()) {
|
|
251
|
+
collections[row.collection].push({
|
|
252
|
+
key: row.record_key,
|
|
253
|
+
value: JSON.parse(row.value_json),
|
|
254
|
+
updatedAt: row.updated_at,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
json(res, 200, { ok: true, lock: lockState(clientId), collections });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
223
260
|
if (!pathname.startsWith('/records/')) {
|
|
224
261
|
json(res, 404, { ok: false, message: `Unknown persistence endpoint: ${pathname}` });
|
|
225
262
|
return;
|
|
@@ -231,8 +268,23 @@ const server = createServer(async (req, res) => {
|
|
|
231
268
|
return;
|
|
232
269
|
}
|
|
233
270
|
|
|
234
|
-
if (pathname === '/records/list') {
|
|
235
|
-
|
|
271
|
+
if (pathname === '/records/list' || pathname === '/records/list-by-field') {
|
|
272
|
+
let rows;
|
|
273
|
+
if (pathname === '/records/list-by-field') {
|
|
274
|
+
const { field, value } = body;
|
|
275
|
+
if (typeof field !== 'string' || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(field)) {
|
|
276
|
+
json(res, 400, { ok: false, message: 'Invalid record field' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
|
280
|
+
json(res, 400, { ok: false, message: 'Invalid record field value' });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
rows = listRecordsByJsonField.all(collection, `$.${field}`, value);
|
|
284
|
+
} else {
|
|
285
|
+
rows = listRecords.all(collection);
|
|
286
|
+
}
|
|
287
|
+
const records = rows.map((row) => ({
|
|
236
288
|
key: row.record_key,
|
|
237
289
|
value: JSON.parse(row.value_json),
|
|
238
290
|
updatedAt: row.updated_at,
|
package/src/App.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
2
|
-
import { useStore } from './store/store';
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { useStore, type ActiveView } from './store/store';
|
|
3
3
|
import { Sidebar } from './components/Sidebar';
|
|
4
4
|
import { ChatView, ChatInput, MessageMenu, TurnQueuePanel } from './components/ChatView';
|
|
5
5
|
import { CharactersView } from './components/CharactersView';
|
|
@@ -30,6 +30,10 @@ export default function App() {
|
|
|
30
30
|
const syncControlState = useStore((s) => s.syncControlState);
|
|
31
31
|
const takeOverControl = useStore((s) => s.takeOverControl);
|
|
32
32
|
const activeView = useStore((s) => s.activeView);
|
|
33
|
+
const [mountedViews, setMountedViews] = useState<ActiveView[]>([activeView]);
|
|
34
|
+
if (!mountedViews.includes(activeView)) {
|
|
35
|
+
setMountedViews([...mountedViews, activeView]);
|
|
36
|
+
}
|
|
33
37
|
const activeSessionId = useStore((s) => s.activeSessionId);
|
|
34
38
|
const sessions = useStore((s) => s.sessions);
|
|
35
39
|
const messages = useStore((s) => s.messages);
|
|
@@ -84,7 +88,11 @@ export default function App() {
|
|
|
84
88
|
};
|
|
85
89
|
|
|
86
90
|
if (initStatus === 'loading') {
|
|
87
|
-
return
|
|
91
|
+
return (
|
|
92
|
+
<div className="startup-loading" role="status" aria-label={t('init.loading')} aria-busy="true">
|
|
93
|
+
<span className="spinner" aria-hidden="true" />
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
if (initStatus === 'error') {
|
|
@@ -132,28 +140,44 @@ export default function App() {
|
|
|
132
140
|
<div className="app-shell">
|
|
133
141
|
<Sidebar />
|
|
134
142
|
<main className="main-area">
|
|
135
|
-
{activeView === 'chat'
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
<div className="chat-main-
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
<ViewSlot active={activeView === 'chat'}>
|
|
144
|
+
{activeSession ? (
|
|
145
|
+
<>
|
|
146
|
+
<SessionHeader session={activeSession} participants={sessionParticipants} />
|
|
147
|
+
<div className="chat-main-row">
|
|
148
|
+
<div className="chat-main-col">
|
|
149
|
+
<ChatView
|
|
150
|
+
session={activeSession}
|
|
151
|
+
messages={sessionMessages}
|
|
152
|
+
participants={sessionParticipants}
|
|
153
|
+
compressions={contextCompressions[activeSession.id!] ?? []}
|
|
154
|
+
streaming={isStreamingSession}
|
|
155
|
+
active={activeView === 'chat'}
|
|
156
|
+
/>
|
|
157
|
+
<ChatInput sessionId={activeSession.id!} />
|
|
158
|
+
</div>
|
|
159
|
+
<TurnQueuePanel session={activeSession} participants={sessionParticipants} />
|
|
148
160
|
</div>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
161
|
+
</>
|
|
162
|
+
) : (
|
|
163
|
+
<Dashboard />
|
|
164
|
+
)}
|
|
165
|
+
</ViewSlot>
|
|
166
|
+
{mountedViews.includes('characters') && (
|
|
167
|
+
<ViewSlot active={activeView === 'characters'}>
|
|
168
|
+
<CharactersView active={activeView === 'characters'} />
|
|
169
|
+
</ViewSlot>
|
|
170
|
+
)}
|
|
171
|
+
{mountedViews.includes('settings') && (
|
|
172
|
+
<ViewSlot active={activeView === 'settings'}>
|
|
173
|
+
<SettingsView />
|
|
174
|
+
</ViewSlot>
|
|
175
|
+
)}
|
|
176
|
+
{mountedViews.includes('stats') && (
|
|
177
|
+
<ViewSlot active={activeView === 'stats'}>
|
|
178
|
+
<StatsView active={activeView === 'stats'} />
|
|
179
|
+
</ViewSlot>
|
|
152
180
|
)}
|
|
153
|
-
{activeView === 'chat' && !activeSession && <Dashboard />}
|
|
154
|
-
{activeView === 'characters' && <CharactersView />}
|
|
155
|
-
{activeView === 'settings' && <SettingsView />}
|
|
156
|
-
{activeView === 'stats' && <StatsView />}
|
|
157
181
|
</main>
|
|
158
182
|
</div>
|
|
159
183
|
<ConfirmationDialog />
|
|
@@ -167,6 +191,48 @@ export default function App() {
|
|
|
167
191
|
);
|
|
168
192
|
}
|
|
169
193
|
|
|
194
|
+
/** 保持已访问的视图挂载,并隔离隐藏视图的焦点与辅助功能。 */
|
|
195
|
+
function ViewSlot({ active, children }: { active: boolean; children: ReactNode }) {
|
|
196
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
197
|
+
const lastTheme = useRef<string | undefined>();
|
|
198
|
+
const revealFrame = useRef<number | null>(null);
|
|
199
|
+
|
|
200
|
+
useLayoutEffect(() => {
|
|
201
|
+
const el = ref.current;
|
|
202
|
+
if (!el) return;
|
|
203
|
+
if (active) {
|
|
204
|
+
const theme = document.documentElement.dataset.theme;
|
|
205
|
+
const themeChanged = lastTheme.current !== undefined && lastTheme.current !== theme;
|
|
206
|
+
if (themeChanged) {
|
|
207
|
+
// 隐藏视图在 content-visibility 下会延后解析变量;显示的首帧禁用过渡,
|
|
208
|
+
// 避免旧主题颜色被补绘并动画到新主题。
|
|
209
|
+
el.dataset.themeReveal = 'true';
|
|
210
|
+
revealFrame.current = window.requestAnimationFrame(() => {
|
|
211
|
+
revealFrame.current = window.requestAnimationFrame(() => {
|
|
212
|
+
delete el.dataset.themeReveal;
|
|
213
|
+
revealFrame.current = null;
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
lastTheme.current = theme;
|
|
218
|
+
el.removeAttribute('inert');
|
|
219
|
+
el.removeAttribute('aria-hidden');
|
|
220
|
+
return () => {
|
|
221
|
+
if (revealFrame.current != null) window.cancelAnimationFrame(revealFrame.current);
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const focused = document.activeElement;
|
|
225
|
+
if (focused instanceof HTMLElement && el.contains(focused)) focused.blur();
|
|
226
|
+
el.setAttribute('inert', '');
|
|
227
|
+
el.setAttribute('aria-hidden', 'true');
|
|
228
|
+
}, [active]);
|
|
229
|
+
return (
|
|
230
|
+
<div ref={ref} className="view-slot" data-active={active ? 'true' : 'false'}>
|
|
231
|
+
{children}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
170
236
|
/** 全局注册的游戏导出/导入弹窗(供 Sidebar 与 SessionHeader 之外的入口使用) */
|
|
171
237
|
function GameplayEntryDialogs() {
|
|
172
238
|
const [exportTarget, setExportTarget] = useState<{ sessionId: number; includeHistory: boolean } | null>(null);
|
|
@@ -278,7 +344,7 @@ function SessionHeader({
|
|
|
278
344
|
)}
|
|
279
345
|
|
|
280
346
|
{confirmReset && (
|
|
281
|
-
<Modal onClose={() => setConfirmReset(false)}
|
|
347
|
+
<Modal onClose={() => setConfirmReset(false)} size="compact">
|
|
282
348
|
<div className="modal-head">
|
|
283
349
|
<span style={{ fontWeight: 800, fontSize: 15 }}>{t('header.resetSessionTitle')}</span>
|
|
284
350
|
<button className="icon-btn" onClick={() => setConfirmReset(false)}><Icon name="x" /></button>
|
|
@@ -303,7 +369,7 @@ function SessionHeader({
|
|
|
303
369
|
)}
|
|
304
370
|
|
|
305
371
|
{confirmDelete && (
|
|
306
|
-
<Modal onClose={() => setConfirmDelete(false)}
|
|
372
|
+
<Modal onClose={() => setConfirmDelete(false)} size="compact">
|
|
307
373
|
<div className="modal-head">
|
|
308
374
|
<span style={{ fontWeight: 800, fontSize: 15 }}>{t('header.deleteTitle')}</span>
|
|
309
375
|
<button className="icon-btn" onClick={() => setConfirmDelete(false)}><Icon name="x" /></button>
|
|
@@ -111,7 +111,7 @@ function CelebrationOverlay({ item }: { item: QueueItem }) {
|
|
|
111
111
|
|
|
112
112
|
return createPortal(
|
|
113
113
|
<>
|
|
114
|
-
<div className="ach-overlay"
|
|
114
|
+
<div className="ach-overlay" />
|
|
115
115
|
<div className="ach-celebration">
|
|
116
116
|
<div className={`ach-rain ${leaving ? 'leave' : ''}`}>
|
|
117
117
|
{confetti.map((c) => (
|
|
@@ -132,7 +132,9 @@ function CelebrationOverlay({ item }: { item: QueueItem }) {
|
|
|
132
132
|
))}
|
|
133
133
|
</div>
|
|
134
134
|
<div className={`ach-trophy ${leaving ? 'leave' : ''}`}>
|
|
135
|
-
<div className="ach-trophy-badge">
|
|
135
|
+
<div className="ach-trophy-badge">
|
|
136
|
+
{item.ach.iconUrl ? <img className="ach-trophy-image" src={item.ach.iconUrl} alt="" /> : item.ach.icon}
|
|
137
|
+
</div>
|
|
136
138
|
<div className="ach-trophy-glow" />
|
|
137
139
|
</div>
|
|
138
140
|
<div className={`ach-caption ${leaving ? 'leave' : ''}`}>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
DndContext,
|
|
4
4
|
closestCenter,
|
|
@@ -25,11 +25,15 @@ import { DeleteConfirmDialog } from './DeleteConfirmDialog';
|
|
|
25
25
|
import { parseSillyTavernCardFile } from '../core/sillyTavernImporter';
|
|
26
26
|
import { parseSillyTavernWorldInfoFile } from '../core/sillyTavernWorldInfo';
|
|
27
27
|
import { characterBookEntryStats } from '../core/characterBook';
|
|
28
|
+
import { textPreview } from '../core/textPreview';
|
|
29
|
+
import { AVATAR_STORAGE_SIZE, shrinkAvatarDataUrl } from '../core/avatarThumbnail';
|
|
28
30
|
import { ALL_BUILTIN_TOOL_NAMES } from '../core/toolDefinitions';
|
|
29
31
|
import { useT } from '../core/i18n';
|
|
30
32
|
|
|
31
33
|
const CharacterBookEditor = lazy(() => import('./CharacterBookEditor'));
|
|
32
34
|
|
|
35
|
+
type WorkshopTab = 'characters' | 'worldbooks' | 'skills';
|
|
36
|
+
|
|
33
37
|
// 内置角色「酒馆老板」默认启用所有技能
|
|
34
38
|
const ALL_DEFAULT_SKILLS = [...ALL_BUILTIN_TOOL_NAMES];
|
|
35
39
|
|
|
@@ -39,12 +43,22 @@ const ALL_DEFAULT_SKILLS = [...ALL_BUILTIN_TOOL_NAMES];
|
|
|
39
43
|
// 由用户手动点保存
|
|
40
44
|
// ============================================================
|
|
41
45
|
|
|
42
|
-
export function CharactersView() {
|
|
46
|
+
export const CharactersView = memo(function CharactersView({ active = true }: { active?: boolean }) {
|
|
43
47
|
const npcs = useStore((s) => s.npcs);
|
|
44
48
|
const worldBooks = useStore((s) => s.worldBooks);
|
|
45
49
|
const addToast = useStore((s) => s.addToast);
|
|
46
50
|
const t = useT();
|
|
47
|
-
const [tab, setTab] = useState<
|
|
51
|
+
const [tab, setTab] = useState<WorkshopTab>('characters');
|
|
52
|
+
// 懒挂载 + 常驻(与 App.tsx 的 ViewSlot 同一思路):tab 一旦打开过就不再卸载。
|
|
53
|
+
// 角色卡 / 世界书卡片带着大段文本和头像背景图,反复重建会把排版和解码
|
|
54
|
+
// 成本重复付一遍,表现就是每次切换都卡一下。
|
|
55
|
+
const [mountedTabs, setMountedTabs] = useState<ReadonlySet<WorkshopTab>>(
|
|
56
|
+
() => new Set<WorkshopTab>(['characters']),
|
|
57
|
+
);
|
|
58
|
+
const selectTab = (next: WorkshopTab) => {
|
|
59
|
+
setTab(next);
|
|
60
|
+
setMountedTabs((prev) => (prev.has(next) ? prev : new Set(prev).add(next)));
|
|
61
|
+
};
|
|
48
62
|
const [editing, setEditing] = useState<NpcCharacter | null>(null);
|
|
49
63
|
const [isNew, setIsNew] = useState(false);
|
|
50
64
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
@@ -55,19 +69,19 @@ export function CharactersView() {
|
|
|
55
69
|
const refreshWb = useStore((s) => s.refreshWorldBooks);
|
|
56
70
|
const refreshTools = useStore((s) => s.refreshTools);
|
|
57
71
|
|
|
58
|
-
//
|
|
72
|
+
// 视图常驻时按激活状态刷新。
|
|
59
73
|
useEffect(() => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}, []);
|
|
74
|
+
if (!active) return;
|
|
75
|
+
void refresh();
|
|
76
|
+
void refreshWb();
|
|
77
|
+
void refreshTools();
|
|
78
|
+
}, [active, refresh, refreshWb, refreshTools]);
|
|
65
79
|
|
|
66
80
|
const handleImport = async (file: File) => {
|
|
67
81
|
try {
|
|
68
82
|
const draft = await parseSillyTavernCardFile(file);
|
|
69
|
-
|
|
70
|
-
setEditing({ ...draft.character, id: undefined });
|
|
83
|
+
const avatarDataUrl = await shrinkAvatarDataUrl(draft.character.avatarDataUrl, AVATAR_STORAGE_SIZE);
|
|
84
|
+
setEditing({ ...draft.character, avatarDataUrl, id: undefined });
|
|
71
85
|
setIsNew(true);
|
|
72
86
|
addToast(t('toast.importedChar', { name: draft.character.name, version: draft.version }));
|
|
73
87
|
} catch (e) {
|
|
@@ -83,18 +97,18 @@ export function CharactersView() {
|
|
|
83
97
|
<p className="view-sub">{t('workshop.sub')}</p>
|
|
84
98
|
</div>
|
|
85
99
|
<div className="side-nav" data-hscroll>
|
|
86
|
-
<button className={`nav-chip ${tab === 'characters' ? 'active' : ''}`} onClick={() =>
|
|
100
|
+
<button className={`nav-chip ${tab === 'characters' ? 'active' : ''}`} onClick={() => selectTab('characters')}>
|
|
87
101
|
{t('workshop.tabCharacters')}
|
|
88
102
|
</button>
|
|
89
|
-
<button className={`nav-chip ${tab === 'worldbooks' ? 'active' : ''}`} onClick={() =>
|
|
103
|
+
<button className={`nav-chip ${tab === 'worldbooks' ? 'active' : ''}`} onClick={() => selectTab('worldbooks')}>
|
|
90
104
|
{t('workshop.tabWorldbooks')}
|
|
91
105
|
</button>
|
|
92
|
-
<button className={`nav-chip ${tab === 'skills' ? 'active' : ''}`} onClick={() =>
|
|
106
|
+
<button className={`nav-chip ${tab === 'skills' ? 'active' : ''}`} onClick={() => selectTab('skills')}>
|
|
93
107
|
{t('workshop.tabSkills')}
|
|
94
108
|
</button>
|
|
95
109
|
</div>
|
|
96
110
|
|
|
97
|
-
{tab === 'characters'
|
|
111
|
+
<div className="workshop-tab-panel" data-active={tab === 'characters' ? 'true' : 'false'}>
|
|
98
112
|
<CharacterGrid
|
|
99
113
|
npcs={npcs}
|
|
100
114
|
onEdit={(n, isNew) => {
|
|
@@ -103,9 +117,13 @@ export function CharactersView() {
|
|
|
103
117
|
}}
|
|
104
118
|
onImportPng={openFilePicker}
|
|
105
119
|
/>
|
|
106
|
-
|
|
107
|
-
{tab === 'worldbooks'
|
|
108
|
-
|
|
120
|
+
</div>
|
|
121
|
+
<div className="workshop-tab-panel" data-active={tab === 'worldbooks' ? 'true' : 'false'}>
|
|
122
|
+
{mountedTabs.has('worldbooks') && <WorldBookList books={worldBooks} onChanged={refreshWb} />}
|
|
123
|
+
</div>
|
|
124
|
+
<div className="workshop-tab-panel" data-active={tab === 'skills' ? 'true' : 'false'}>
|
|
125
|
+
{mountedTabs.has('skills') && <SkillList />}
|
|
126
|
+
</div>
|
|
109
127
|
</div>
|
|
110
128
|
<input
|
|
111
129
|
ref={fileRef}
|
|
@@ -134,7 +152,7 @@ export function CharactersView() {
|
|
|
134
152
|
)}
|
|
135
153
|
</div>
|
|
136
154
|
);
|
|
137
|
-
}
|
|
155
|
+
});
|
|
138
156
|
|
|
139
157
|
// ---------------- 角色网格 ----------------
|
|
140
158
|
|
|
@@ -188,7 +206,7 @@ function CharacterGrid({ npcs, onEdit, onImportPng }: { npcs: NpcCharacter[]; on
|
|
|
188
206
|
<ScrollingCardName name={n.name} />
|
|
189
207
|
{n.isBuiltIn && <span className="tag">{t('common.builtin')}</span>}
|
|
190
208
|
</div>
|
|
191
|
-
<div className="cgreet">{n.greeting || t('workshop.noGreeting')}</div>
|
|
209
|
+
<div className="cgreet">{textPreview(n.greeting) || t('workshop.noGreeting')}</div>
|
|
192
210
|
{/* 技能区固定渲染(哪怕无技能),保证所有卡片高度一致 */}
|
|
193
211
|
<div className="cskil">
|
|
194
212
|
{n.enabledToolNames.map((s) => (
|
|
@@ -328,7 +346,10 @@ function CharacterEditorModal({ npc, isNew, onClose, onSaved }: { npc: NpcCharac
|
|
|
328
346
|
const f = e.target.files?.[0];
|
|
329
347
|
if (f) {
|
|
330
348
|
const reader = new FileReader();
|
|
331
|
-
reader.onload = () =>
|
|
349
|
+
reader.onload = () => {
|
|
350
|
+
const raw = reader.result as string;
|
|
351
|
+
void shrinkAvatarDataUrl(raw, AVATAR_STORAGE_SIZE).then((url) => setAvatarDataUrl(url));
|
|
352
|
+
};
|
|
332
353
|
reader.readAsDataURL(f);
|
|
333
354
|
}
|
|
334
355
|
}} />
|
|
@@ -451,6 +472,13 @@ function WorldBookList({ books, onChanged }: {
|
|
|
451
472
|
const [editorTab, setEditorTab] = useState<'worldbook' | 'knowledge'>('worldbook');
|
|
452
473
|
const [pendingDelete, setPendingDelete] = useState<WorldBook | null>(null);
|
|
453
474
|
const jsonFileRef = useRef<HTMLInputElement>(null);
|
|
475
|
+
const statsByBook = useMemo(() => {
|
|
476
|
+
const map = new Map<number, { total: number; enabled: number }>();
|
|
477
|
+
for (const book of books) {
|
|
478
|
+
if (book.id != null) map.set(book.id, characterBookEntryStats(book.characterBook));
|
|
479
|
+
}
|
|
480
|
+
return map;
|
|
481
|
+
}, [books]);
|
|
454
482
|
|
|
455
483
|
const closeEditor = () => {
|
|
456
484
|
setEditing(null);
|
|
@@ -556,7 +584,7 @@ function WorldBookList({ books, onChanged }: {
|
|
|
556
584
|
<MasonryGrid>
|
|
557
585
|
{books.map((b) => {
|
|
558
586
|
const isBuiltin = isBuiltinTavernHandbook(b);
|
|
559
|
-
const knowledge =
|
|
587
|
+
const knowledge = (b.id != null ? statsByBook.get(b.id) : undefined) ?? { total: 0, enabled: 0 };
|
|
560
588
|
const meta = [
|
|
561
589
|
b.content ? t('workshop.wbChars', { n: b.content.length }) : '',
|
|
562
590
|
knowledge.total > 0 ? t('workshop.wbKnowledgeMeta', { enabled: knowledge.enabled, total: knowledge.total }) : '',
|
|
@@ -568,7 +596,7 @@ function WorldBookList({ books, onChanged }: {
|
|
|
568
596
|
<ScrollingCardName name={b.name} />
|
|
569
597
|
{isBuiltin && <span className="tag">{t('common.builtin')}</span>}
|
|
570
598
|
</div>
|
|
571
|
-
<div className="wcontent">{b.content || t('workshop.wbNoContent')}</div>
|
|
599
|
+
<div className="wcontent">{textPreview(b.content) || t('workshop.wbNoContent')}</div>
|
|
572
600
|
<div className="cmeta">{meta}</div>
|
|
573
601
|
<div className="cactions">
|
|
574
602
|
<button className="btn btn-sm" onClick={() => openEditor(b)}><Icon name="settings" size={12} /> {t('workshop.browseEdit')}</button>
|
|
@@ -652,7 +680,7 @@ function WorldBookForm({
|
|
|
652
680
|
const [characterBook, setCharacterBook] = useState(initial.characterBook ?? '');
|
|
653
681
|
const [renderMarkdown, setRenderMarkdown] = useState(false);
|
|
654
682
|
const isBuiltin = isBuiltinTavernHandbook(initial);
|
|
655
|
-
const knowledge = characterBookEntryStats(characterBook);
|
|
683
|
+
const knowledge = useMemo(() => characterBookEntryStats(characterBook), [characterBook]);
|
|
656
684
|
const isEditing = 'id' in initial && initial.id != null;
|
|
657
685
|
|
|
658
686
|
const resetToDefault = () => {
|
|
@@ -1030,7 +1058,7 @@ function SkillDetailModal({ tool, onClose }: { tool: McpTool; onClose: () => voi
|
|
|
1030
1058
|
};
|
|
1031
1059
|
|
|
1032
1060
|
return (
|
|
1033
|
-
<Modal onClose={onClose}
|
|
1061
|
+
<Modal onClose={onClose}>
|
|
1034
1062
|
<div className="modal-head">
|
|
1035
1063
|
<span style={{ fontWeight: 800, fontSize: 15 }}>
|
|
1036
1064
|
{t('workshop.skillDetails')}
|