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
@@ -9,6 +9,11 @@ import { effectiveDisplayQueue, speakerLabel, suggestMagicCommands } from '../co
9
9
  import { onChatScroll, onQueueScroll, onQueueScrollIntent, registerChatEl, registerQueueEl, scrollChatTo, scrollQueueToLoop } from '../core/linkedScroll';
10
10
  import { useT, translate } from '../core/i18n';
11
11
  import { escapeRegExp, prettyJson, trimEdgeNewlines } from '../core/strings';
12
+ import { copyText } from '../core/clipboard';
13
+ import { clearTextSelection, shouldSuppressContextMenuWhileTyping, useTouchLongPress } from '../core/longPress';
14
+ import { fixedViewportPoint } from '../core/viewport';
15
+ import { keepStickyBottom } from '../core/stickyBottom';
16
+ import { PopoverMenu } from './PopoverMenu';
12
17
 
13
18
  const CharacterBookEditor = lazy(() => import('./CharacterBookEditor'));
14
19
 
@@ -137,6 +142,14 @@ export function ChatView({
137
142
  // eslint-disable-next-line react-hooks/exhaustive-deps
138
143
  }, [messages.length, updateStreaming, messages[messages.length - 1]?.content.length, isGroup]);
139
144
 
145
+ // 软键盘弹起/收起、输入框增高都会改变滚动容器高度;在绘制前同步贴底,
146
+ // 让内容与输入框在同一帧到位(实现见 core/stickyBottom.ts)。
147
+ useEffect(() => {
148
+ const el = scrollRef.current;
149
+ if (!el) return;
150
+ return keepStickyBottom(el);
151
+ }, []);
152
+
140
153
  const npcId = session.associatedId;
141
154
  const npc = useStore((s) => s.npcs.find((n) => n.id === npcId));
142
155
  const userPersonaName = useStore((s) => {
@@ -202,6 +215,10 @@ export function ChatView({
202
215
  className="sys-banner fade-up compression-banner"
203
216
  onContextMenu={(e) => {
204
217
  e.preventDefault();
218
+ // 软键盘弹出期间不响应触摸长按,避免菜单打断输入;
219
+ // 触摸可能先让输入框失焦并收起键盘,还要看手势开始时的状态。
220
+ // 鼠标右键不受影响。
221
+ if (shouldSuppressContextMenuWhileTyping(e.nativeEvent)) return;
205
222
  const compression = compressionsByEndMessageId.get(m.id!);
206
223
  if (compression) openCompressionMenu(e, compression.summary, compression, t('chat.contextCompressed'));
207
224
  }}
@@ -225,6 +242,14 @@ function lastMsgId(messages: ChatMessage[]): number | undefined {
225
242
  // 单条消息气泡
226
243
  // ============================================================
227
244
 
245
+ /** 消息气泡共用的长按 / 右键菜单手势。 */
246
+ function useMessageLongPress(msg: ChatMessage, session: ChatSession) {
247
+ return useTouchLongPress(
248
+ (point) => openMsgMenuAt(point, msg, session),
249
+ (event) => openMsgMenu(event, msg, session),
250
+ );
251
+ }
252
+
228
253
  function MessageBubble({
229
254
  msg,
230
255
  session,
@@ -250,6 +275,7 @@ function MessageBubble({
250
275
  const t = useT();
251
276
  const providers = useStore((s) => s.providers);
252
277
  const turnStreaming = useStore((s) => s.streaming.sessionId === session.id);
278
+ const longPressProps = useMessageLongPress(msg, session);
253
279
  // System 消息(/new 标记)
254
280
  if (msg.role === 'system') {
255
281
  const content = msg.content === '开始新话题' ? t('chat.newTopicMarker') : msg.content;
@@ -356,7 +382,7 @@ function MessageBubble({
356
382
  className={`msg-row fade-up ${streaming ? 'streaming' : ''}`}
357
383
  data-loop={loopIndex ?? undefined}
358
384
  data-speaker={speakerKey ?? undefined}
359
- onContextMenu={(e) => { e.preventDefault(); openMsgMenu(e, msg, session); }}
385
+ {...longPressProps}
360
386
  >
361
387
  <Avatar name={speakerName} colorOrdinal={npcHue} imageUrl={npcAvatar} />
362
388
  <div className="msg-body">
@@ -396,14 +422,8 @@ function MessageBubble({
396
422
  ))}
397
423
  </div>
398
424
  )}
399
- <div className="bubble-content-row">
400
- <div className="bubble-text">
401
- {displayContent ? <Markdown text={displayContent} mentionNames={mentionNames} /> : streaming && <span className="stream-cursor" />}
402
- </div>
403
- {/* 编辑按钮:位于正文气泡内最右侧,铅笔图标 */}
404
- <button className="msg-edit-btn" title={t('chat.editMsg')} onClick={() => startEditingMsg(msg.id!)}>
405
- <Icon name="pencil" size={13} />
406
- </button>
425
+ <div className="bubble-text">
426
+ {displayContent ? <Markdown text={displayContent} mentionNames={mentionNames} /> : streaming && <span className="stream-cursor" />}
407
427
  </div>
408
428
  </div>
409
429
  )}
@@ -497,7 +517,7 @@ function BubbleEditor({
497
517
  </div>
498
518
  )}
499
519
  <div className="bubble-editor-actions">
500
- <div style={{ display: 'flex', gap: 8 }}>
520
+ <div className="bubble-editor-actions-start">
501
521
  <input
502
522
  ref={fileRef}
503
523
  type="file"
@@ -516,7 +536,7 @@ function BubbleEditor({
516
536
  <Icon name="image" size={12} /> {t('chat.addAttachment')}
517
537
  </button>
518
538
  </div>
519
- <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
539
+ <div className="bubble-editor-actions-end">
520
540
  <span className="bubble-editor-hint">{t('chat.saveHint')}</span>
521
541
  <button className="btn btn-sm" onClick={onDone}>{t('common.cancel')}</button>
522
542
  <button className="btn btn-sm btn-primary" disabled={!text.trim() || streaming} onClick={primaryAction}>
@@ -531,13 +551,19 @@ function BubbleEditor({
531
551
  function UserBubble({ msg, session, userPersonaName, editing, loopIndex }: { msg: ChatMessage; session: ChatSession; userPersonaName: string | null; editing?: boolean; loopIndex?: number | null }) {
532
552
  const t = useT();
533
553
  const participants = useStore((s) => (session.id != null ? (s.participants[session.id] ?? []) : []));
554
+ const longPressProps = useMessageLongPress(msg, session);
534
555
  // 群聊中「用户消息」的 @角色名 也作为特殊指令高亮(点名)
535
556
  const mentionNames =
536
557
  session.mode === 'GROUP'
537
558
  ? participants.filter((p) => p.kind === 'NPC').map((p) => p.displayName)
538
559
  : [];
539
560
  return (
540
- <div className="msg-row user-row fade-up" data-loop={loopIndex ?? undefined} data-speaker="player" onContextMenu={(e) => { e.preventDefault(); openMsgMenu(e, msg, session); }}>
561
+ <div
562
+ className="msg-row user-row fade-up"
563
+ data-loop={loopIndex ?? undefined}
564
+ data-speaker="player"
565
+ {...longPressProps}
566
+ >
541
567
  <div className="avatar sm user-avatar">{t('common.me')}</div>
542
568
  <div className="msg-body">
543
569
  <div className="msg-head right">
@@ -555,13 +581,8 @@ function UserBubble({ msg, session, userPersonaName, editing, loopIndex }: { msg
555
581
  ))}
556
582
  </div>
557
583
  )}
558
- <div className="bubble-content-row">
559
- <div className="bubble-text">
560
- {msg.content && <Markdown text={displayUserPersonaPlaceholder(msg.content, userPersonaName)} mentionNames={mentionNames} />}
561
- </div>
562
- <button className="msg-edit-btn" title={t('chat.editMsg')} onClick={() => startEditingMsg(msg.id!)}>
563
- <Icon name="pencil" size={13} />
564
- </button>
584
+ <div className="bubble-text">
585
+ {msg.content && <Markdown text={displayUserPersonaPlaceholder(msg.content, userPersonaName)} mentionNames={mentionNames} />}
565
586
  </div>
566
587
  </div>
567
588
  )}
@@ -646,6 +667,7 @@ export function ChatInput({ sessionId }: { sessionId: number }) {
646
667
  const providers = useStore((s) => s.providers);
647
668
  const defaultModel = useStore((s) => s.settings?.defaultModel?.trim() || '');
648
669
  const defaultProviderId = useStore((s) => s.settings?.defaultProviderId ?? null);
670
+ const composerPlaceholder = session?.mode === 'GROUP' ? t('chat.phGroup') : t('chat.phSingle');
649
671
 
650
672
  const canSend = text.trim().length > 0 || attachments.length > 0;
651
673
 
@@ -958,11 +980,8 @@ export function ChatInput({ sessionId }: { sessionId: number }) {
958
980
  }}
959
981
  onClick={(e) => detectMention(text, e.currentTarget.selectionStart ?? text.length)}
960
982
  onKeyUp={(e) => detectMention(text, e.currentTarget.selectionStart ?? text.length)}
961
- placeholder={
962
- session?.mode === 'GROUP'
963
- ? t('chat.phGroup')
964
- : t('chat.phSingle')
965
- }
983
+ aria-label={composerPlaceholder}
984
+ placeholder={composerPlaceholder}
966
985
  rows={1}
967
986
  style={{ height: 'auto', minHeight: 38 }}
968
987
  />
@@ -1093,11 +1112,15 @@ function notifyMsgMenu() {
1093
1112
  msgMenuListeners.forEach((fn) => fn());
1094
1113
  }
1095
1114
  function openMsgMenu(e: React.MouseEvent, msg: ChatMessage, session: ChatSession) {
1096
- msgMenuState = { x: e.clientX, y: e.clientY, msg, session };
1115
+ if (window.matchMedia('(pointer: coarse)').matches) clearTextSelection();
1116
+ openMsgMenuAt({ x: e.clientX, y: e.clientY }, msg, session);
1117
+ }
1118
+ function openMsgMenuAt(point: { x: number; y: number }, msg: ChatMessage, session: ChatSession) {
1119
+ msgMenuState = { ...fixedViewportPoint(point), msg, session };
1097
1120
  notifyMsgMenu();
1098
1121
  }
1099
1122
  function openCompressionMenu(e: React.MouseEvent, summary: string | undefined, rawLog: RawLogData | undefined, title: string) {
1100
- msgMenuState = { x: e.clientX, y: e.clientY, summary, rawLog, title };
1123
+ msgMenuState = { ...fixedViewportPoint({ x: e.clientX, y: e.clientY }), summary, rawLog, title };
1101
1124
  notifyMsgMenu();
1102
1125
  }
1103
1126
  function closeMsgMenu() {
@@ -1174,36 +1197,45 @@ export function MessageMenu() {
1174
1197
  if (!('msg' in msgMenuState)) {
1175
1198
  const { x, y, summary, rawLog: compressionRawLog, title } = msgMenuState;
1176
1199
  return (
1177
- <>
1178
- <div className="overlay-msg" onClick={closeMsgMenu} />
1179
- <div className="msg-menu card" style={{ left: Math.min(x, window.innerWidth - 190), top: Math.min(y, window.innerHeight - 200) }}>
1180
- <button
1181
- className="msg-menu-item"
1182
- disabled={!summary}
1183
- onClick={() => {
1184
- if (summary) setCompressionSummary({ content: summary, title });
1185
- closeMsgMenu();
1186
- }}
1187
- >
1188
- <Icon name="eye" size={13} /> {t('chat.viewMemory')}
1189
- </button>
1190
- <button
1191
- className="msg-menu-item"
1192
- disabled={!compressionRawLog?.rawRequestBody && !compressionRawLog?.rawResponseBody}
1193
- onClick={() => {
1194
- if (compressionRawLog) setRawLog({ data: compressionRawLog, title, filename: 'raw-log-context-compression.txt' });
1195
- closeMsgMenu();
1196
- }}
1197
- >
1198
- <Icon name="file" size={13} /> {t('chat.viewRaw')}
1199
- </button>
1200
- </div>
1201
- </>
1200
+ <PopoverMenu key={`${x}:${y}:compress`} point={{ x, y }} onClose={closeMsgMenu}>
1201
+ <button
1202
+ className="msg-menu-item"
1203
+ disabled={!summary}
1204
+ onClick={() => {
1205
+ if (summary) setCompressionSummary({ content: summary, title });
1206
+ closeMsgMenu();
1207
+ }}
1208
+ >
1209
+ <Icon name="eye" size={13} /> {t('chat.viewMemory')}
1210
+ </button>
1211
+ <button
1212
+ className="msg-menu-item"
1213
+ disabled={!compressionRawLog?.rawRequestBody && !compressionRawLog?.rawResponseBody}
1214
+ onClick={() => {
1215
+ if (compressionRawLog) setRawLog({ data: compressionRawLog, title, filename: 'raw-log-context-compression.txt' });
1216
+ closeMsgMenu();
1217
+ }}
1218
+ >
1219
+ <Icon name="file" size={13} /> {t('chat.viewRaw')}
1220
+ </button>
1221
+ </PopoverMenu>
1202
1222
  );
1203
1223
  }
1204
1224
  const { x, y, msg } = msgMenuState;
1205
1225
 
1206
1226
  const actions: Array<{ label: string; icon: string; onClick: () => void; danger?: boolean }> = [];
1227
+ if (msg.content.trim()) {
1228
+ // 触屏下消息正文禁用了文本选择,复制必须走菜单
1229
+ actions.push({
1230
+ label: t('chat.copyMsg'),
1231
+ icon: 'copy',
1232
+ onClick: () => {
1233
+ void copyText(msg.content).then((copied) => {
1234
+ addToast(copied ? t('toast.copied') : t('toast.copyFailed'));
1235
+ });
1236
+ },
1237
+ });
1238
+ }
1207
1239
  if (msg.role === 'user' || msg.role === 'assistant') {
1208
1240
  actions.push({
1209
1241
  label: t('chat.editMsg'),
@@ -1252,24 +1284,21 @@ export function MessageMenu() {
1252
1284
  });
1253
1285
 
1254
1286
  return (
1255
- <>
1256
- <div className="overlay-msg" onClick={closeMsgMenu} />
1257
- <div className="msg-menu card" style={{ left: Math.min(x, window.innerWidth - 190), top: Math.min(y, window.innerHeight - 200) }}>
1258
- {actions.map((a) => (
1259
- <button
1260
- key={a.label}
1261
- className="msg-menu-item"
1262
- onClick={() => {
1263
- closeMsgMenu();
1264
- a.onClick();
1265
- }}
1266
- >
1267
- <Icon name={a.icon} size={13} /> {a.label}
1268
- </button>
1269
- ))}
1270
- {actions.length === 0 && <div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('chat.noActions')}</div>}
1271
- </div>
1272
- </>
1287
+ <PopoverMenu key={`${x}:${y}:${msg.id ?? ''}`} point={{ x, y }} onClose={closeMsgMenu}>
1288
+ {actions.map((a) => (
1289
+ <button
1290
+ key={a.label}
1291
+ className="msg-menu-item"
1292
+ onClick={() => {
1293
+ closeMsgMenu();
1294
+ a.onClick();
1295
+ }}
1296
+ >
1297
+ <Icon name={a.icon} size={13} /> {a.label}
1298
+ </button>
1299
+ ))}
1300
+ {actions.length === 0 && <div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('chat.noActions')}</div>}
1301
+ </PopoverMenu>
1273
1302
  );
1274
1303
  }
1275
1304
 
@@ -6,6 +6,7 @@ import { useT } from '../core/i18n';
6
6
  import { highlightCode } from '../core/markdown';
7
7
  import { readWorkspaceFileText } from '../core/tools/generatedSkillExecutor';
8
8
  import { applySessionWorkspace } from '../core/tools/toolExecutor';
9
+ import { fixedViewportBounds, isMobileViewport } from '../core/viewport';
9
10
  import type { DisplayFileRef } from '../types/models';
10
11
 
11
12
  // ============================================================
@@ -41,11 +42,18 @@ interface PipRect {
41
42
  }
42
43
 
43
44
  function createPipRect(): PipRect {
44
- const height = Math.max(0, window.innerHeight - PIP_EDGE_GAP * 2);
45
+ // 用 visualViewport 边界(而不是 innerWidth/innerHeight):Edge Android 的
46
+ // 底部工具栏不在布局视口里,PiP 落到底部时会被工具栏盖住。
47
+ const bounds = fixedViewportBounds();
48
+ const availableWidth = Math.max(0, bounds.right - bounds.left - PIP_EDGE_GAP * 2);
49
+ const availableHeight = Math.max(0, bounds.bottom - bounds.top - PIP_EDGE_GAP * 2);
50
+ const isMobile = isMobileViewport();
51
+ const width = Math.min(isMobile ? 360 : 440, availableWidth);
52
+ const height = Math.min(isMobile ? 300 : 380, availableHeight);
45
53
  return {
46
- x: PIP_EDGE_GAP,
47
- y: PIP_EDGE_GAP,
48
- width: Math.min(440, Math.max(0, window.innerWidth - PIP_EDGE_GAP * 2)),
54
+ x: Math.max(bounds.left + PIP_EDGE_GAP, bounds.right - width - PIP_EDGE_GAP),
55
+ y: Math.max(bounds.top + PIP_EDGE_GAP, bounds.bottom - height - PIP_EDGE_GAP),
56
+ width,
49
57
  height,
50
58
  };
51
59
  }
@@ -230,23 +238,26 @@ export function FileDisplayModal() {
230
238
  pipGestureRef.current = null;
231
239
  return;
232
240
  }
241
+ // 与 createPipRect 使用同一套视觉视口边界,避免拖到 Edge Android
242
+ // 底部工具栏下面(innerHeight 包含被工具栏覆盖的区域)。
243
+ const bounds = fixedViewportBounds();
233
244
  if (gesture.type === 'move') {
234
- setPipRect((rect) => ({
235
- ...rect,
236
- x: Math.min(
237
- Math.max(PIP_EDGE_GAP, window.innerWidth - rect.width - PIP_EDGE_GAP),
238
- Math.max(PIP_EDGE_GAP, gesture.startX + e.clientX - gesture.pointerX),
239
- ),
240
- y: Math.min(
241
- Math.max(PIP_EDGE_GAP, window.innerHeight - rect.height - PIP_EDGE_GAP),
242
- Math.max(PIP_EDGE_GAP, gesture.startY + e.clientY - gesture.pointerY),
243
- ),
244
- }));
245
+ setPipRect((rect) => {
246
+ const minX = bounds.left + PIP_EDGE_GAP;
247
+ const maxX = Math.max(minX, bounds.right - rect.width - PIP_EDGE_GAP);
248
+ const minY = bounds.top + PIP_EDGE_GAP;
249
+ const maxY = Math.max(minY, bounds.bottom - rect.height - PIP_EDGE_GAP);
250
+ return {
251
+ ...rect,
252
+ x: Math.min(maxX, Math.max(minX, gesture.startX + e.clientX - gesture.pointerX)),
253
+ y: Math.min(maxY, Math.max(minY, gesture.startY + e.clientY - gesture.pointerY)),
254
+ };
255
+ });
245
256
  return;
246
257
  }
247
258
  setPipRect((rect) => {
248
- const availableWidth = window.innerWidth - rect.x - PIP_EDGE_GAP;
249
- const availableHeight = window.innerHeight - rect.y - PIP_EDGE_GAP;
259
+ const availableWidth = Math.max(0, bounds.right - rect.x - PIP_EDGE_GAP);
260
+ const availableHeight = Math.max(0, bounds.bottom - rect.y - PIP_EDGE_GAP);
250
261
  return {
251
262
  ...rect,
252
263
  width: Math.min(availableWidth, Math.max(Math.min(PIP_MIN_WIDTH, availableWidth), gesture.startWidth + e.clientX - gesture.pointerX)),
@@ -1,22 +1,18 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import {
3
3
  DndContext,
4
- PointerSensor,
5
- KeyboardSensor,
6
4
  closestCenter,
7
- useSensor,
8
- useSensors,
9
5
  type Modifier,
10
6
  type DragEndEvent,
11
7
  } from '@dnd-kit/core';
12
8
  import {
13
9
  SortableContext,
14
10
  arrayMove,
15
- sortableKeyboardCoordinates,
16
11
  verticalListSortingStrategy,
17
12
  useSortable,
18
13
  } from '@dnd-kit/sortable';
19
14
  import { CSS } from '@dnd-kit/utilities';
15
+ import { useSortableSensors } from '../core/sortableSensors';
20
16
  import { useStore, createSession } from '../store/store';
21
17
  import { Avatar, Icon, Modal } from './shared';
22
18
  import { useT } from '../core/i18n';
@@ -58,10 +54,7 @@ export function NewSessionMenu({ onClose, editingSession }: { onClose: () => voi
58
54
  const [enableGreeting, setEnableGreeting] = useState(editingSession?.enableGreeting !== false);
59
55
  const selectedNpcIds = participantOrder.filter((id) => id !== -1);
60
56
  const modeLabel = selectedNpcIds.length === 0 ? t('newSession.notSelected') : selectedNpcIds.length === 1 ? t('newSession.npcChat') : t('newSession.groupChat', { n: selectedNpcIds.length });
61
- const sensors = useSensors(
62
- useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
63
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
64
- );
57
+ const sensors = useSortableSensors();
65
58
 
66
59
  useEffect(() => {
67
60
  if (!editingSession?.id) return;
@@ -0,0 +1,54 @@
1
+ import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
2
+ import { clampPopoverPosition, fixedViewportBounds, type ViewportPoint } from '../core/viewport';
3
+
4
+ /**
5
+ * 浮动菜单(消息菜单 / 会话菜单共用):遮罩 + 贴边钳制的卡片。
6
+ *
7
+ * 菜单项数量随上下文变化,用硬编码宽高去钳制会算错位置。这里先按触发点挂载,
8
+ * 再用 `useLayoutEffect` 在绘制前按实测尺寸重新钳制一次。测量完成前菜单保持
9
+ * `visibility: hidden`:即使个别 WebView 把布局副作用推迟到一帧之后,用户也
10
+ * 不会看到菜单从触发点跳到钳制位置。
11
+ * 切换菜单时由外层 `key` 触发重新挂载,保证每次打开都会重新测量。
12
+ */
13
+ export function PopoverMenu({
14
+ point,
15
+ onClose,
16
+ children,
17
+ }: {
18
+ point: ViewportPoint;
19
+ onClose: () => void;
20
+ children: ReactNode;
21
+ }) {
22
+ const ref = useRef<HTMLDivElement>(null);
23
+ const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
24
+
25
+ useLayoutEffect(() => {
26
+ const element = ref.current;
27
+ if (!element) return;
28
+ setPosition(
29
+ clampPopoverPosition(
30
+ point,
31
+ { width: element.offsetWidth, height: element.offsetHeight },
32
+ fixedViewportBounds(),
33
+ ),
34
+ );
35
+ // 点到点之间不重复渲染:point 对象每次都是新引用,按坐标值比较。
36
+ }, [point.x, point.y]);
37
+
38
+ return (
39
+ <>
40
+ <div className="overlay-msg" onClick={onClose} />
41
+ <div
42
+ ref={ref}
43
+ className="msg-menu card"
44
+ style={{
45
+ left: position?.left ?? point.x,
46
+ top: position?.top ?? point.y,
47
+ visibility: position ? 'visible' : 'hidden',
48
+ }}
49
+ >
50
+ {children}
51
+ </div>
52
+ </>
53
+ );
54
+ }
@@ -7,6 +7,8 @@ import { Icon, Modal } from './shared';
7
7
  import { DeleteConfirmDialog } from './DeleteConfirmDialog';
8
8
  import { isProxyUrl, isNetworkLikeError, toProxyUrl } from '../core/proxy';
9
9
  import { useT } from '../core/i18n';
10
+ import { APP_VERSION, useHasUnseenVersion } from '../core/version';
11
+ import { VersionHistoryModal } from './VersionHistoryModal';
10
12
 
11
13
  // ============================================================
12
14
  // 设置视图(模型服务 / 超参数 / 主题)
@@ -20,7 +22,9 @@ export function SettingsView() {
20
22
  const refreshProviders = useStore((s) => s.refreshProviders);
21
23
  const [showResetConfirm, setShowResetConfirm] = useState(false);
22
24
  const [resetting, setResetting] = useState(false);
25
+ const [showVersionHistory, setShowVersionHistory] = useState(false);
23
26
  const t = useT();
27
+ const hasUnseenVersion = useHasUnseenVersion();
24
28
 
25
29
  if (!settings) return null;
26
30
 
@@ -76,7 +80,7 @@ export function SettingsView() {
76
80
  <table className="table param-table">
77
81
  <tbody>
78
82
  <tr>
79
- <td style={{ width: 170 }}>
83
+ <td className="param-label">
80
84
  <div className="s-label">Temperature</div>
81
85
  <div className="s-desc">{t('settings.tempDesc')}</div>
82
86
  </td>
@@ -197,6 +201,20 @@ export function SettingsView() {
197
201
  </div>
198
202
  </div>
199
203
 
204
+ <div className="set-section settings-about-section">
205
+ <div>
206
+ <h3>{t('settings.aboutTitle')}</h3>
207
+ <p className="settings-reset-desc">{t('settings.aboutDesc', { version: APP_VERSION })}</p>
208
+ </div>
209
+ <button
210
+ className="btn settings-about-button"
211
+ onClick={() => setShowVersionHistory(true)}
212
+ >
213
+ <Icon name="book" size={14} /> {t('settings.viewChangelog')}
214
+ {hasUnseenVersion && <span className="unseen-dot" title={t('changelog.unseen')} />}
215
+ </button>
216
+ </div>
217
+
200
218
  <div className="set-section settings-reset-section">
201
219
  <div>
202
220
  <h3>{t('settings.resetTitle')}</h3>
@@ -208,6 +226,8 @@ export function SettingsView() {
208
226
  </div>
209
227
  </div>
210
228
 
229
+ {showVersionHistory && <VersionHistoryModal onClose={() => setShowVersionHistory(false)} />}
230
+
211
231
  {showResetConfirm && (
212
232
  <Modal onClose={() => !resetting && setShowResetConfirm(false)} width="min(420px, calc(100vw - 32px))">
213
233
  <div className="modal-head">