tavern-harness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +161 -0
  2. package/README.zh.md +123 -0
  3. package/bin/cli.js +48 -0
  4. package/index.html +35 -0
  5. package/package.json +50 -0
  6. package/public/favicon.png +0 -0
  7. package/public/tavern-harness-logo.png +0 -0
  8. package/sandbox-server.mjs +997 -0
  9. package/src/App.tsx +268 -0
  10. package/src/assets/tavern-handbook.md +39 -0
  11. package/src/components/AchievementModal.tsx +158 -0
  12. package/src/components/CharactersView.tsx +904 -0
  13. package/src/components/ChatView.tsx +1639 -0
  14. package/src/components/ConfirmationDialog.tsx +56 -0
  15. package/src/components/Dashboard.tsx +180 -0
  16. package/src/components/DeleteConfirmDialog.tsx +48 -0
  17. package/src/components/FileDisplayModal.tsx +556 -0
  18. package/src/components/GameplayDialogs.tsx +191 -0
  19. package/src/components/NewSessionMenu.tsx +362 -0
  20. package/src/components/SettingsView.tsx +517 -0
  21. package/src/components/Sidebar.tsx +392 -0
  22. package/src/components/StatsView.tsx +189 -0
  23. package/src/components/Toasts.tsx +20 -0
  24. package/src/components/WorkspaceFileManagerModal.tsx +216 -0
  25. package/src/components/shared.tsx +369 -0
  26. package/src/core/achievements.ts +85 -0
  27. package/src/core/fileDownload.ts +67 -0
  28. package/src/core/gameplay.ts +521 -0
  29. package/src/core/horizontalWheel.ts +93 -0
  30. package/src/core/i18n.ts +1403 -0
  31. package/src/core/linkedScroll.ts +236 -0
  32. package/src/core/markdown.ts +170 -0
  33. package/src/core/openai.ts +451 -0
  34. package/src/core/openingGreeting.ts +41 -0
  35. package/src/core/prompts.ts +254 -0
  36. package/src/core/proxy.ts +58 -0
  37. package/src/core/sillyTavernImporter.ts +317 -0
  38. package/src/core/stats.ts +91 -0
  39. package/src/core/strings.ts +19 -0
  40. package/src/core/toolDefinitions.ts +400 -0
  41. package/src/core/tools/builtinTools.ts +38 -0
  42. package/src/core/tools/generatedSkillExecutor.ts +706 -0
  43. package/src/core/tools/generatedWorkspace.ts +13 -0
  44. package/src/core/tools/toolExecutor.ts +718 -0
  45. package/src/core/turnLoop.ts +211 -0
  46. package/src/db/database.ts +647 -0
  47. package/src/main.tsx +23 -0
  48. package/src/store/store.ts +2419 -0
  49. package/src/theme/chat.css +1227 -0
  50. package/src/theme/global.css +1377 -0
  51. package/src/theme/theme.ts +143 -0
  52. package/src/theme/views.css +1430 -0
  53. package/src/types/models.ts +364 -0
  54. package/src/vite-env.d.ts +1 -0
  55. package/tsconfig.json +21 -0
  56. package/vite.config.ts +273 -0
@@ -0,0 +1,392 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useStore } from '../store/store';
4
+ import { db } from '../db/database';
5
+ import { Icon, Modal, SessionVisual } from './shared';
6
+ import type { SessionMember } from './shared';
7
+ import type { ChatSession, NpcCharacter } from '../types/models';
8
+ import { NewSessionMenu } from './NewSessionMenu';
9
+ import { useT } from '../core/i18n';
10
+
11
+ const SIDEBAR_MIN = 240;
12
+ const SIDEBAR_MAX = 520;
13
+
14
+ /** 触发 App 级全局游戏导出弹窗(会话右键菜单共用) */
15
+ function openGlobalGameplayExport(sessionId: number, includeHistory: boolean) {
16
+ window.dispatchEvent(
17
+ new CustomEvent('th-gameplay-export', { detail: { sessionId, includeHistory } })
18
+ );
19
+ }
20
+
21
+ export function Sidebar() {
22
+ const sessions = useStore((s) => s.sessions);
23
+ const npcs = useStore((s) => s.npcs);
24
+ const activeSessionId = useStore((s) => s.activeSessionId);
25
+ const activeView = useStore((s) => s.activeView);
26
+ const streamingSessionId = useStore((s) => s.streaming.sessionId);
27
+ const activitySessionIds = useStore((s) => s.activitySessionIds);
28
+ const setActiveSession = useStore((s) => s.setActiveSession);
29
+ const setActiveView = useStore((s) => s.setActiveView);
30
+ const deleteSession = useStore((s) => s.deleteSession);
31
+ const togglePin = useStore((s) => s.togglePin);
32
+ const addToast = useStore((s) => s.addToast);
33
+ const t = useT();
34
+
35
+ const [showNew, setShowNew] = useState(false);
36
+ const [confirmDelete, setConfirmDelete] = useState<ChatSession | null>(null);
37
+ const [editTarget, setEditTarget] = useState<ChatSession | null>(null);
38
+ const [sessionMenu, setSessionMenu] = useState<{ session: ChatSession; x: number; y: number } | null>(null);
39
+ const [query, setQuery] = useState('');
40
+ // 搜索命中的会话 id 集合(null = 未在搜索中)
41
+ const [searchHits, setSearchHits] = useState<Set<number> | null>(null);
42
+ // 群聊会话 → 参与者中的 NPC 角色
43
+ const [groupMembers, setGroupMembers] = useState<Record<number, NpcCharacter[]>>({});
44
+
45
+ // 可拖拽侧边栏宽度
46
+ const [sidebarW, setSidebarW] = useState<number>(() => {
47
+ const saved = parseFloat(localStorage.getItem('tav-sidebar-w') || '0');
48
+ return saved >= SIDEBAR_MIN && saved <= SIDEBAR_MAX ? saved : 356;
49
+ });
50
+ const dragRef = useRef<{ startX: number; startW: number } | null>(null);
51
+
52
+ useEffect(() => {
53
+ const root = document.documentElement;
54
+ root.style.setProperty('--sidebar-w', `${sidebarW}px`);
55
+ localStorage.setItem('tav-sidebar-w', String(sidebarW));
56
+ }, [sidebarW]);
57
+
58
+ useEffect(() => {
59
+ const onMove = (e: MouseEvent) => {
60
+ const d = dragRef.current;
61
+ if (!d) return;
62
+ const w = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, d.startW + (e.clientX - d.startX)));
63
+ setSidebarW(w);
64
+ };
65
+ const onUp = () => {
66
+ dragRef.current = null;
67
+ document.body.classList.remove('resizing');
68
+ };
69
+ window.addEventListener('mousemove', onMove);
70
+ window.addEventListener('mouseup', onUp);
71
+ return () => {
72
+ window.removeEventListener('mousemove', onMove);
73
+ window.removeEventListener('mouseup', onUp);
74
+ };
75
+ }, []);
76
+
77
+ const npcById = (id: number | null) => (id != null ? npcs.find((n) => n.id === id) : null);
78
+
79
+ const handleDelete = async () => {
80
+ if (!confirmDelete) return;
81
+ const sid = confirmDelete.id!;
82
+ await deleteSession(sid);
83
+ addToast(t('toast.sessionDeleted'));
84
+ setConfirmDelete(null);
85
+ };
86
+
87
+ const openEdit = (session: ChatSession) => {
88
+ setSessionMenu(null);
89
+ setEditTarget(session);
90
+ };
91
+
92
+ useEffect(() => {
93
+ if (!sessionMenu && !editTarget) return;
94
+ const onKeyDown = (e: KeyboardEvent) => {
95
+ if (e.key === 'Escape') {
96
+ setSessionMenu(null);
97
+ setEditTarget(null);
98
+ }
99
+ };
100
+ window.addEventListener('keydown', onKeyDown);
101
+ return () => window.removeEventListener('keydown', onKeyDown);
102
+ }, [sessionMenu, editTarget]);
103
+
104
+ // 搜索:标题 / 消息内容关键词匹配
105
+ useEffect(() => {
106
+ const keyword = query.trim().toLowerCase();
107
+ if (!keyword) {
108
+ setSearchHits(null);
109
+ return;
110
+ }
111
+ let cancelled = false;
112
+ (async () => {
113
+ // 标题匹配
114
+ const titleHits = new Set(
115
+ sessions.filter((s) => s.title.toLowerCase().includes(keyword)).map((s) => s.id!)
116
+ );
117
+ // 消息内容匹配(分页扫描,避免大结果集)
118
+ const contentHits = new Set<number>();
119
+ let offset = 0;
120
+ const pageSize = 500;
121
+ while (true) {
122
+ const page = await db.messages.orderBy('timestamp').offset(offset).limit(pageSize).toArray();
123
+ if (page.length === 0) break;
124
+ for (const m of page) {
125
+ const text = (m.content || '').toLowerCase();
126
+ if (text.includes(keyword)) contentHits.add(m.sessionId);
127
+ }
128
+ if (page.length < pageSize) break;
129
+ offset += pageSize;
130
+ }
131
+ if (!cancelled) {
132
+ const merged = new Set([...titleHits, ...contentHits]);
133
+ setSearchHits(merged);
134
+ }
135
+ })();
136
+ return () => {
137
+ cancelled = true;
138
+ };
139
+ }, [query, sessions]);
140
+
141
+ const visibleSessions = useMemo(() => {
142
+ const list = [...sessions];
143
+ if (searchHits != null) {
144
+ return list.filter((s) => searchHits.has(s.id!));
145
+ }
146
+ return list;
147
+ }, [sessions, searchHits]);
148
+
149
+ const searching = searchHits != null;
150
+
151
+ // 加载各群聊会话的参与角色(用于群头像拼贴,每个会话一次即可)
152
+ useEffect(() => {
153
+ const groupSessions = sessions.filter((s) => s.mode === 'GROUP');
154
+ if (groupSessions.length === 0) return;
155
+ let cancelled = false;
156
+ (async () => {
157
+ const loaded: Record<number, NpcCharacter[]> = {};
158
+ await Promise.all(
159
+ groupSessions.map(async (s) => {
160
+ const parts = await db.participants.where('sessionId').equals(s.id!).toArray();
161
+ const npcs1 = parts
162
+ .filter((p) => p.kind === 'NPC' && p.npcId != null)
163
+ .map((p) => npcs.find((n) => n.id === p.npcId))
164
+ .filter((n): n is NpcCharacter => Boolean(n));
165
+ loaded[s.id!] = npcs1;
166
+ })
167
+ );
168
+ if (!cancelled) setGroupMembers(loaded);
169
+ })();
170
+ return () => {
171
+ cancelled = true;
172
+ };
173
+ }, [sessions, npcs]);
174
+
175
+ const sessionMembers = (s: ChatSession): SessionMember[] | undefined =>
176
+ (groupMembers[s.id!] ?? []).slice(0, 4).map((n) => ({
177
+ name: n.name,
178
+ colorOrdinal: n.avatarColorOrdinal,
179
+ imageUrl: n.avatarDataUrl,
180
+ }));
181
+
182
+ return (
183
+ <aside className="sidebar">
184
+ <div className="sidebar-head">
185
+ <div
186
+ className="brand"
187
+ role="button"
188
+ tabIndex={0}
189
+ title={t('nav.home')}
190
+ onClick={() => setActiveSession(null)}
191
+ onKeyDown={(e) => {
192
+ if (e.key === 'Enter' || e.key === ' ') {
193
+ e.preventDefault();
194
+ setActiveSession(null);
195
+ }
196
+ }}
197
+ >
198
+ <div className="brand-logo">
199
+ <img src="/tavern-harness-logo.png" alt="Tavern Harness" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
200
+ </div>
201
+ <div>
202
+ <div className="brand-name">Tavern Harness</div>
203
+ <div className="brand-sub">{t('nav.brandSub')}</div>
204
+ </div>
205
+ </div>
206
+ <div className="side-nav" data-hscroll>
207
+ <button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => setActiveView('chat')}>
208
+ <Icon name="send" size={13} /> {t('nav.chat')}
209
+ </button>
210
+ <button className={`nav-chip ${activeView === 'characters' ? 'active' : ''}`} onClick={() => setActiveView('characters')}>
211
+ <Icon name="users" size={13} /> {t('nav.characters')}
212
+ </button>
213
+ <button className={`nav-chip ${activeView === 'stats' ? 'active' : ''}`} onClick={() => setActiveView('stats')}>
214
+ <Icon name="trophy" size={13} /> {t('nav.achievements')}
215
+ </button>
216
+ <button className={`nav-chip ${activeView === 'settings' ? 'active' : ''}`} onClick={() => setActiveView('settings')}>
217
+ <Icon name="settings" size={13} /> {t('nav.settings')}
218
+ </button>
219
+ </div>
220
+ </div>
221
+
222
+ <div className="session-section">
223
+ {/* 会话列表头部:标题 + 新建按钮(固定不滚动) */}
224
+ <div className="session-list-head">
225
+ <span className="section-title" style={{ margin: 0 }}>{t('nav.sessions')}</span>
226
+ <div className="session-head-actions">
227
+ <button
228
+ className="session-new-btn"
229
+ onClick={() => window.dispatchEvent(new CustomEvent('th-gameplay-import'))}
230
+ title={t('nav.importGameplayTitle')}
231
+ >
232
+ <Icon name="import" size={13} /> {t('nav.importGameplay')}
233
+ </button>
234
+ <button className="session-new-btn" onClick={() => setShowNew(true)} title={t('nav.newChatTitle')}>
235
+ <Icon name="plus" size={13} /> {t('nav.newChat')}
236
+ </button>
237
+ </div>
238
+ </div>
239
+ {/* 可滚动的会话列表 */}
240
+ <div className="session-list">
241
+ {visibleSessions.map((s) => {
242
+ const npc = npcById(s.associatedId);
243
+ const hasActivity = streamingSessionId === s.id || Boolean(activitySessionIds[s.id!]);
244
+ return (
245
+ <button
246
+ key={s.id}
247
+ className={`session-item ${s.id === activeSessionId && activeView === 'chat' ? 'active' : ''}`}
248
+ onClick={() => setActiveSession(s.id!)}
249
+ onContextMenu={(e) => {
250
+ e.preventDefault();
251
+ setSessionMenu({ session: s, x: e.clientX, y: e.clientY });
252
+ }}
253
+ >
254
+ {s.mode === 'NPC' ? (
255
+ <SessionVisual mode="NPC" npcName={npc?.name ?? 'NPC'} hue={npc?.avatarColorOrdinal ?? 0} imageUrl={npc?.avatarDataUrl} />
256
+ ) : (
257
+ <SessionVisual mode={s.mode} members={s.mode === 'GROUP' ? sessionMembers(s) : undefined} />
258
+ )}
259
+ <div className="smeta">
260
+ <div className="stitle">
261
+ {!!(s as { pinned?: unknown }).pinned && (
262
+ <span className="spinned" title={t('nav.pinSession')}><Icon name="pin" size={11} /></span>
263
+ )}
264
+ <span className="stitle-text">{s.title}</span>
265
+ {hasActivity && <span className="session-activity-dot" aria-label={t('nav.sessionActive')} />}
266
+ </div>
267
+ <div className="sprev">{s.lastMessage || t('nav.newConversation')}</div>
268
+ </div>
269
+ </button>
270
+ );
271
+ })}
272
+ {!searching && sessions.length === 0 && (
273
+ <div className="empty-state" style={{ padding: 30 }}>
274
+ <div className="big">🍺</div>
275
+ <span>{t('nav.empty')}</span>
276
+ </div>
277
+ )}
278
+ {searching && visibleSessions.length === 0 && (
279
+ <div className="empty-state" style={{ padding: 30 }}>
280
+ <div className="big">🔍</div>
281
+ <span>{t('nav.noMatch')}</span>
282
+ </div>
283
+ )}
284
+ </div>
285
+ </div>
286
+
287
+ {/* 底部:搜索框 */}
288
+ <div className="sidebar-search">
289
+ <input
290
+ className="input search-input"
291
+ type="text"
292
+ placeholder={t('nav.searchPh')}
293
+ value={query}
294
+ onChange={(e) => setQuery(e.target.value)}
295
+ />
296
+ {query && (
297
+ <button className="search-clear" onClick={() => setQuery('')} title={t('common.clear')}>
298
+ <Icon name="x" size={13} />
299
+ </button>
300
+ )}
301
+ </div>
302
+
303
+ {showNew && <NewSessionMenu onClose={() => setShowNew(false)} />}
304
+ {editTarget && <NewSessionMenu editingSession={editTarget} onClose={() => setEditTarget(null)} />}
305
+
306
+ {sessionMenu &&
307
+ createPortal(
308
+ <>
309
+ <div className="overlay-msg" onClick={() => setSessionMenu(null)} />
310
+ <div
311
+ className="msg-menu card"
312
+ style={{
313
+ left: Math.max(8, Math.min(sessionMenu.x, window.innerWidth - 196)),
314
+ top: Math.max(8, Math.min(sessionMenu.y, window.innerHeight - 132)),
315
+ }}
316
+ >
317
+ <button className="msg-menu-item" onClick={() => openEdit(sessionMenu.session)}>
318
+ <Icon name="pencil" size={13} /> {t('nav.editSession')}
319
+ </button>
320
+ <button
321
+ className="msg-menu-item"
322
+ onClick={async () => {
323
+ await togglePin(sessionMenu.session.id!);
324
+ setSessionMenu(null);
325
+ addToast(
326
+ (sessionMenu.session as { pinned?: unknown }).pinned
327
+ ? t('toast.sessionUnpinned')
328
+ : t('toast.sessionPinned')
329
+ );
330
+ }}
331
+ >
332
+ <Icon name="pin" size={13} /> {(sessionMenu.session as { pinned?: unknown }).pinned ? t('nav.unpinSession') : t('nav.pinSession')}
333
+ </button>
334
+ <button
335
+ className="msg-menu-item"
336
+ onClick={() => {
337
+ const target = sessionMenu.session;
338
+ setSessionMenu(null);
339
+ openGlobalGameplayExport(target.id!, true);
340
+ }}
341
+ >
342
+ <Icon name="share" size={13} /> {t('nav.exportGameplay')}
343
+ </button>
344
+ <button
345
+ className="msg-menu-item danger"
346
+ onClick={() => {
347
+ setConfirmDelete(sessionMenu.session);
348
+ setSessionMenu(null);
349
+ }}
350
+ >
351
+ <Icon name="trash" size={13} /> {t('nav.deleteSession')}
352
+ </button>
353
+ </div>
354
+ </>,
355
+ document.body
356
+ )}
357
+
358
+ {/* 删除会话确认弹窗 */}
359
+ {confirmDelete && (
360
+ <Modal onClose={() => setConfirmDelete(null)} width="min(400px, calc(100vw - 32px))">
361
+ <div className="modal-head">
362
+ <span style={{ fontWeight: 800, fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>
363
+ <Icon name="trash" size={15} /> {t('nav.deleteSession')}
364
+ </span>
365
+ <button className="icon-btn" onClick={() => setConfirmDelete(null)}><Icon name="x" /></button>
366
+ </div>
367
+ <div className="modal-body">
368
+ <div style={{ fontSize: 13.5, lineHeight: 1.7 }}>
369
+ {t('nav.deleteSessionConfirm', { title: confirmDelete.title })}
370
+ </div>
371
+ </div>
372
+ <div className="modal-foot">
373
+ <button className="btn" onClick={() => setConfirmDelete(null)}>{t('common.cancel')}</button>
374
+ <button className="btn btn-danger" onClick={handleDelete}>
375
+ <Icon name="trash" size={13} /> {t('common.delete')}
376
+ </button>
377
+ </div>
378
+ </Modal>
379
+ )}
380
+
381
+ {/* 拖拽手柄:调整侧边栏宽度 */}
382
+ <div
383
+ className="sidebar-resizer"
384
+ title={t('nav.resize')}
385
+ onMouseDown={(e) => {
386
+ dragRef.current = { startX: e.clientX, startW: sidebarW };
387
+ document.body.classList.add('resizing');
388
+ }}
389
+ />
390
+ </aside>
391
+ );
392
+ }
@@ -0,0 +1,189 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useStore } from '../store/store';
3
+ import { getCareerStats, resetCareerStats } from '../core/stats';
4
+ import { Icon } from './shared';
5
+ import type { AchievementState } from '../store/store';
6
+ import { useT, currentLocale } from '../core/i18n';
7
+ import { idToKey } from '../core/strings';
8
+
9
+ // ============================================================
10
+ // 生涯统计 + 成就陈列
11
+ // ============================================================
12
+
13
+ interface StatsData {
14
+ inputTokens: number;
15
+ outputTokens: number;
16
+ totalRounds: number;
17
+ sessionCount: number;
18
+ npcStats: Array<{ npcId: number; npcName: string; rounds: number }>;
19
+ }
20
+
21
+ export function StatsView() {
22
+ const addToast = useStore((s) => s.addToast);
23
+ const t = useT();
24
+ const [data, setData] = useState<StatsData | null>(null);
25
+
26
+ const refresh = async () => {
27
+ const d = await getCareerStats();
28
+ setData(d);
29
+ };
30
+
31
+ useEffect(() => {
32
+ refresh();
33
+ }, []);
34
+
35
+ if (!data) {
36
+ return <div className="view-page"><div className="spinner" /></div>;
37
+ }
38
+
39
+ return (
40
+ <div className="view-page">
41
+ <div className="view-col">
42
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
43
+ <div>
44
+ <h2 className="view-title">{t('stats.title')}</h2>
45
+ <p className="view-sub">{t('stats.sub')}</p>
46
+ </div>
47
+ <button
48
+ className="btn btn-sm btn-danger"
49
+ onClick={async () => {
50
+ if (!confirm(t('stats.resetConfirm'))) return;
51
+ await resetCareerStats();
52
+ await refresh();
53
+ addToast(t('toast.statsReset'));
54
+ }}
55
+ >
56
+ <Icon name="refresh" size={12} /> {t('stats.resetBtn')}
57
+ </button>
58
+ </div>
59
+
60
+ {/* 成就陈列柜 */}
61
+ <AchievementShelf />
62
+
63
+ {/* 生涯统计 */}
64
+ <StatCards data={data} />
65
+ </div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ /** 成就陈列柜:已解锁的奖杯点亮,未解锁的只展示锁定状态 */
71
+ function AchievementShelf() {
72
+ const achievements = useStore((s) => s.achievements);
73
+ const t = useT();
74
+ const unlockedCount = achievements.filter((a) => a.unlockedAt != null).length;
75
+
76
+ return (
77
+ <div className="set-section">
78
+ <h3>{t('stats.shelfTitle')}</h3>
79
+ <div className="ach-shelf">
80
+ {achievements.map((a) => (
81
+ <AchievementCard key={a.def.id} data={a} />
82
+ ))}
83
+ </div>
84
+ <div className="ach-shelf-summary">
85
+ {t('stats.unlocked')} <b>{unlockedCount}</b> / {achievements.length} {t('stats.trophies')}
86
+ </div>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ function AchievementCard({ data }: { data: AchievementState }) {
92
+ const t = useT();
93
+ const unlocked = data.unlockedAt != null;
94
+
95
+ return (
96
+ <div className={`ach-card ${unlocked ? 'unlocked' : ''}`} title={unlocked ? t('stats.unlockedStatus') : t('stats.locked')}>
97
+ <div className="ach-card-head">
98
+ <span className="ach-card-icon">{unlocked ? data.def.icon : '🔒'}</span>
99
+ <span className="ach-card-status">{unlocked ? t('stats.unlockedStatus') : t('stats.locked')}</span>
100
+ </div>
101
+ <div className="ach-card-name">{unlocked ? t(`ach.${idToKey(data.def.id)}.name`) : t('stats.hidden')}</div>
102
+ {unlocked && (
103
+ <div className="ach-card-desc">{t(`ach.${idToKey(data.def.id)}.desc`)}</div>
104
+ )}
105
+ {unlocked && data.unlockedAt && (
106
+ <div className="ach-card-time">
107
+ {new Date(data.unlockedAt).toLocaleDateString(currentLocale(), { year: 'numeric', month: '2-digit', day: '2-digit' })} {t('stats.unlockedAt')}
108
+ </div>
109
+ )}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function StatCards({ data }: { data: StatsData }) {
115
+ const t = useT();
116
+ const totalTokens = data.inputTokens + data.outputTokens;
117
+
118
+ return (
119
+ <>
120
+ <div className="stat-grid">
121
+ <div className="stat-card">
122
+ <div className="v">{totalTokens.toLocaleString()}</div>
123
+ <div className="k">{t('stats.totalTokens')}</div>
124
+ </div>
125
+ <div className="stat-card">
126
+ <div className="v">{data.inputTokens.toLocaleString()}</div>
127
+ <div className="k">{t('stats.inputTokens')}</div>
128
+ </div>
129
+ <div className="stat-card">
130
+ <div className="v">{data.outputTokens.toLocaleString()}</div>
131
+ <div className="k">{t('stats.outputTokens')}</div>
132
+ </div>
133
+ <div className="stat-card">
134
+ <div className="v">{data.totalRounds}</div>
135
+ <div className="k">{t('stats.rounds')}</div>
136
+ </div>
137
+ <div className="stat-card">
138
+ <div className="v">{data.sessionCount}</div>
139
+ <div className="k">{t('stats.sessions')}</div>
140
+ </div>
141
+ <div className="stat-card">
142
+ <div className="v">{data.totalRounds && data.sessionCount ? (data.totalRounds / data.sessionCount).toFixed(1) : '0'}</div>
143
+ <div className="k">{t('stats.avgRounds')}</div>
144
+ </div>
145
+ </div>
146
+
147
+ <div className="set-section">
148
+ <h3>{t('stats.mostActive')}</h3>
149
+ {data.npcStats.length > 0 ? (
150
+ <>
151
+ {(() => {
152
+ const mostActive = [...data.npcStats].sort((a, b) => b.rounds - a.rounds)[0];
153
+ return (
154
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
155
+ <div style={{ fontSize: 26 }}>🎭</div>
156
+ <div>
157
+ <div style={{ fontWeight: 800 }}>{mostActive.npcName}</div>
158
+ <div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('stats.roundsCount', { n: mostActive.rounds })}</div>
159
+ </div>
160
+ </div>
161
+ );
162
+ })()}
163
+ </>
164
+ ) : (
165
+ <div style={{ color: 'var(--text-faint)', fontSize: 13 }}>{t('stats.noData')}</div>
166
+ )}
167
+ </div>
168
+
169
+ {data.npcStats.length > 0 && (
170
+ <div className="set-section">
171
+ <h3>{t('stats.ranking')}</h3>
172
+ <table className="table">
173
+ <thead>
174
+ <tr><th>{t('stats.colChar')}</th><th>{t('stats.colRounds')}</th></tr>
175
+ </thead>
176
+ <tbody>
177
+ {[...data.npcStats].sort((a, b) => b.rounds - a.rounds).map((n) => (
178
+ <tr key={n.npcId}>
179
+ <td>{n.npcName}</td>
180
+ <td>{n.rounds}</td>
181
+ </tr>
182
+ ))}
183
+ </tbody>
184
+ </table>
185
+ </div>
186
+ )}
187
+ </>
188
+ );
189
+ }
@@ -0,0 +1,20 @@
1
+ import { useStore } from '../store/store';
2
+
3
+ // ============================================================
4
+ // Toast 通知
5
+ // ============================================================
6
+
7
+ export function Toasts() {
8
+ const toasts = useStore((s) => s.toasts);
9
+ const remove = useStore((s) => s.removeToast);
10
+
11
+ return (
12
+ <div className="toast-wrap">
13
+ {toasts.map((t) => (
14
+ <div key={t.id} className={`toast ${t.kind === 'error' ? 'err' : ''}`} onClick={() => remove(t.id)}>
15
+ {t.kind === 'error' ? '⚠️ ' : '✓ '}{t.message}
16
+ </div>
17
+ ))}
18
+ </div>
19
+ );
20
+ }