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.
Files changed (40) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/index.html +1 -1
  3. package/package.json +1 -1
  4. package/persistence-server.mjs +54 -2
  5. package/src/App.tsx +91 -25
  6. package/src/components/AchievementModal.tsx +4 -2
  7. package/src/components/CharactersView.tsx +53 -25
  8. package/src/components/ChatView.tsx +264 -135
  9. package/src/components/Dashboard.tsx +12 -8
  10. package/src/components/DeleteConfirmDialog.tsx +1 -1
  11. package/src/components/FileDisplayModal.tsx +1 -1
  12. package/src/components/GameplayDialogs.tsx +2 -2
  13. package/src/components/NewSessionMenu.tsx +1 -1
  14. package/src/components/SettingsView.tsx +46 -29
  15. package/src/components/Sidebar.tsx +30 -3
  16. package/src/components/StatsView.tsx +55 -45
  17. package/src/components/VersionHistoryModal.tsx +1 -1
  18. package/src/components/WorkspaceFileManagerModal.tsx +1 -1
  19. package/src/components/shared.tsx +84 -24
  20. package/src/core/achievements.ts +22 -4
  21. package/src/core/avatarThumbnail.ts +245 -0
  22. package/src/core/characterBook.ts +2 -1
  23. package/src/core/i18n.ts +42 -6
  24. package/src/core/mobileViewport.ts +18 -3
  25. package/src/core/openai.ts +41 -19
  26. package/src/core/randomId.ts +19 -0
  27. package/src/core/rowFingerprint.ts +75 -0
  28. package/src/core/stats.ts +50 -5
  29. package/src/core/stickyBottom.ts +10 -1
  30. package/src/core/textPreview.ts +16 -0
  31. package/src/db/database.ts +101 -13
  32. package/src/generated/changelog.json +87 -1
  33. package/src/main.tsx +12 -21
  34. package/src/store/store.ts +179 -53
  35. package/src/theme/chat.css +62 -71
  36. package/src/theme/global.css +85 -21
  37. package/src/theme/theme.ts +13 -10
  38. package/src/theme/views.css +82 -5
  39. package/src/types/models.ts +2 -0
  40. package/vite.config.ts +8 -2
@@ -153,7 +153,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
153
153
  const canCreate = selectedNpcIds.length > 0;
154
154
 
155
155
  return (
156
- <Modal onClose={onClose} width={460} className="new-session-modal">
156
+ <Modal onClose={onClose} className="new-session-modal">
157
157
  <div className="modal-head">
158
158
  <span style={{ fontWeight: 800, fontSize: 15 }}>{isEditing ? t('nav.editSession') : t('newSession.title')}</span>
159
159
  <button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
@@ -1,6 +1,6 @@
1
- import { useState } from 'react';
1
+ import { memo, useState } from 'react';
2
2
  import { useStore } from '../store/store';
3
- import { db } from '../db/database';
3
+ import { db, DEFAULT_GENERATION_SETTINGS } from '../db/database';
4
4
  import { type ThemeMode } from '../theme/theme';
5
5
  import { type ApiProvider, type AppLanguage, type ReasoningEffort, enabledModelIds, modelAliases } from '../types/models';
6
6
  import { Icon, Modal } from './shared';
@@ -14,9 +14,10 @@ import { VersionHistoryModal } from './VersionHistoryModal';
14
14
  // 设置视图(模型服务 / 超参数 / 主题)
15
15
  // ============================================================
16
16
 
17
- export function SettingsView() {
17
+ export const SettingsView = memo(function SettingsView() {
18
18
  const settings = useStore((s) => s.settings);
19
19
  const setSettings = useStore((s) => s.setSettings);
20
+ const addToast = useStore((s) => s.addToast);
20
21
  const resetTavern = useStore((s) => s.resetTavern);
21
22
  const providers = useStore((s) => s.providers);
22
23
  const refreshProviders = useStore((s) => s.refreshProviders);
@@ -43,6 +44,15 @@ export function SettingsView() {
43
44
  </label>
44
45
  );
45
46
 
47
+ const generationDefaults = DEFAULT_GENERATION_SETTINGS;
48
+ const isGenerationDefault = (Object.keys(generationDefaults) as Array<keyof typeof generationDefaults>)
49
+ .every((key) => settings[key] === generationDefaults[key]);
50
+
51
+ const resetGenerationParams = async () => {
52
+ await setSettings({ ...generationDefaults });
53
+ addToast(t('toast.genParamsReset'));
54
+ };
55
+
46
56
  return (
47
57
  <div className="view-page">
48
58
  <div className="view-col">
@@ -76,7 +86,18 @@ export function SettingsView() {
76
86
 
77
87
  {/* 生成参数 */}
78
88
  <div className="set-section">
79
- <h3>{t('settings.genParams')}</h3>
89
+ <div className="settings-section-head">
90
+ <h3>{t('settings.genParams')}</h3>
91
+ <button
92
+ className="btn btn-sm settings-gen-reset"
93
+ title={t('settings.genParamsResetTitle')}
94
+ aria-label={t('settings.genParamsResetTitle')}
95
+ disabled={isGenerationDefault}
96
+ onClick={() => void resetGenerationParams()}
97
+ >
98
+ <Icon name="refresh" size={12} /> {t('settings.genParamsReset')}
99
+ </button>
100
+ </div>
80
101
  <table className="table param-table">
81
102
  <tbody>
82
103
  <tr>
@@ -229,7 +250,7 @@ export function SettingsView() {
229
250
  {showVersionHistory && <VersionHistoryModal onClose={() => setShowVersionHistory(false)} />}
230
251
 
231
252
  {showResetConfirm && (
232
- <Modal onClose={() => !resetting && setShowResetConfirm(false)} width="min(420px, calc(100vw - 32px))">
253
+ <Modal onClose={() => !resetting && setShowResetConfirm(false)} size="compact">
233
254
  <div className="modal-head">
234
255
  <span style={{ fontWeight: 800, fontSize: 15 }}>{t('settings.resetConfirmTitle')}</span>
235
256
  <button className="icon-btn" disabled={resetting} onClick={() => setShowResetConfirm(false)}><Icon name="x" /></button>
@@ -257,7 +278,7 @@ export function SettingsView() {
257
278
  )}
258
279
  </div>
259
280
  );
260
- }
281
+ });
261
282
 
262
283
  // ---------------- Providers ----------------
263
284
 
@@ -381,30 +402,26 @@ function ProviderManager({ providers, onChanged }: { providers: ApiProvider[]; o
381
402
  </div>
382
403
 
383
404
  {editing && (
384
- <div className="overlay" onClick={() => setEditing(null)}>
385
- <div className="modal-root" onClick={(e) => e.stopPropagation()}>
386
- <div className="modal card">
387
- <div className="modal-head">
388
- <span style={{ fontWeight: 800, fontSize: 15 }}>{editing.id != null ? t('settings.editProvider') : t('settings.addProvider')}</span>
389
- <button className="icon-btn" onClick={() => setEditing(null)}><Icon name="x" /></button>
390
- </div>
391
- <ProviderForm
392
- initial={editing}
393
- onSave={async (data) => {
394
- if (editing.id != null) {
395
- await db.providers.update(editing.id, data);
396
- addToast(t('toast.providerUpdated'));
397
- } else {
398
- await db.providers.add(data);
399
- addToast(t('toast.providerAdded'));
400
- }
401
- setEditing(null);
402
- onChanged();
403
- }}
404
- />
405
- </div>
405
+ <Modal onClose={() => setEditing(null)}>
406
+ <div className="modal-head">
407
+ <span style={{ fontWeight: 800, fontSize: 15 }}>{editing.id != null ? t('settings.editProvider') : t('settings.addProvider')}</span>
408
+ <button className="icon-btn" onClick={() => setEditing(null)}><Icon name="x" /></button>
406
409
  </div>
407
- </div>
410
+ <ProviderForm
411
+ initial={editing}
412
+ onSave={async (data) => {
413
+ if (editing.id != null) {
414
+ await db.providers.update(editing.id, data);
415
+ addToast(t('toast.providerUpdated'));
416
+ } else {
417
+ await db.providers.add(data);
418
+ addToast(t('toast.providerAdded'));
419
+ }
420
+ setEditing(null);
421
+ onChanged();
422
+ }}
423
+ />
424
+ </Modal>
408
425
  )}
409
426
 
410
427
  {pendingDelete && (
@@ -12,8 +12,11 @@ import { useTouchLongPress } from '../core/longPress';
12
12
  import { fixedViewportPoint } from '../core/viewport';
13
13
  import { useHasUnseenVersion } from '../core/version';
14
14
  import { PopoverMenu } from './PopoverMenu';
15
+ import { unlockAchievement } from '../core/achievements';
15
16
 
16
17
  const SIDEBAR_MIN = 240;
18
+ const SHAREHOLDER_CLICK_COUNT = 5;
19
+ const SHAREHOLDER_CLICK_WINDOW_MS = 5_000;
17
20
  const SIDEBAR_MAX = 520;
18
21
 
19
22
  /** 触发 App 级全局游戏导出弹窗(会话右键菜单共用) */
@@ -50,6 +53,7 @@ export function Sidebar() {
50
53
  // 群聊会话 → 参与者中的 NPC 角色
51
54
  const [groupMembers, setGroupMembers] = useState<Record<number, NpcCharacter[]>>({});
52
55
  const sessionLongPressTarget = useRef<ChatSession | null>(null);
56
+ const homeClickTimes = useRef<number[]>([]);
53
57
  const sessionLongPressProps = useTouchLongPress(
54
58
  (point) => {
55
59
  const session = sessionLongPressTarget.current;
@@ -221,6 +225,20 @@ export function Sidebar() {
221
225
  colorOrdinal: n.avatarColorOrdinal,
222
226
  imageUrl: n.avatarDataUrl,
223
227
  }));
228
+
229
+ const returnHome = () => {
230
+ const now = Date.now();
231
+ homeClickTimes.current = [...homeClickTimes.current, now]
232
+ .filter((time) => now - time <= SHAREHOLDER_CLICK_WINDOW_MS)
233
+ .slice(-SHAREHOLDER_CLICK_COUNT);
234
+ if (homeClickTimes.current.length === SHAREHOLDER_CLICK_COUNT) {
235
+ homeClickTimes.current = [];
236
+ void unlockAchievement('th-shareholder').then(() => useStore.getState().refreshAchievements());
237
+ }
238
+ setActiveSession(null);
239
+ setActiveView('chat');
240
+ };
241
+
224
242
  return (
225
243
  <>
226
244
  <div className="mobile-topbar">
@@ -234,6 +252,15 @@ export function Sidebar() {
234
252
  <Icon name={mobileOpen ? 'x' : 'menu'} size={22} />
235
253
  </button>
236
254
  <nav className="mobile-topbar-nav" aria-label={t('nav.chat')}>
255
+ <button
256
+ className="mobile-home-button"
257
+ type="button"
258
+ title={t('nav.home')}
259
+ aria-label={t('nav.home')}
260
+ onClick={returnHome}
261
+ >
262
+ <img src="/tavern-harness-logo.png" alt="" />
263
+ </button>
237
264
  <button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => setActiveView('chat')}>
238
265
  <Icon name="send" size={15} /> <span>{t('nav.chat')}</span>
239
266
  </button>
@@ -257,11 +284,11 @@ export function Sidebar() {
257
284
  role="button"
258
285
  tabIndex={0}
259
286
  title={t('nav.home')}
260
- onClick={() => setActiveSession(null)}
287
+ onClick={returnHome}
261
288
  onKeyDown={(e) => {
262
289
  if (e.key === 'Enter' || e.key === ' ') {
263
290
  e.preventDefault();
264
- setActiveSession(null);
291
+ returnHome();
265
292
  }
266
293
  }}
267
294
  >
@@ -436,7 +463,7 @@ export function Sidebar() {
436
463
 
437
464
  {/* 删除会话确认弹窗 */}
438
465
  {confirmDelete && (
439
- <Modal onClose={() => setConfirmDelete(null)} width="min(400px, calc(100vw - 32px))">
466
+ <Modal onClose={() => setConfirmDelete(null)} size="compact">
440
467
  <div className="modal-head">
441
468
  <span style={{ fontWeight: 800, fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>
442
469
  <Icon name="trash" size={15} /> {t('nav.deleteSession')}
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react';
1
+ import { memo, useCallback, useEffect, useState } from 'react';
2
2
  import { useStore } from '../store/store';
3
3
  import { getCareerStats, resetCareerStats } from '../core/stats';
4
4
  import { Icon } from './shared';
@@ -18,19 +18,22 @@ interface StatsData {
18
18
  npcStats: Array<{ npcId: number; npcName: string; rounds: number }>;
19
19
  }
20
20
 
21
- export function StatsView() {
21
+ export const StatsView = memo(function StatsView({ active = true }: { active?: boolean }) {
22
22
  const addToast = useStore((s) => s.addToast);
23
23
  const t = useT();
24
24
  const [data, setData] = useState<StatsData | null>(null);
25
25
 
26
- const refresh = async () => {
26
+ const refresh = useCallback(async () => {
27
27
  const d = await getCareerStats();
28
28
  setData(d);
29
- };
29
+ }, []);
30
30
 
31
+ // 视图常驻不卸载(见 App.tsx 的 ViewSlot),所以每次重新显示时刷新一次,
32
+ // 否则聊完天回来看生涯统计会看到旧数字。
31
33
  useEffect(() => {
32
- refresh();
33
- }, []);
34
+ if (!active) return;
35
+ void refresh();
36
+ }, [active, refresh]);
34
37
 
35
38
  if (!data) {
36
39
  return <div className="view-page"><div className="spinner" /></div>;
@@ -39,13 +42,24 @@ export function StatsView() {
39
42
  return (
40
43
  <div className="view-page">
41
44
  <div className="view-col">
42
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
45
+ <div>
46
+ <h2 className="view-title">{t('stats.title')}</h2>
47
+ <p className="view-sub">{t('stats.sub')}</p>
48
+ </div>
49
+
50
+ {/* 成就陈列柜 */}
51
+ <AchievementShelf />
52
+
53
+ {/* 生涯统计 */}
54
+ <StatCards data={data} />
55
+
56
+ <div className="stats-reset-card">
43
57
  <div>
44
- <h2 className="view-title">{t('stats.title')}</h2>
45
- <p className="view-sub">{t('stats.sub')}</p>
58
+ <h3>{t('stats.resetTitle')}</h3>
59
+ <p>{t('stats.resetDesc')}</p>
46
60
  </div>
47
61
  <button
48
- className="btn btn-sm btn-danger"
62
+ className="btn btn-danger settings-reset-button"
49
63
  onClick={async () => {
50
64
  if (!confirm(t('stats.resetConfirm'))) return;
51
65
  await resetCareerStats();
@@ -56,16 +70,10 @@ export function StatsView() {
56
70
  <Icon name="refresh" size={12} /> {t('stats.resetBtn')}
57
71
  </button>
58
72
  </div>
59
-
60
- {/* 成就陈列柜 */}
61
- <AchievementShelf />
62
-
63
- {/* 生涯统计 */}
64
- <StatCards data={data} />
65
73
  </div>
66
74
  </div>
67
75
  );
68
- }
76
+ });
69
77
 
70
78
  /** 成就陈列柜:已解锁的奖杯点亮,未解锁的只展示锁定状态 */
71
79
  function AchievementShelf() {
@@ -95,7 +103,9 @@ function AchievementCard({ data }: { data: AchievementState }) {
95
103
  return (
96
104
  <div className={`ach-card ${unlocked ? 'unlocked' : ''}`} title={unlocked ? t('stats.unlockedStatus') : t('stats.locked')}>
97
105
  <div className="ach-card-head">
98
- <span className="ach-card-icon">{unlocked ? data.def.icon : '🔒'}</span>
106
+ <span className="ach-card-icon">
107
+ {unlocked && data.def.iconUrl ? <img className="ach-card-image" src={data.def.iconUrl} alt="" /> : unlocked ? data.def.icon : '🔒'}
108
+ </span>
99
109
  <span className="ach-card-status">{unlocked ? t('stats.unlockedStatus') : t('stats.locked')}</span>
100
110
  </div>
101
111
  <div className="ach-card-name">{unlocked ? t(`ach.${idToKey(data.def.id)}.name`) : t('stats.hidden')}</div>
@@ -117,33 +127,6 @@ function StatCards({ data }: { data: StatsData }) {
117
127
 
118
128
  return (
119
129
  <>
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
130
  <div className="set-section">
148
131
  <h3>{t('stats.mostActive')}</h3>
149
132
  {data.npcStats.length > 0 ? (
@@ -184,6 +167,33 @@ function StatCards({ data }: { data: StatsData }) {
184
167
  </table>
185
168
  </div>
186
169
  )}
170
+
171
+ <div className="stat-grid">
172
+ <div className="stat-card">
173
+ <div className="v">{totalTokens.toLocaleString()}</div>
174
+ <div className="k">{t('stats.totalTokens')}</div>
175
+ </div>
176
+ <div className="stat-card">
177
+ <div className="v">{data.inputTokens.toLocaleString()}</div>
178
+ <div className="k">{t('stats.inputTokens')}</div>
179
+ </div>
180
+ <div className="stat-card">
181
+ <div className="v">{data.outputTokens.toLocaleString()}</div>
182
+ <div className="k">{t('stats.outputTokens')}</div>
183
+ </div>
184
+ <div className="stat-card">
185
+ <div className="v">{data.totalRounds}</div>
186
+ <div className="k">{t('stats.rounds')}</div>
187
+ </div>
188
+ <div className="stat-card">
189
+ <div className="v">{data.sessionCount}</div>
190
+ <div className="k">{t('stats.sessions')}</div>
191
+ </div>
192
+ <div className="stat-card">
193
+ <div className="v">{data.totalRounds && data.sessionCount ? (data.totalRounds / data.sessionCount).toFixed(1) : '0'}</div>
194
+ <div className="k">{t('stats.avgRounds')}</div>
195
+ </div>
196
+ </div>
187
197
  </>
188
198
  );
189
199
  }
@@ -91,7 +91,7 @@ export function VersionHistoryModal({ onClose }: { onClose: () => void }) {
91
91
  }, []);
92
92
 
93
93
  return (
94
- <Modal onClose={onClose} width="min(720px, calc(100vw - 24px))">
94
+ <Modal onClose={onClose} size="wide">
95
95
  <div className="modal-head">
96
96
  <span style={{ fontWeight: 800, fontSize: 15 }}>{t('changelog.title')}</span>
97
97
  <button className="icon-btn" onClick={onClose} aria-label={t('common.close')}>
@@ -126,7 +126,7 @@ export function WorkspaceFileManagerModal({ sessionId, onClose }: { sessionId: n
126
126
  };
127
127
 
128
128
  return (
129
- <Modal onClose={onClose} width="min(640px, calc(100vw - 32px))" className="ws-fm-modal">
129
+ <Modal onClose={onClose} className="ws-fm-modal">
130
130
  <div className="modal-head ws-fm-head">
131
131
  <span className="ws-fm-title">
132
132
  <Icon name="folder" size={15} />
@@ -1,30 +1,33 @@
1
- import { Children, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
1
+ import { Children, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import DOMPurify from 'dompurify';
4
4
  import { renderMarkdown, highlightMentions } from '../core/markdown';
5
5
  import { useT } from '../core/i18n';
6
+ import { useAvatarImageUrl } from '../core/avatarThumbnail';
6
7
 
7
8
  // ============================================================
8
9
  // 通用弹窗容器(Portal 到 body,避开祖先 backdrop-filter 产生的
9
10
  // 包含块,保证全页面居中、遮罩覆盖全屏、点击可用)
10
11
  // ============================================================
11
12
 
13
+ type ModalSize = 'compact' | 'standard' | 'wide';
14
+
12
15
  export function Modal({
13
- onClose,
14
- width,
16
+ onClose: _onClose,
17
+ size = 'standard',
15
18
  className,
16
19
  children,
17
20
  }: {
18
21
  onClose: () => void;
19
- width?: string | number;
22
+ size?: ModalSize;
20
23
  className?: string;
21
24
  children: React.ReactNode;
22
25
  }) {
23
26
  return createPortal(
24
27
  <>
25
- <div className="overlay" onClick={onClose} />
28
+ <div className="overlay" />
26
29
  <div className="modal-root" onClick={(e) => e.stopPropagation()}>
27
- <div className={`modal card fade-up ${className ?? ''}`.trim()} style={width != null ? { width } : undefined}>
30
+ <div className={`modal modal-size-${size} card fade-up ${className ?? ''}`.trim()}>
28
31
  {children}
29
32
  </div>
30
33
  </div>
@@ -43,15 +46,60 @@ interface MdProps {
43
46
  mentionNames?: string[];
44
47
  }
45
48
 
49
+ /** LRU cache for sanitized Markdown. */
50
+ const MARKDOWN_CACHE_LIMIT = 500;
51
+ const MARKDOWN_CACHE_MAX_CHARS = 8 * 1024 * 1024;
52
+ const MARKDOWN_CACHE_MAX_TEXT = 100_000;
53
+ const markdownCache = new Map<string, string>();
54
+ let markdownCacheChars = 0;
55
+ const MENTION_SEPARATOR = '\u0000';
56
+
57
+ function trimMarkdownCache(): void {
58
+ while (
59
+ (markdownCache.size > MARKDOWN_CACHE_LIMIT || markdownCacheChars > MARKDOWN_CACHE_MAX_CHARS)
60
+ && markdownCache.size > 1
61
+ ) {
62
+ const oldest = markdownCache.keys().next().value;
63
+ if (oldest == null) break;
64
+ const html = markdownCache.get(oldest);
65
+ if (html != null) markdownCacheChars -= oldest.length + html.length;
66
+ markdownCache.delete(oldest);
67
+ }
68
+ }
69
+
70
+ function renderSanitizedMarkdown(text: string, mathEnabled: boolean, mentionKey: string): string {
71
+ const key = `${mathEnabled ? 1 : 0}\u0001${mentionKey}\u0001${text}`;
72
+ const cached = markdownCache.get(key);
73
+ if (cached != null) {
74
+ // 刷新 LRU 顺序。
75
+ markdownCache.delete(key);
76
+ markdownCache.set(key, cached);
77
+ return cached;
78
+ }
79
+ let html = renderMarkdown(text, { mathEnabled });
80
+ const mentionNames = mentionKey ? mentionKey.split(MENTION_SEPARATOR) : [];
81
+ if (mentionNames.length > 0) html = highlightMentions(html, mentionNames);
82
+ const sanitized = DOMPurify.sanitize(html, {
83
+ USE_PROFILES: { html: true },
84
+ ALLOW_UNKNOWN_PROTOCOLS: false,
85
+ });
86
+ // 超长文本(例如大段工具输出)只渲染不缓存:重新解析一次比长期占内存划算。
87
+ if (text.length <= MARKDOWN_CACHE_MAX_TEXT) {
88
+ markdownCache.set(key, sanitized);
89
+ markdownCacheChars += key.length + sanitized.length;
90
+ trimMarkdownCache();
91
+ }
92
+ return sanitized;
93
+ }
94
+
46
95
  export function Markdown({ text, mathEnabled = true, mentionNames = [] }: MdProps) {
47
- const html = useMemo(() => {
48
- let h = renderMarkdown(text, { mathEnabled });
49
- if (mentionNames.length > 0) h = highlightMentions(h, mentionNames);
50
- return DOMPurify.sanitize(h, {
51
- USE_PROFILES: { html: true },
52
- ALLOW_UNKNOWN_PROTOCOLS: false,
53
- });
54
- }, [text, mathEnabled, mentionNames]);
96
+ // 调用方每次渲染都会新建 mentionNames 数组,直接用数组做依赖会让缓存永远失效;
97
+ // 这里把它折叠成内容键(成员名按调用方顺序拼接)。
98
+ const mentionKey = mentionNames.join(MENTION_SEPARATOR);
99
+ const html = useMemo(
100
+ () => renderSanitizedMarkdown(text, mathEnabled, mentionKey),
101
+ [text, mathEnabled, mentionKey],
102
+ );
55
103
 
56
104
  return <div className="md" dangerouslySetInnerHTML={{ __html: html }} />;
57
105
  }
@@ -76,6 +124,7 @@ export function MasonryGrid({ children }: { children: React.ReactNode }) {
76
124
  const updateColumnCount = useCallback(() => {
77
125
  const grid = gridRef.current;
78
126
  if (!grid) return;
127
+ if (grid.clientWidth === 0) return;
79
128
  const styles = getComputedStyle(grid);
80
129
  const minColumnWidth = Number.parseFloat(styles.getPropertyValue('--masonry-column-min-width'));
81
130
  const gap = Number.parseFloat(styles.getPropertyValue('--masonry-gap'));
@@ -95,6 +144,7 @@ export function MasonryGrid({ children }: { children: React.ReactNode }) {
95
144
  useLayoutEffect(() => {
96
145
  const grid = gridRef.current;
97
146
  if (!grid) return;
147
+ if (grid.clientWidth === 0) return;
98
148
  const gap = Number.parseFloat(getComputedStyle(grid).getPropertyValue('--masonry-gap'));
99
149
  const heights = Array.from({ length: columnCount }, () => 0);
100
150
  const nextLayout = Array.from({ length: columnCount }, () => [] as number[]);
@@ -142,7 +192,13 @@ export function MasonryGrid({ children }: { children: React.ReactNode }) {
142
192
  // 头像(彩色首字母 / 图片)
143
193
  // ============================================================
144
194
 
145
- export function Avatar({
195
+ /** Convert an image URL to a CSS `url()` value. */
196
+ function cssImageUrl(url: string): string {
197
+ return /^data:[^,]*;base64,/i.test(url) ? `url("${url}")` : `url(${JSON.stringify(url)})`;
198
+ }
199
+
200
+ /** Avatar with image-thumbnail support. */
201
+ export const Avatar = memo(function Avatar({
146
202
  name,
147
203
  colorOrdinal,
148
204
  imageUrl,
@@ -155,7 +211,8 @@ export function Avatar({
155
211
  }) {
156
212
  const cls = size === 'lg' ? 'avatar' : size === 'xs' ? 'avatar xs' : 'avatar sm';
157
213
  const hue = (colorOrdinal ?? 0) % 6;
158
- if (imageUrl) {
214
+ const displayUrl = useAvatarImageUrl(imageUrl);
215
+ if (displayUrl) {
159
216
  // 用背景图而不是 <img>:微信 Android 内置浏览器会对 <img> 的长按弹出
160
217
  // “发送给朋友 / 保存图片”。命中测试落在宿主 div 上就不会触发原生菜单;
161
218
  // pointer-events: none 再让点击/滚动继续冒泡给外层(见 global.css)。
@@ -164,12 +221,12 @@ export function Avatar({
164
221
  className={`${cls} avatar-image`}
165
222
  role="img"
166
223
  aria-label={name}
167
- style={{ backgroundImage: `url(${JSON.stringify(imageUrl)})` }}
224
+ style={{ backgroundImage: cssImageUrl(displayUrl) }}
168
225
  />
169
226
  );
170
227
  }
171
228
  return <div className={`${cls} avatar-hue-${hue}`}>{name.slice(0, 1)}</div>;
172
- }
229
+ });
173
230
 
174
231
  // ============================================================
175
232
  // 附件卡片:仅展示文件名(去掉图片/视频缩略图)
@@ -285,13 +342,16 @@ export function Collapse({
285
342
 
286
343
  useEffect(() => {
287
344
  const el = contentRef.current;
288
- if (el) {
289
- setOverflow(el.scrollHeight > el.clientHeight + 4);
345
+ if (!el) return;
346
+ const measure = () => {
347
+ if (el.clientHeight > 0) setOverflow(el.scrollHeight > el.clientHeight + 4);
290
348
  // 流式进行中:实时跟随最新内容(钉在底部,展示最新的一行/一段)
291
- if (live && open) {
292
- el.scrollTop = el.scrollHeight;
293
- }
294
- }
349
+ if (live && open) el.scrollTop = el.scrollHeight;
350
+ };
351
+ measure();
352
+ const observer = new ResizeObserver(measure);
353
+ observer.observe(el);
354
+ return () => observer.disconnect();
295
355
  }, [preview, children, live, open]);
296
356
 
297
357
  const cls = `collp ${accent !== 'default' ? `collp-${accent}` : ''}`;
@@ -8,8 +8,10 @@ export interface AchievementDef {
8
8
  id: string;
9
9
  name: string;
10
10
  icon: string;
11
- /** 达成所需累计 Token 数(输入 + 输出) */
12
- threshold: number;
11
+ /** 应用图标类成就使用的图片地址。 */
12
+ iconUrl?: string;
13
+ /** 达成所需累计 Token 数(输入 + 输出);手动触发的彩蛋成就不设置此项。 */
14
+ threshold?: number;
13
15
  description: string;
14
16
  }
15
17
 
@@ -19,6 +21,7 @@ export const ACHIEVEMENTS: AchievementDef[] = [
19
21
  { id: 'bard', name: '吟游诗人', icon: '🎻', threshold: 100_000, description: '累计使用 100,000 Token' },
20
22
  { id: 'night-owl', name: '午夜不归人', icon: '🌙', threshold: 1_000_000, description: '累计使用 1,000,000 Token' },
21
23
  { id: 'legend', name: '酒馆传奇', icon: '🏆', threshold: 100_000_000, description: '累计使用 100,000,000 Token' },
24
+ { id: 'th-shareholder', name: 'TH股东', icon: '📈', iconUrl: '/tavern-harness-logo.png', description: '连续点击首页图标 5 次' },
22
25
  ];
23
26
 
24
27
  /** 生涯总 token(输入 + 输出) */
@@ -44,6 +47,21 @@ export function registerUnlockDispatcher(
44
47
  unlockDispatcher.push = dispatcher;
45
48
  }
46
49
 
50
+ /** 解锁指定成就(幂等),用于不依赖 Token 门槛的彩蛋成就。 */
51
+ export async function unlockAchievement(id: string): Promise<void> {
52
+ const def = ACHIEVEMENTS.find((achievement) => achievement.id === id);
53
+ if (!def) return;
54
+ const exists = await db.achievementUnlocks.where('achievementId').equals(id).first();
55
+ if (exists) return;
56
+
57
+ await db.achievementUnlocks.add({ achievementId: id, unlockedAt: Date.now() });
58
+ try {
59
+ await unlockDispatcher.push(def, await getTotalTokens());
60
+ } catch {
61
+ // 忽略 UI 展示层的错误,不影响成就记录。
62
+ }
63
+ }
64
+
47
65
  let checkLock: Promise<void> | null = null;
48
66
 
49
67
  /**
@@ -61,7 +79,7 @@ export async function checkAchievementUnlocks(): Promise<void> {
61
79
  const newly: Array<{ def: AchievementDef; total: number }> = [];
62
80
 
63
81
  for (const def of ACHIEVEMENTS) {
64
- if (unlocked.has(def.id)) continue;
82
+ if (unlocked.has(def.id) || def.threshold == null) continue;
65
83
  if (total >= def.threshold) {
66
84
  await db.achievementUnlocks.add({ achievementId: def.id, unlockedAt: Date.now() });
67
85
  newly.push({ def, total });
@@ -69,7 +87,7 @@ export async function checkAchievementUnlocks(): Promise<void> {
69
87
  }
70
88
  if (newly.length > 0) {
71
89
  // 由高到低依次展示(若多级同时达成)
72
- for (const { def, total } of newly.sort((a, b) => b.def.threshold - a.def.threshold)) {
90
+ for (const { def, total } of newly.sort((a, b) => (b.def.threshold ?? 0) - (a.def.threshold ?? 0))) {
73
91
  try {
74
92
  await unlockDispatcher.push(def, total);
75
93
  } catch {