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
|
@@ -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}
|
|
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
|
-
<
|
|
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)}
|
|
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
|
-
<
|
|
385
|
-
<div className="modal-
|
|
386
|
-
<
|
|
387
|
-
|
|
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
|
-
|
|
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={
|
|
287
|
+
onClick={returnHome}
|
|
261
288
|
onKeyDown={(e) => {
|
|
262
289
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
263
290
|
e.preventDefault();
|
|
264
|
-
|
|
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)}
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
45
|
-
<p
|
|
58
|
+
<h3>{t('stats.resetTitle')}</h3>
|
|
59
|
+
<p>{t('stats.resetDesc')}</p>
|
|
46
60
|
</div>
|
|
47
61
|
<button
|
|
48
|
-
className="btn btn-
|
|
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">
|
|
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}
|
|
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}
|
|
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
|
-
|
|
16
|
+
onClose: _onClose,
|
|
17
|
+
size = 'standard',
|
|
15
18
|
className,
|
|
16
19
|
children,
|
|
17
20
|
}: {
|
|
18
21
|
onClose: () => void;
|
|
19
|
-
|
|
22
|
+
size?: ModalSize;
|
|
20
23
|
className?: string;
|
|
21
24
|
children: React.ReactNode;
|
|
22
25
|
}) {
|
|
23
26
|
return createPortal(
|
|
24
27
|
<>
|
|
25
|
-
<div className="overlay"
|
|
28
|
+
<div className="overlay" />
|
|
26
29
|
<div className="modal-root" onClick={(e) => e.stopPropagation()}>
|
|
27
|
-
<div className={`modal card fade-up ${className ?? ''}`.trim()}
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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}` : ''}`;
|
package/src/core/achievements.ts
CHANGED
|
@@ -8,8 +8,10 @@ export interface AchievementDef {
|
|
|
8
8
|
id: string;
|
|
9
9
|
name: string;
|
|
10
10
|
icon: string;
|
|
11
|
-
/**
|
|
12
|
-
|
|
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 {
|