tavern-harness 0.1.12 → 0.1.13
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 +345 -0
- package/README.md +12 -4
- package/README.zh.md +12 -4
- package/bin/cli.js +16 -2
- package/index.html +3 -1
- package/package.json +18 -5
- package/persistence-server.mjs +303 -0
- package/public/sillytavern-logo.png +0 -0
- package/src/App.tsx +72 -8
- package/src/components/CharactersView.tsx +221 -68
- package/src/components/ChatView.tsx +98 -69
- package/src/components/FileDisplayModal.tsx +28 -17
- package/src/components/NewSessionMenu.tsx +2 -9
- package/src/components/PopoverMenu.tsx +54 -0
- package/src/components/SettingsView.tsx +21 -1
- package/src/components/Sidebar.tsx +133 -53
- package/src/components/VersionHistoryModal.tsx +114 -0
- package/src/components/shared.tsx +12 -3
- package/src/core/browserPreferences.ts +45 -0
- package/src/core/changelog.ts +43 -0
- package/src/core/characterBook.ts +32 -0
- package/src/core/clipboard.ts +32 -0
- package/src/core/editable.ts +26 -0
- package/src/core/gameplay.ts +2 -0
- package/src/core/i18n.ts +141 -10
- package/src/core/longPress.ts +209 -0
- package/src/core/longPressGesture.ts +110 -0
- package/src/core/mobileViewport.ts +362 -0
- package/src/core/openai.ts +39 -7
- package/src/core/sillyTavernWorldInfo.ts +159 -0
- package/src/core/sortableSensors.ts +21 -0
- package/src/core/stickyBottom.ts +45 -0
- package/src/core/tools/generatedSkillExecutor.ts +4 -20
- package/src/core/version.ts +46 -0
- package/src/core/viewport.ts +81 -0
- package/src/core/wechatTouch.ts +145 -0
- package/src/db/database.ts +313 -2
- package/src/generated/changelog.json +510 -0
- package/src/main.tsx +18 -2
- package/src/store/store.ts +99 -33
- package/src/theme/chat.css +174 -49
- package/src/theme/global.css +391 -10
- package/src/theme/theme.ts +9 -2
- package/src/theme/views.css +341 -8
- package/src/types/models.ts +9 -0
- package/src/vite-env.d.ts +4 -1
- package/vite.config.ts +184 -17
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
|
+
import { QRCodeSVG } from 'qrcode.react';
|
|
3
4
|
import { useStore } from '../store/store';
|
|
4
5
|
import { db } from '../db/database';
|
|
5
6
|
import { Icon, Modal, SessionVisual } from './shared';
|
|
@@ -7,6 +8,10 @@ import type { SessionMember } from './shared';
|
|
|
7
8
|
import type { ChatSession, NpcCharacter } from '../types/models';
|
|
8
9
|
import { NewSessionMenu } from './NewSessionMenu';
|
|
9
10
|
import { useT } from '../core/i18n';
|
|
11
|
+
import { useTouchLongPress } from '../core/longPress';
|
|
12
|
+
import { fixedViewportPoint } from '../core/viewport';
|
|
13
|
+
import { useHasUnseenVersion } from '../core/version';
|
|
14
|
+
import { PopoverMenu } from './PopoverMenu';
|
|
10
15
|
|
|
11
16
|
const SIDEBAR_MIN = 240;
|
|
12
17
|
const SIDEBAR_MAX = 520;
|
|
@@ -19,6 +24,7 @@ function openGlobalGameplayExport(sessionId: number, includeHistory: boolean) {
|
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
export function Sidebar() {
|
|
27
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
22
28
|
const sessions = useStore((s) => s.sessions);
|
|
23
29
|
const npcs = useStore((s) => s.npcs);
|
|
24
30
|
const activeSessionId = useStore((s) => s.activeSessionId);
|
|
@@ -31,16 +37,32 @@ export function Sidebar() {
|
|
|
31
37
|
const togglePin = useStore((s) => s.togglePin);
|
|
32
38
|
const addToast = useStore((s) => s.addToast);
|
|
33
39
|
const t = useT();
|
|
40
|
+
const hasUnseenVersion = useHasUnseenVersion();
|
|
34
41
|
|
|
35
42
|
const [showNew, setShowNew] = useState(false);
|
|
36
43
|
const [confirmDelete, setConfirmDelete] = useState<ChatSession | null>(null);
|
|
37
44
|
const [editTarget, setEditTarget] = useState<ChatSession | null>(null);
|
|
38
45
|
const [sessionMenu, setSessionMenu] = useState<{ session: ChatSession; x: number; y: number } | null>(null);
|
|
39
46
|
const [query, setQuery] = useState('');
|
|
47
|
+
const [appUrl, setAppUrl] = useState(() => window.location.href);
|
|
40
48
|
// 搜索命中的会话 id 集合(null = 未在搜索中)
|
|
41
49
|
const [searchHits, setSearchHits] = useState<Set<number> | null>(null);
|
|
42
50
|
// 群聊会话 → 参与者中的 NPC 角色
|
|
43
51
|
const [groupMembers, setGroupMembers] = useState<Record<number, NpcCharacter[]>>({});
|
|
52
|
+
const sessionLongPressTarget = useRef<ChatSession | null>(null);
|
|
53
|
+
const sessionLongPressProps = useTouchLongPress(
|
|
54
|
+
(point) => {
|
|
55
|
+
const session = sessionLongPressTarget.current;
|
|
56
|
+
if (session) setSessionMenu({ session, ...fixedViewportPoint(point) });
|
|
57
|
+
},
|
|
58
|
+
(event) => {
|
|
59
|
+
// 键盘 / 触屏补发的 contextmenu 未必经过 pointerdown,用行上记录的 id 兜底。
|
|
60
|
+
const id = Number(event.currentTarget.dataset.sessionId);
|
|
61
|
+
const session = (Number.isFinite(id) ? sessions.find((item) => item.id === id) : null)
|
|
62
|
+
?? sessionLongPressTarget.current;
|
|
63
|
+
if (session) setSessionMenu({ session, ...fixedViewportPoint({ x: event.clientX, y: event.clientY }) });
|
|
64
|
+
},
|
|
65
|
+
);
|
|
44
66
|
|
|
45
67
|
// 可拖拽侧边栏宽度
|
|
46
68
|
const [sidebarW, setSidebarW] = useState<number>(() => {
|
|
@@ -55,6 +77,27 @@ export function Sidebar() {
|
|
|
55
77
|
localStorage.setItem('tav-sidebar-w', String(sidebarW));
|
|
56
78
|
}, [sidebarW]);
|
|
57
79
|
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
const updateAppUrl = async () => {
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch('/api-v2/lan-address', { cache: 'no-store' });
|
|
85
|
+
const { url } = await response.json() as { url?: string | null };
|
|
86
|
+
if (!cancelled) setAppUrl(url || window.location.href);
|
|
87
|
+
} catch {
|
|
88
|
+
if (!cancelled) setAppUrl(window.location.href);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
void updateAppUrl();
|
|
92
|
+
window.addEventListener('popstate', updateAppUrl);
|
|
93
|
+
window.addEventListener('hashchange', updateAppUrl);
|
|
94
|
+
return () => {
|
|
95
|
+
cancelled = true;
|
|
96
|
+
window.removeEventListener('popstate', updateAppUrl);
|
|
97
|
+
window.removeEventListener('hashchange', updateAppUrl);
|
|
98
|
+
};
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
58
101
|
useEffect(() => {
|
|
59
102
|
const onMove = (e: MouseEvent) => {
|
|
60
103
|
const d = dragRef.current;
|
|
@@ -178,9 +221,36 @@ export function Sidebar() {
|
|
|
178
221
|
colorOrdinal: n.avatarColorOrdinal,
|
|
179
222
|
imageUrl: n.avatarDataUrl,
|
|
180
223
|
}));
|
|
181
|
-
|
|
182
224
|
return (
|
|
183
|
-
|
|
225
|
+
<>
|
|
226
|
+
<div className="mobile-topbar">
|
|
227
|
+
<button
|
|
228
|
+
className="mobile-nav-toggle"
|
|
229
|
+
type="button"
|
|
230
|
+
aria-label={mobileOpen ? t('common.close') : t('nav.sessions')}
|
|
231
|
+
aria-expanded={mobileOpen}
|
|
232
|
+
onClick={() => setMobileOpen((open) => !open)}
|
|
233
|
+
>
|
|
234
|
+
<Icon name={mobileOpen ? 'x' : 'menu'} size={22} />
|
|
235
|
+
</button>
|
|
236
|
+
<nav className="mobile-topbar-nav" aria-label={t('nav.chat')}>
|
|
237
|
+
<button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => setActiveView('chat')}>
|
|
238
|
+
<Icon name="send" size={15} /> <span>{t('nav.chat')}</span>
|
|
239
|
+
</button>
|
|
240
|
+
<button className={`nav-chip ${activeView === 'characters' ? 'active' : ''}`} onClick={() => setActiveView('characters')}>
|
|
241
|
+
<Icon name="users" size={15} /> <span>{t('nav.characters')}</span>
|
|
242
|
+
</button>
|
|
243
|
+
<button className={`nav-chip ${activeView === 'stats' ? 'active' : ''}`} onClick={() => setActiveView('stats')}>
|
|
244
|
+
<Icon name="trophy" size={15} /> <span>{t('nav.achievements')}</span>
|
|
245
|
+
</button>
|
|
246
|
+
<button className={`nav-chip ${activeView === 'settings' ? 'active' : ''}`} onClick={() => setActiveView('settings')}>
|
|
247
|
+
<Icon name="settings" size={15} /> <span>{t('nav.settings')}</span>
|
|
248
|
+
{hasUnseenVersion && <span className="unseen-dot" title={t('changelog.unseen')} />}
|
|
249
|
+
</button>
|
|
250
|
+
</nav>
|
|
251
|
+
</div>
|
|
252
|
+
{mobileOpen && <button className="mobile-sidebar-backdrop" type="button" aria-label={t('common.close')} onClick={() => setMobileOpen(false)} />}
|
|
253
|
+
<aside className={`sidebar ${mobileOpen ? 'mobile-open' : ''}`}>
|
|
184
254
|
<div className="sidebar-head">
|
|
185
255
|
<div
|
|
186
256
|
className="brand"
|
|
@@ -198,10 +268,14 @@ export function Sidebar() {
|
|
|
198
268
|
<div className="brand-logo">
|
|
199
269
|
<img src="/tavern-harness-logo.png" alt="Tavern Harness" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
|
|
200
270
|
</div>
|
|
201
|
-
<div>
|
|
271
|
+
<div className="brand-copy">
|
|
202
272
|
<div className="brand-name">Tavern Harness</div>
|
|
203
273
|
<div className="brand-sub">{t('nav.brandSub')}</div>
|
|
204
274
|
</div>
|
|
275
|
+
<div className="brand-qr" title={t('nav.qrHint')}>
|
|
276
|
+
<QRCodeSVG value={appUrl} size={52} level="H" includeMargin={false} fgColor="var(--primary)" bgColor="transparent" />
|
|
277
|
+
<span className="brand-qr-icon"><Icon name="phone" size={9} /></span>
|
|
278
|
+
</div>
|
|
205
279
|
</div>
|
|
206
280
|
<div className="side-nav" data-hscroll>
|
|
207
281
|
<button className={`nav-chip ${activeView === 'chat' ? 'active' : ''}`} onClick={() => setActiveView('chat')}>
|
|
@@ -215,6 +289,7 @@ export function Sidebar() {
|
|
|
215
289
|
</button>
|
|
216
290
|
<button className={`nav-chip ${activeView === 'settings' ? 'active' : ''}`} onClick={() => setActiveView('settings')}>
|
|
217
291
|
<Icon name="settings" size={13} /> {t('nav.settings')}
|
|
292
|
+
{hasUnseenVersion && <span className="unseen-dot" title={t('changelog.unseen')} />}
|
|
218
293
|
</button>
|
|
219
294
|
</div>
|
|
220
295
|
</div>
|
|
@@ -245,11 +320,20 @@ export function Sidebar() {
|
|
|
245
320
|
<button
|
|
246
321
|
key={s.id}
|
|
247
322
|
className={`session-item ${s.id === activeSessionId && activeView === 'chat' ? 'active' : ''}`}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
323
|
+
data-session-id={s.id}
|
|
324
|
+
onClick={() => {
|
|
325
|
+
setActiveSession(s.id!);
|
|
326
|
+
setMobileOpen(false);
|
|
327
|
+
}}
|
|
328
|
+
onPointerDown={(event) => {
|
|
329
|
+
sessionLongPressTarget.current = s;
|
|
330
|
+
sessionLongPressProps.onPointerDown(event);
|
|
252
331
|
}}
|
|
332
|
+
onPointerMove={sessionLongPressProps.onPointerMove}
|
|
333
|
+
onPointerUp={sessionLongPressProps.onPointerUp}
|
|
334
|
+
onPointerCancel={sessionLongPressProps.onPointerCancel}
|
|
335
|
+
onClickCapture={sessionLongPressProps.onClickCapture}
|
|
336
|
+
onContextMenu={sessionLongPressProps.onContextMenu}
|
|
253
337
|
>
|
|
254
338
|
{s.mode === 'NPC' ? (
|
|
255
339
|
<SessionVisual mode="NPC" npcName={npc?.name ?? 'NPC'} hue={npc?.avatarColorOrdinal ?? 0} imageUrl={npc?.avatarDataUrl} />
|
|
@@ -305,53 +389,48 @@ export function Sidebar() {
|
|
|
305
389
|
|
|
306
390
|
{sessionMenu &&
|
|
307
391
|
createPortal(
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
392
|
+
<PopoverMenu
|
|
393
|
+
key={`${sessionMenu.x}:${sessionMenu.y}:${sessionMenu.session.id ?? ''}`}
|
|
394
|
+
point={sessionMenu}
|
|
395
|
+
onClose={() => setSessionMenu(null)}
|
|
396
|
+
>
|
|
397
|
+
<button className="msg-menu-item" onClick={() => openEdit(sessionMenu.session)}>
|
|
398
|
+
<Icon name="pencil" size={13} /> {t('nav.editSession')}
|
|
399
|
+
</button>
|
|
400
|
+
<button
|
|
401
|
+
className="msg-menu-item"
|
|
402
|
+
onClick={async () => {
|
|
403
|
+
await togglePin(sessionMenu.session.id!);
|
|
404
|
+
setSessionMenu(null);
|
|
405
|
+
addToast(
|
|
406
|
+
(sessionMenu.session as { pinned?: unknown }).pinned
|
|
407
|
+
? t('toast.sessionUnpinned')
|
|
408
|
+
: t('toast.sessionPinned')
|
|
409
|
+
);
|
|
315
410
|
}}
|
|
316
411
|
>
|
|
317
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
</>,
|
|
412
|
+
<Icon name="pin" size={13} /> {(sessionMenu.session as { pinned?: unknown }).pinned ? t('nav.unpinSession') : t('nav.pinSession')}
|
|
413
|
+
</button>
|
|
414
|
+
<button
|
|
415
|
+
className="msg-menu-item"
|
|
416
|
+
onClick={() => {
|
|
417
|
+
const target = sessionMenu.session;
|
|
418
|
+
setSessionMenu(null);
|
|
419
|
+
openGlobalGameplayExport(target.id!, true);
|
|
420
|
+
}}
|
|
421
|
+
>
|
|
422
|
+
<Icon name="share" size={13} /> {t('nav.exportGameplay')}
|
|
423
|
+
</button>
|
|
424
|
+
<button
|
|
425
|
+
className="msg-menu-item danger"
|
|
426
|
+
onClick={() => {
|
|
427
|
+
setConfirmDelete(sessionMenu.session);
|
|
428
|
+
setSessionMenu(null);
|
|
429
|
+
}}
|
|
430
|
+
>
|
|
431
|
+
<Icon name="trash" size={13} /> {t('nav.deleteSession')}
|
|
432
|
+
</button>
|
|
433
|
+
</PopoverMenu>,
|
|
355
434
|
document.body
|
|
356
435
|
)}
|
|
357
436
|
|
|
@@ -387,6 +466,7 @@ export function Sidebar() {
|
|
|
387
466
|
document.body.classList.add('resizing');
|
|
388
467
|
}}
|
|
389
468
|
/>
|
|
390
|
-
|
|
469
|
+
</aside>
|
|
470
|
+
</>
|
|
391
471
|
);
|
|
392
472
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { CHANGELOG, compareUrl, entryGroups, type ChangelogEntry, type ChangelogGroup } from '../core/changelog';
|
|
3
|
+
import { currentLanguage, useT } from '../core/i18n';
|
|
4
|
+
import { APP_VERSION, markVersionSeen } from '../core/version';
|
|
5
|
+
import { Icon, Modal } from './shared';
|
|
6
|
+
|
|
7
|
+
// ============================================================
|
|
8
|
+
// 版本记录弹窗(设置 → 关于)
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
/** 行内格式:只支持 `code` 与 **强调**,不引入完整 Markdown 渲染 */
|
|
12
|
+
function InlineText({ text }: { text: string }) {
|
|
13
|
+
const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g).filter(Boolean);
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
{parts.map((part, index) => {
|
|
17
|
+
if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) {
|
|
18
|
+
return <code key={index}>{part.slice(1, -1)}</code>;
|
|
19
|
+
}
|
|
20
|
+
if (part.length > 4 && part.startsWith('**') && part.endsWith('**')) {
|
|
21
|
+
return <strong key={index}>{part.slice(2, -2)}</strong>;
|
|
22
|
+
}
|
|
23
|
+
return <span key={index}>{part}</span>;
|
|
24
|
+
})}
|
|
25
|
+
</>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function GroupList({ group }: { group: ChangelogGroup }) {
|
|
30
|
+
const t = useT();
|
|
31
|
+
const items = (
|
|
32
|
+
<ul>
|
|
33
|
+
{group.items.map((item, index) => (
|
|
34
|
+
<li key={index}>
|
|
35
|
+
<InlineText text={item} />
|
|
36
|
+
</li>
|
|
37
|
+
))}
|
|
38
|
+
</ul>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (group.kind === 'internal') {
|
|
42
|
+
return (
|
|
43
|
+
<details className="changelog-group changelog-internal">
|
|
44
|
+
<summary>{t('changelog.internal')}</summary>
|
|
45
|
+
{items}
|
|
46
|
+
</details>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<section className="changelog-group">
|
|
52
|
+
<h4>{t(`changelog.${group.kind}`)}</h4>
|
|
53
|
+
{items}
|
|
54
|
+
</section>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function Entry({ entry, isCurrent }: { entry: ChangelogEntry; isCurrent: boolean }) {
|
|
59
|
+
const t = useT();
|
|
60
|
+
const groups = entryGroups(entry, currentLanguage());
|
|
61
|
+
return (
|
|
62
|
+
<article className={`changelog-entry ${isCurrent ? 'current' : ''}`}>
|
|
63
|
+
<header className="changelog-entry-head">
|
|
64
|
+
<h3>v{entry.version}</h3>
|
|
65
|
+
<time dateTime={entry.date}>{entry.date}</time>
|
|
66
|
+
<span className="changelog-flags">
|
|
67
|
+
{isCurrent && <span className="changelog-flag">{t('changelog.currentTag')}</span>}
|
|
68
|
+
{entry.breaking && <span className="changelog-flag breaking">{t('changelog.breaking')}</span>}
|
|
69
|
+
</span>
|
|
70
|
+
</header>
|
|
71
|
+
{groups.map((group) => (
|
|
72
|
+
<GroupList key={group.kind} group={group} />
|
|
73
|
+
))}
|
|
74
|
+
<a
|
|
75
|
+
className="changelog-compare"
|
|
76
|
+
href={compareUrl(entry)}
|
|
77
|
+
target="_blank"
|
|
78
|
+
rel="noopener noreferrer"
|
|
79
|
+
>
|
|
80
|
+
{entry.compare ?? `v${entry.version}`} ↗
|
|
81
|
+
</a>
|
|
82
|
+
</article>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function VersionHistoryModal({ onClose }: { onClose: () => void }) {
|
|
87
|
+
const t = useT();
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
markVersionSeen();
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Modal onClose={onClose} width="min(720px, calc(100vw - 24px))">
|
|
95
|
+
<div className="modal-head">
|
|
96
|
+
<span style={{ fontWeight: 800, fontSize: 15 }}>{t('changelog.title')}</span>
|
|
97
|
+
<button className="icon-btn" onClick={onClose} aria-label={t('common.close')}>
|
|
98
|
+
<Icon name="x" />
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
<div className="modal-body changelog-body">
|
|
102
|
+
<p className="changelog-meta">{t('changelog.current', { version: APP_VERSION })}</p>
|
|
103
|
+
{CHANGELOG.entries.map((entry) => (
|
|
104
|
+
<Entry key={entry.version} entry={entry} isCurrent={entry.version === APP_VERSION} />
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
<div className="modal-foot">
|
|
108
|
+
<button className="btn btn-primary" onClick={onClose}>
|
|
109
|
+
{t('common.close')}
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
</Modal>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -156,10 +156,16 @@ export function Avatar({
|
|
|
156
156
|
const cls = size === 'lg' ? 'avatar' : size === 'xs' ? 'avatar xs' : 'avatar sm';
|
|
157
157
|
const hue = (colorOrdinal ?? 0) % 6;
|
|
158
158
|
if (imageUrl) {
|
|
159
|
+
// 用背景图而不是 <img>:微信 Android 内置浏览器会对 <img> 的长按弹出
|
|
160
|
+
// “发送给朋友 / 保存图片”。命中测试落在宿主 div 上就不会触发原生菜单;
|
|
161
|
+
// pointer-events: none 再让点击/滚动继续冒泡给外层(见 global.css)。
|
|
159
162
|
return (
|
|
160
|
-
<div
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
<div
|
|
164
|
+
className={`${cls} avatar-image`}
|
|
165
|
+
role="img"
|
|
166
|
+
aria-label={name}
|
|
167
|
+
style={{ backgroundImage: `url(${JSON.stringify(imageUrl)})` }}
|
|
168
|
+
/>
|
|
163
169
|
);
|
|
164
170
|
}
|
|
165
171
|
return <div className={`${cls} avatar-hue-${hue}`}>{name.slice(0, 1)}</div>;
|
|
@@ -330,7 +336,9 @@ const ICONS: Record<string, React.ReactNode> = {
|
|
|
330
336
|
file: <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM14 2v6h6" />,
|
|
331
337
|
trophy: <><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18" /><path d="M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" /><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" /></>,
|
|
332
338
|
more: <><circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" /></>,
|
|
339
|
+
menu: <><path d="M4 6h16" /><path d="M4 12h16" /><path d="M4 18h16" /></>,
|
|
333
340
|
image: <><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="m21 15-5-5L5 21" /></>,
|
|
341
|
+
phone: <><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></>,
|
|
334
342
|
check: <path d="M20 6 9 17l-5-5" />,
|
|
335
343
|
cancel: <path d="M18 6 6 18M6 6l12 12" />,
|
|
336
344
|
x: <path d="M18 6 6 18M6 6l12 12" />,
|
|
@@ -348,6 +356,7 @@ const ICONS: Record<string, React.ReactNode> = {
|
|
|
348
356
|
'text-wrap': <><path d="M4 6h13a4 4 0 0 1 0 8H9" /><path d="m12 11-3 3 3 3M4 18h3" /></>,
|
|
349
357
|
eye: <><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" /><circle cx="12" cy="12" r="3" /></>,
|
|
350
358
|
folder: <><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" /></>,
|
|
359
|
+
copy: <><rect x="8" y="8" width="13" height="13" rx="2" /><path d="M5 16V6a2 2 0 0 1 2-2h10" /></>,
|
|
351
360
|
};
|
|
352
361
|
|
|
353
362
|
export function Icon({ name, size = 15 }: { name: string; size?: number }) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AppLanguage, ThemeModeId } from '../types/models';
|
|
2
|
+
|
|
3
|
+
const BROWSER_UI_PREFERENCES_KEY = 'th-browser-ui-preferences';
|
|
4
|
+
|
|
5
|
+
export interface BrowserUiPreferences {
|
|
6
|
+
language: AppLanguage;
|
|
7
|
+
themeMode: ThemeModeId;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_BROWSER_UI_PREFERENCES: BrowserUiPreferences = {
|
|
11
|
+
language: null,
|
|
12
|
+
themeMode: 'system',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function hasBrowserUiPreferences(): boolean {
|
|
16
|
+
try {
|
|
17
|
+
return localStorage.getItem(BROWSER_UI_PREFERENCES_KEY) != null;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readBrowserUiPreferences(): BrowserUiPreferences {
|
|
24
|
+
try {
|
|
25
|
+
const parsed: unknown = JSON.parse(localStorage.getItem(BROWSER_UI_PREFERENCES_KEY) || '{}');
|
|
26
|
+
if (!parsed || typeof parsed !== 'object') return DEFAULT_BROWSER_UI_PREFERENCES;
|
|
27
|
+
const value = parsed as Partial<BrowserUiPreferences>;
|
|
28
|
+
return {
|
|
29
|
+
language: value.language === 'zh-CN' || value.language === 'zh-TW' || value.language === 'en' ? value.language : null,
|
|
30
|
+
themeMode: value.themeMode === 'light' || value.themeMode === 'dark' || value.themeMode === 'system' ? value.themeMode : 'system',
|
|
31
|
+
};
|
|
32
|
+
} catch {
|
|
33
|
+
return DEFAULT_BROWSER_UI_PREFERENCES;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function saveBrowserUiPreferences(preferences: Partial<BrowserUiPreferences>): BrowserUiPreferences {
|
|
38
|
+
const next = { ...readBrowserUiPreferences(), ...preferences };
|
|
39
|
+
try {
|
|
40
|
+
localStorage.setItem(BROWSER_UI_PREFERENCES_KEY, JSON.stringify(next));
|
|
41
|
+
} catch {
|
|
42
|
+
// Private browsing and blocked storage keep the current-page preference only.
|
|
43
|
+
}
|
|
44
|
+
return next;
|
|
45
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import data from '../generated/changelog.json';
|
|
2
|
+
|
|
3
|
+
// ============================================================
|
|
4
|
+
// 版本记录(由 scripts/changelog.mjs 从 changelog/v*.md 生成)
|
|
5
|
+
// 条目正文不放进 i18n 字典:每发一版只改数据源,不用同步三份文案。
|
|
6
|
+
// ============================================================
|
|
7
|
+
|
|
8
|
+
export type ChangelogKind = 'added' | 'changed' | 'fixed' | 'internal';
|
|
9
|
+
|
|
10
|
+
export interface ChangelogGroup {
|
|
11
|
+
kind: ChangelogKind;
|
|
12
|
+
items: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ChangelogEntry {
|
|
16
|
+
version: string;
|
|
17
|
+
date: string;
|
|
18
|
+
breaking: boolean;
|
|
19
|
+
compare: string | null;
|
|
20
|
+
groups: {
|
|
21
|
+
'zh-CN': ChangelogGroup[];
|
|
22
|
+
en: ChangelogGroup[];
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ChangelogData {
|
|
27
|
+
latest: string | null;
|
|
28
|
+
repo: string;
|
|
29
|
+
entries: ChangelogEntry[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const CHANGELOG = data as unknown as ChangelogData;
|
|
33
|
+
|
|
34
|
+
/** 取当前界面语言对应的条目;繁體中文回退到简体中文(版本记录不维护第三份文案) */
|
|
35
|
+
export function entryGroups(entry: ChangelogEntry, language: string): ChangelogGroup[] {
|
|
36
|
+
return entry.groups[language === 'en' ? 'en' : 'zh-CN'] ?? entry.groups['zh-CN'];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function compareUrl(entry: ChangelogEntry): string {
|
|
40
|
+
return entry.compare
|
|
41
|
+
? `${CHANGELOG.repo}/compare/${entry.compare}`
|
|
42
|
+
: `${CHANGELOG.repo}/releases/tag/v${entry.version}`;
|
|
43
|
+
}
|
|
@@ -198,6 +198,38 @@ export function hasUsableCharacterBook(characterBook: string | undefined): boole
|
|
|
198
198
|
}));
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/** 知识库条目统计:总条目数与可参与召回的条目数(已启用且内容非空)。 */
|
|
202
|
+
export function characterBookEntryStats(characterBook: string | undefined): { total: number; enabled: number } {
|
|
203
|
+
const book = parseCharacterBook(characterBook);
|
|
204
|
+
const entries = Array.isArray(book?.entries)
|
|
205
|
+
? book.entries.map(asRecord).filter(Boolean) as CharacterBookEntry[]
|
|
206
|
+
: [];
|
|
207
|
+
let enabled = 0;
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
if (entry.enabled !== false && typeof entry.content === 'string' && entry.content.trim()) enabled++;
|
|
210
|
+
}
|
|
211
|
+
return { total: entries.length, enabled };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 合并多个知识库(Character Book JSON):角色卡知识库 + 所选世界书知识库。
|
|
216
|
+
* 召回参数取并集:扫描深度取最大值、预算累加、递归扫描任一开启即开启;
|
|
217
|
+
* 条目按各自顺序保留,召回时统一按 insertion order 排序。
|
|
218
|
+
*/
|
|
219
|
+
export function mergeCharacterBooks(...sources: Array<string | null | undefined>): string | undefined {
|
|
220
|
+
const valid = sources
|
|
221
|
+
.map((source) => ({ source, book: parseCharacterBook(source ?? undefined) }))
|
|
222
|
+
.filter((item): item is { source: string; book: CharacterBook } => item.book != null && Boolean(item.source?.trim()));
|
|
223
|
+
if (valid.length === 0) return undefined;
|
|
224
|
+
if (valid.length === 1) return valid[0].source;
|
|
225
|
+
return JSON.stringify({
|
|
226
|
+
scan_depth: Math.max(...valid.map(({ book }) => positiveInt(book.scan_depth, DEFAULT_SCAN_DEPTH))),
|
|
227
|
+
token_budget: valid.reduce((total, { book }) => total + positiveInt(book.token_budget, DEFAULT_TOKEN_BUDGET), 0),
|
|
228
|
+
recursive_scanning: valid.some(({ book }) => toBoolean(book.recursive_scanning)),
|
|
229
|
+
entries: valid.flatMap(({ book }) => (Array.isArray(book.entries) ? book.entries : [])),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
201
233
|
function retrieveCharacterBook(characterBook: string | undefined, messages: NetworkMessage[]): RetrievedCharacterBookContext | null {
|
|
202
234
|
const book = parseCharacterBook(characterBook);
|
|
203
235
|
if (!book) return null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 复制文本到剪贴板。
|
|
3
|
+
*
|
|
4
|
+
* 局域网 http 访问不是安全上下文,`navigator.clipboard` 不可用,
|
|
5
|
+
* 此时退回已废弃但兼容性最好的 `document.execCommand('copy')`。
|
|
6
|
+
*
|
|
7
|
+
* @returns 是否复制成功
|
|
8
|
+
*/
|
|
9
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
10
|
+
try {
|
|
11
|
+
await navigator.clipboard.writeText(text);
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
// 非安全上下文 / 无权限:走下面的兜底
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const helper = document.createElement('textarea');
|
|
18
|
+
helper.value = text;
|
|
19
|
+
helper.setAttribute('readonly', '');
|
|
20
|
+
helper.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
|
|
21
|
+
document.body.appendChild(helper);
|
|
22
|
+
helper.select();
|
|
23
|
+
|
|
24
|
+
let copied = false;
|
|
25
|
+
try {
|
|
26
|
+
copied = document.execCommand('copy');
|
|
27
|
+
} catch {
|
|
28
|
+
copied = false;
|
|
29
|
+
}
|
|
30
|
+
helper.remove();
|
|
31
|
+
return copied;
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文本输入控件的判定(输入框、文本域、contenteditable)。
|
|
3
|
+
*
|
|
4
|
+
* 键盘 / 视口逻辑(`mobileViewport.ts`)和微信长按兜底(`wechatTouch.ts`)
|
|
5
|
+
* 共用同一份判定,避免两处选择器不一致。
|
|
6
|
+
*
|
|
7
|
+
* 注意:`mobileViewport.ts` 会被 Node 单测直接加载,所以从它引用的模块
|
|
8
|
+
* 需要显式带 `.ts` 后缀(Node 的 ESM 解析不补后缀)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const EDITABLE_SELECTOR =
|
|
12
|
+
'input, textarea, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]';
|
|
13
|
+
|
|
14
|
+
function closestElement(node: Node | null): Element | null {
|
|
15
|
+
return node instanceof Element ? node : node?.parentElement ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 事件目标(或其祖先)是否是可编辑控件。 */
|
|
19
|
+
export function isEditableTarget(target: EventTarget | null): boolean {
|
|
20
|
+
return target instanceof Element && target.closest(EDITABLE_SELECTOR) != null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 选区端点可能是文本节点,先取最近的元素再判断。 */
|
|
24
|
+
export function isEditableNode(node: Node | null): boolean {
|
|
25
|
+
return closestElement(node)?.closest(EDITABLE_SELECTOR) != null;
|
|
26
|
+
}
|
package/src/core/gameplay.ts
CHANGED
|
@@ -307,6 +307,7 @@ export async function importGameplay(payload: unknown): Promise<ImportGameplayRe
|
|
|
307
307
|
name: finalName,
|
|
308
308
|
prompt: source.prompt ?? '',
|
|
309
309
|
greeting: source.greeting ?? '',
|
|
310
|
+
characterBook: source.characterBook,
|
|
310
311
|
alternateGreetings: source.alternateGreetings ?? [],
|
|
311
312
|
avatarColorOrdinal: source.avatarColorOrdinal ?? 0,
|
|
312
313
|
avatarDataUrl: source.avatarDataUrl ?? null,
|
|
@@ -338,6 +339,7 @@ export async function importGameplay(payload: unknown): Promise<ImportGameplayRe
|
|
|
338
339
|
worldBookId = await db.worldBooks.add({
|
|
339
340
|
name: finalName,
|
|
340
341
|
content: data.worldBook.content ?? '',
|
|
342
|
+
characterBook: data.worldBook.characterBook,
|
|
341
343
|
imageUri: null,
|
|
342
344
|
createdAt: Date.now(),
|
|
343
345
|
});
|