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.
Files changed (47) hide show
  1. package/CHANGELOG.md +345 -0
  2. package/README.md +12 -4
  3. package/README.zh.md +12 -4
  4. package/bin/cli.js +16 -2
  5. package/index.html +3 -1
  6. package/package.json +18 -5
  7. package/persistence-server.mjs +303 -0
  8. package/public/sillytavern-logo.png +0 -0
  9. package/src/App.tsx +72 -8
  10. package/src/components/CharactersView.tsx +221 -68
  11. package/src/components/ChatView.tsx +98 -69
  12. package/src/components/FileDisplayModal.tsx +28 -17
  13. package/src/components/NewSessionMenu.tsx +2 -9
  14. package/src/components/PopoverMenu.tsx +54 -0
  15. package/src/components/SettingsView.tsx +21 -1
  16. package/src/components/Sidebar.tsx +133 -53
  17. package/src/components/VersionHistoryModal.tsx +114 -0
  18. package/src/components/shared.tsx +12 -3
  19. package/src/core/browserPreferences.ts +45 -0
  20. package/src/core/changelog.ts +43 -0
  21. package/src/core/characterBook.ts +32 -0
  22. package/src/core/clipboard.ts +32 -0
  23. package/src/core/editable.ts +26 -0
  24. package/src/core/gameplay.ts +2 -0
  25. package/src/core/i18n.ts +141 -10
  26. package/src/core/longPress.ts +209 -0
  27. package/src/core/longPressGesture.ts +110 -0
  28. package/src/core/mobileViewport.ts +362 -0
  29. package/src/core/openai.ts +39 -7
  30. package/src/core/sillyTavernWorldInfo.ts +159 -0
  31. package/src/core/sortableSensors.ts +21 -0
  32. package/src/core/stickyBottom.ts +45 -0
  33. package/src/core/tools/generatedSkillExecutor.ts +4 -20
  34. package/src/core/version.ts +46 -0
  35. package/src/core/viewport.ts +81 -0
  36. package/src/core/wechatTouch.ts +145 -0
  37. package/src/db/database.ts +313 -2
  38. package/src/generated/changelog.json +510 -0
  39. package/src/main.tsx +18 -2
  40. package/src/store/store.ts +99 -33
  41. package/src/theme/chat.css +174 -49
  42. package/src/theme/global.css +391 -10
  43. package/src/theme/theme.ts +9 -2
  44. package/src/theme/views.css +341 -8
  45. package/src/types/models.ts +9 -0
  46. package/src/vite-env.d.ts +4 -1
  47. 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
- <aside className="sidebar">
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
- onClick={() => setActiveSession(s.id!)}
249
- onContextMenu={(e) => {
250
- e.preventDefault();
251
- setSessionMenu({ session: s, x: e.clientX, y: e.clientY });
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
- <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)),
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
- <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
- </>,
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
- </aside>
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 className={cls}>
161
- <img src={imageUrl} alt={name} />
162
- </div>
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
+ }
@@ -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
  });