tavern-harness 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/index.html +1 -1
  3. package/package.json +1 -1
  4. package/persistence-server.mjs +54 -2
  5. package/src/App.tsx +91 -25
  6. package/src/components/AchievementModal.tsx +4 -2
  7. package/src/components/CharactersView.tsx +53 -25
  8. package/src/components/ChatView.tsx +264 -135
  9. package/src/components/Dashboard.tsx +12 -8
  10. package/src/components/DeleteConfirmDialog.tsx +1 -1
  11. package/src/components/FileDisplayModal.tsx +1 -1
  12. package/src/components/GameplayDialogs.tsx +2 -2
  13. package/src/components/NewSessionMenu.tsx +1 -1
  14. package/src/components/SettingsView.tsx +46 -29
  15. package/src/components/Sidebar.tsx +30 -3
  16. package/src/components/StatsView.tsx +55 -45
  17. package/src/components/VersionHistoryModal.tsx +1 -1
  18. package/src/components/WorkspaceFileManagerModal.tsx +1 -1
  19. package/src/components/shared.tsx +84 -24
  20. package/src/core/achievements.ts +22 -4
  21. package/src/core/avatarThumbnail.ts +245 -0
  22. package/src/core/characterBook.ts +2 -1
  23. package/src/core/i18n.ts +42 -6
  24. package/src/core/mobileViewport.ts +18 -3
  25. package/src/core/openai.ts +41 -19
  26. package/src/core/randomId.ts +19 -0
  27. package/src/core/rowFingerprint.ts +75 -0
  28. package/src/core/stats.ts +50 -5
  29. package/src/core/stickyBottom.ts +10 -1
  30. package/src/core/textPreview.ts +16 -0
  31. package/src/db/database.ts +101 -13
  32. package/src/generated/changelog.json +87 -1
  33. package/src/main.tsx +12 -21
  34. package/src/store/store.ts +179 -53
  35. package/src/theme/chat.css +62 -71
  36. package/src/theme/global.css +85 -21
  37. package/src/theme/theme.ts +13 -10
  38. package/src/theme/views.css +82 -5
  39. package/src/types/models.ts +2 -0
  40. package/vite.config.ts +8 -2
@@ -1,4 +1,4 @@
1
- import { Fragment, lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
1
+ import { Fragment, lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type UIEvent } from 'react';
2
2
  import { useStore } from '../store/store';
3
3
  import { type ChatMessage, type ChatParticipant, type ChatSession, type ContextCompression, type ToolCallRecord, displayModelName } from '../types/models';
4
4
  import { Avatar, Icon, Markdown, Collapse, Modal, AttachCard } from './shared';
@@ -12,7 +12,7 @@ import { escapeRegExp, prettyJson, trimEdgeNewlines } from '../core/strings';
12
12
  import { copyText } from '../core/clipboard';
13
13
  import { clearTextSelection, shouldSuppressContextMenuWhileTyping, useTouchLongPress } from '../core/longPress';
14
14
  import { fixedViewportPoint } from '../core/viewport';
15
- import { keepStickyBottom } from '../core/stickyBottom';
15
+ import { keepStickyBottom, isNearBottom } from '../core/stickyBottom';
16
16
  import { PopoverMenu } from './PopoverMenu';
17
17
 
18
18
  const CharacterBookEditor = lazy(() => import('./CharacterBookEditor'));
@@ -90,15 +90,29 @@ export function ChatView({
90
90
  participants,
91
91
  compressions,
92
92
  streaming,
93
+ active = true,
93
94
  }: {
94
95
  session: ChatSession;
95
96
  messages: ChatMessage[];
96
97
  participants: ChatParticipant[];
97
98
  compressions: ContextCompression[];
98
99
  streaming: boolean;
100
+ /** 是否为当前显示的 Tab。切走的 Tab 不显示(display:none 或 content-visibility:hidden)。 */
101
+ active?: boolean;
99
102
  }) {
100
103
  const scrollRef = useRef<HTMLDivElement>(null);
104
+ const chatColumnRef = useRef<HTMLDivElement>(null);
105
+ const streamingScrollFrameRef = useRef<number | null>(null);
106
+ const streamingScrollTargetRef = useRef(0);
107
+ const streamingProgrammaticScrollTopRef = useRef<number | null>(null);
108
+ const followStreamingRef = useRef(true);
109
+ const suspendStickyScrollRef = useRef(false);
101
110
  const positionedSessionRef = useRef<{ id: number | undefined; messageCount: number } | null>(null);
111
+ const scrollMemory = useRef<{ sessionId: number | undefined; top: number; atBottom: boolean }>({
112
+ sessionId: undefined,
113
+ top: 0,
114
+ atBottom: true,
115
+ });
102
116
  const updateStreaming = useStore((s) => s.streaming.sessionId === session.id);
103
117
  const t = useT();
104
118
  const compressionsByEndMessageId = useMemo(
@@ -115,6 +129,7 @@ export function ChatView({
115
129
 
116
130
  // 进入或切换会话时直接定位到最新消息,不播放滚动动画。
117
131
  useLayoutEffect(() => {
132
+ if (!active) return;
118
133
  const el = scrollRef.current;
119
134
  if (!el) return;
120
135
  const positioned = positionedSessionRef.current;
@@ -123,22 +138,96 @@ export function ChatView({
123
138
  if (!enteringSession && !historyJustLoaded) return;
124
139
  el.scrollTop = el.scrollHeight;
125
140
  positionedSessionRef.current = { id: session.id, messageCount: messages.length };
126
- }, [session.id, messages.length]);
141
+ }, [active, session.id, messages.length]);
142
+
143
+ useLayoutEffect(() => {
144
+ if (!active) return;
145
+ const el = scrollRef.current;
146
+ if (!el) return;
147
+ const memory = scrollMemory.current;
148
+ // 记下的位置属于另一个会话(切 Tab 的同时也切换了会话):直接贴底。
149
+ if (memory.sessionId !== session.id) {
150
+ el.scrollTop = el.scrollHeight;
151
+ return;
152
+ }
153
+ el.scrollTop = memory.atBottom ? el.scrollHeight : memory.top;
154
+ }, [active, session.id]);
127
155
 
128
- // 自动滚动到底(新消息 / 流式更新时):群聊流式生成时同样跟随最新内容
156
+ // 自动滚动到底(新消息 / 流式更新时):群聊流式生成时同样跟随最新内容。
157
+ // 将 token 的高频更新合并至一个 requestAnimationFrame,并插值追赶最新底部位置。
158
+ // 不反复重启浏览器的 smooth 动画,滚动可以保持连续。
129
159
  const isGroup = session.mode === 'GROUP';
130
- useEffect(() => {
160
+ useEffect(() => () => {
161
+ if (streamingScrollFrameRef.current != null) cancelAnimationFrame(streamingScrollFrameRef.current);
162
+ if (chatColumnRef.current) chatColumnRef.current.style.paddingBottom = '';
163
+ }, []);
164
+ useLayoutEffect(() => {
165
+ if (updateStreaming) return;
166
+ if (streamingScrollFrameRef.current != null) cancelAnimationFrame(streamingScrollFrameRef.current);
167
+ streamingScrollFrameRef.current = null;
168
+ if (chatColumnRef.current) chatColumnRef.current.style.paddingBottom = '';
169
+ }, [updateStreaming]);
170
+ useLayoutEffect(() => {
171
+ if (!active) return;
131
172
  const el = scrollRef.current;
132
173
  if (!el) return;
133
174
  if (positionedSessionRef.current?.id !== session.id) return;
134
175
  const selection = document.getSelection();
135
176
  if (selection && !selection.isCollapsed) return;
136
- const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
177
+ const nearBottom = isNearBottom(el.scrollHeight, el.scrollTop, el.clientHeight);
178
+ if (!updateStreaming) followStreamingRef.current = true;
179
+ if (updateStreaming && !followStreamingRef.current) return;
137
180
  // 群聊:仅在已处于底部或正在流式生成时自动吸附(流式画布增长时继续跟随),
138
- // 用户主动向上回看时不打断
181
+ // 用户主动向上回看时不打断。
139
182
  if (!isGroup || nearBottom || updateStreaming) {
140
- el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
183
+ if (!updateStreaming) {
184
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
185
+ return;
186
+ }
187
+ streamingScrollTargetRef.current = el.scrollHeight;
188
+ if (streamingScrollFrameRef.current != null) return;
189
+ const followStreamingBottom = () => {
190
+ const container = scrollRef.current;
191
+ if (!container || !followStreamingRef.current) {
192
+ streamingScrollFrameRef.current = null;
193
+ return;
194
+ }
195
+ // iOS 等浏览器的键盘只会压缩 visualViewport,聊天容器的 clientHeight
196
+ // 仍可能包含被键盘覆盖的区域。以可见视口底部作为滚动终点,确保思考卡片
197
+ // 完整露在输入框和键盘上方。
198
+ streamingScrollTargetRef.current = Math.max(streamingScrollTargetRef.current, container.scrollHeight);
199
+ const viewport = window.visualViewport;
200
+ const containerTop = container.getBoundingClientRect().top;
201
+ const visibleHeight = Math.min(
202
+ container.clientHeight,
203
+ Math.max(0, (viewport?.height ?? window.innerHeight) - containerTop),
204
+ );
205
+ const keyboardCoveredHeight = Math.max(0, container.clientHeight - visibleHeight);
206
+ // 为视觉视口被键盘遮住的部分追加底部空间。这样浏览器的原生滚动上限
207
+ // 也能让思考气泡越过输入框,而不是把目标位置夹回旧的底部。
208
+ const column = chatColumnRef.current;
209
+ if (column) column.style.paddingBottom = `${keyboardCoveredHeight + 8}px`;
210
+ const target = Math.min(
211
+ Math.max(0, container.scrollHeight - container.clientHeight),
212
+ Math.max(0, streamingScrollTargetRef.current - visibleHeight + 8),
213
+ );
214
+ const distance = target - container.scrollTop;
215
+ if (Math.abs(distance) < 0.5) {
216
+ streamingProgrammaticScrollTopRef.current = target;
217
+ container.scrollTop = target;
218
+ streamingScrollFrameRef.current = requestAnimationFrame(followStreamingBottom);
219
+ return;
220
+ }
221
+ // 思考内容增长时加快追赶速度,避免平滑动画长期落后于卡片底部。
222
+ const step = Math.sign(distance) * Math.max(2, Math.abs(distance) * 0.45);
223
+ const nextTop = container.scrollTop + (Math.abs(step) >= Math.abs(distance) ? distance : step);
224
+ streamingProgrammaticScrollTopRef.current = nextTop;
225
+ container.scrollTop = nextTop;
226
+ streamingScrollFrameRef.current = requestAnimationFrame(followStreamingBottom);
227
+ };
228
+ streamingScrollFrameRef.current = requestAnimationFrame(followStreamingBottom);
141
229
  }
230
+ // 恢复由上方 layout effect 处理。
142
231
  // eslint-disable-next-line react-hooks/exhaustive-deps
143
232
  }, [messages.length, updateStreaming, messages[messages.length - 1]?.content.length, isGroup]);
144
233
 
@@ -147,7 +236,36 @@ export function ChatView({
147
236
  useEffect(() => {
148
237
  const el = scrollRef.current;
149
238
  if (!el) return;
150
- return keepStickyBottom(el);
239
+ return keepStickyBottom(el, undefined, () => suspendStickyScrollRef.current);
240
+ }, []);
241
+
242
+ // 任意通用弹窗内的编辑控件都会触发软键盘;后台聊天列表在此期间保持位置。
243
+ // 焦点进入时立即冻结,失焦并等待键盘收起动画结束后再恢复。
244
+ useEffect(() => {
245
+ let resumeTimer = 0;
246
+ const isModalEditor = (target: EventTarget | null) =>
247
+ target instanceof HTMLElement
248
+ && target.matches('input, textarea, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]')
249
+ && target.closest('.modal-root') != null;
250
+ const onFocusIn = (event: FocusEvent) => {
251
+ if (!isModalEditor(event.target)) return;
252
+ window.clearTimeout(resumeTimer);
253
+ suspendStickyScrollRef.current = true;
254
+ };
255
+ const onFocusOut = (event: FocusEvent) => {
256
+ if (!isModalEditor(event.target)) return;
257
+ window.clearTimeout(resumeTimer);
258
+ resumeTimer = window.setTimeout(() => {
259
+ suspendStickyScrollRef.current = false;
260
+ }, 350);
261
+ };
262
+ document.addEventListener('focusin', onFocusIn, true);
263
+ document.addEventListener('focusout', onFocusOut, true);
264
+ return () => {
265
+ window.clearTimeout(resumeTimer);
266
+ document.removeEventListener('focusin', onFocusIn, true);
267
+ document.removeEventListener('focusout', onFocusOut, true);
268
+ };
151
269
  }, []);
152
270
 
153
271
  const npcId = session.associatedId;
@@ -184,9 +302,39 @@ export function ChatView({
184
302
  return ids;
185
303
  }, [messages]);
186
304
 
305
+ const handleScroll = (event: UIEvent<HTMLDivElement>) => {
306
+ const el = event.currentTarget;
307
+ // 隐藏过程中浏览器会把滚动位置夹到 0 并派发一次 scroll 事件,
308
+ // 那不是用户的滚动意图,不能覆盖掉记下的位置。
309
+ if (!active || el.clientHeight === 0) return;
310
+ const atBottom = isNearBottom(el.scrollHeight, el.scrollTop, el.clientHeight);
311
+ if (updateStreaming && followStreamingRef.current) {
312
+ const programmaticTop = streamingProgrammaticScrollTopRef.current;
313
+ // 仅忽略刚刚由自动跟随写入的位置;其它滚动事件都来自用户,应立刻停止跟随。
314
+ if (programmaticTop != null && Math.abs(el.scrollTop - programmaticTop) <= 2) {
315
+ streamingProgrammaticScrollTopRef.current = null;
316
+ }
317
+ }
318
+ scrollMemory.current = {
319
+ sessionId: session.id,
320
+ top: el.scrollTop,
321
+ atBottom,
322
+ };
323
+ onChatScroll();
324
+ };
325
+
187
326
  return (
188
- <div className="chat-scroll" ref={scrollRef} onScroll={onChatScroll}>
189
- <div className="chat-col">
327
+ <div
328
+ className="chat-scroll"
329
+ ref={scrollRef}
330
+ onScroll={handleScroll}
331
+ onWheel={() => { if (updateStreaming) followStreamingRef.current = false; }}
332
+ onTouchStart={() => { if (updateStreaming) followStreamingRef.current = false; }}
333
+ onPointerDown={(event) => {
334
+ if (updateStreaming && event.pointerType === 'mouse') followStreamingRef.current = false;
335
+ }}
336
+ >
337
+ <div className="chat-col" ref={chatColumnRef}>
190
338
  {messages.length === 0 && (
191
339
  <div className="empty-state fade-up">
192
340
  <div className="big">🫖</div>
@@ -316,20 +464,6 @@ function MessageBubble({
316
464
  }
317
465
  }, [msg.toolCallsJson]);
318
466
 
319
- // ---- 内联编辑状态(右键菜单 → 编辑消息,直接在气泡内编辑,不弹窗) ----
320
- const [isEditing, setIsEditing] = useState(false);
321
- useEffect(() => {
322
- const fn = () => {
323
- const isTarget = editingMsgState?.msgId === msg.id;
324
- setIsEditing(isTarget);
325
- };
326
- editingMsgListeners.push(fn);
327
- return () => {
328
- editingMsgListeners = editingMsgListeners.filter((f) => f !== fn);
329
- };
330
- // eslint-disable-next-line react-hooks/exhaustive-deps
331
- }, [msg.id]);
332
-
333
467
  const isUser = msg.role === 'user';
334
468
  const speaker = msg.speakerParticipantId != null
335
469
  ? participants.find((p) => p.participantId === msg.speakerParticipantId)
@@ -349,7 +483,7 @@ function MessageBubble({
349
483
  );
350
484
 
351
485
  if (isUser) {
352
- return <UserBubble msg={msg} session={session} userPersonaName={userPersonaName} editing={isEditing} loopIndex={loopIndex} />;
486
+ return <UserBubble msg={msg} session={session} userPersonaName={userPersonaName} loopIndex={loopIndex} />;
353
487
  }
354
488
 
355
489
  // 用于「点击队列发言 → 对话定位」:NPC 用 participantId,玩家/未知用 null(定位到循环首条)
@@ -362,21 +496,6 @@ function MessageBubble({
362
496
  .map((p) => p.kind === 'PLAYER' ? 'User' : p.displayName)
363
497
  : [];
364
498
 
365
- if (isEditing) {
366
- return (
367
- <div className="msg-row fade-up" data-loop={loopIndex ?? undefined} data-speaker={speakerKey ?? undefined}>
368
- <Avatar name={speakerName} colorOrdinal={npcHue} imageUrl={npcAvatar} />
369
- <div className="msg-body">
370
- <div className="msg-head">
371
- <span className="msg-name">{speakerName}</span>
372
- <span className="msg-time">{fmtTime(msg.timestamp)}</span>
373
- </div>
374
- <BubbleEditor msg={msg} session={session} onDone={stopEditingMsg} />
375
- </div>
376
- </div>
377
- );
378
- }
379
-
380
499
  return (
381
500
  <div
382
501
  className={`msg-row fade-up ${streaming ? 'streaming' : ''}`}
@@ -391,13 +510,14 @@ function MessageBubble({
391
510
  {msg.modelUsed && !msg.modelUsed.startsWith('scheduled:') && <span className="msg-model" title={msg.modelUsed}>{displayModelName(providers, msg.modelUsed)}</span>}
392
511
  <span className="msg-time">{fmtTime(msg.timestamp)}</span>
393
512
  </div>
394
- {msg.thinkingContent && streaming && (
395
- <Collapse title={t('chat.thinking')} icon="🧠" accent="think" preview={msg.thinkingContent} live>
396
- <Markdown text={msg.thinkingContent} mathEnabled={false} />
397
- </Collapse>
398
- )}
399
- {msg.thinkingContent && !streaming && (
400
- <Collapse title={t('chat.thinking')} icon="🧠" accent="think" defaultOpen={false} preview={msg.thinkingContent}>
513
+ {msg.thinkingContent && (
514
+ <Collapse
515
+ title={t('chat.thinking')}
516
+ icon="🧠"
517
+ accent="think"
518
+ preview={msg.thinkingContent}
519
+ live={streaming && !displayContent}
520
+ >
401
521
  <Markdown text={msg.thinkingContent} mathEnabled={false} />
402
522
  </Collapse>
403
523
  )}
@@ -427,6 +547,9 @@ function MessageBubble({
427
547
  </div>
428
548
  </div>
429
549
  )}
550
+ {msg.interrupted && !streaming && (
551
+ <div className="msg-metrics">{t('chat.interrupted')}</div>
552
+ )}
430
553
  {(msg.latencyMs != null || msg.promptTokens > 0 || msg.completionTokens > 0) && !streaming && (
431
554
  <div className="msg-metrics">{formatMetrics(msg)}</div>
432
555
  )}
@@ -435,8 +558,8 @@ function MessageBubble({
435
558
  );
436
559
  }
437
560
 
438
- /** 气泡内联编辑器(替换弹窗编辑,支持管理附件) */
439
- function BubbleEditor({
561
+ /** 统一消息编辑弹窗,集中处理虚拟键盘、滚动与安全区适配。 */
562
+ function MessageEditorModal({
440
563
  msg,
441
564
  session,
442
565
  onDone,
@@ -486,69 +609,69 @@ function BubbleEditor({
486
609
  const primaryAction = isUserMsg ? doSave : doSaveOnly;
487
610
 
488
611
  return (
489
- <div className="bubble-editor">
490
- <textarea
491
- className="textarea mono"
492
- rows={Math.max(3, Math.min(12, text.split('\n').length))}
493
- value={text}
494
- onChange={(e) => setText(e.target.value)}
495
- autoFocus
496
- onKeyDown={(e) => {
497
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
498
- e.preventDefault();
499
- primaryAction();
500
- }
501
- if (e.key === 'Escape') {
502
- e.preventDefault();
503
- onDone();
504
- }
505
- }}
506
- />
507
- {/* 附件管理 */}
508
- {attachments.length > 0 && (
509
- <div className="attachments" style={{ marginBottom: 0 }}>
510
- {attachments.map((a, i) => (
511
- <AttachCard
512
- key={i}
513
- name={a.name}
514
- onRemove={() => setAttachments(attachments.filter((_, j) => j !== i))}
515
- />
516
- ))}
517
- </div>
518
- )}
519
- <div className="bubble-editor-actions">
520
- <div className="bubble-editor-actions-start">
521
- <input
522
- ref={fileRef}
523
- type="file"
524
- accept="image/*,video/*"
525
- multiple
526
- hidden
527
- onChange={(e) => {
528
- const files = e.target.files;
529
- if (files) {
530
- Array.from(files).forEach((f) => readFileAsDataUrl(f).then((url) => setAttachments((prev) => [...prev, { dataUrl: url, name: f.name }])));
531
- }
532
- e.target.value = '';
533
- }}
534
- />
535
- <button className="btn btn-sm" onClick={() => fileRef.current?.click()}>
536
- <Icon name="image" size={12} /> {t('chat.addAttachment')}
537
- </button>
538
- </div>
539
- <div className="bubble-editor-actions-end">
540
- <span className="bubble-editor-hint">{t('chat.saveHint')}</span>
541
- <button className="btn btn-sm" onClick={onDone}>{t('common.cancel')}</button>
542
- <button className="btn btn-sm btn-primary" disabled={!text.trim() || streaming} onClick={primaryAction}>
543
- {isUserMsg ? t('chat.saveRegenerate') : t('chat.saveOnly')}
544
- </button>
545
- </div>
612
+ <Modal onClose={onDone} size="wide" className="message-editor-modal">
613
+ <div className="modal-head">
614
+ <span>{t('chat.editMsg')}</span>
615
+ <button className="icon-btn" onClick={onDone} aria-label={t('common.close')}>×</button>
546
616
  </div>
547
- </div>
617
+ <div className="modal-body message-editor-body">
618
+ <textarea
619
+ className="textarea message-editor-textarea"
620
+ value={text}
621
+ onChange={(e) => setText(e.target.value)}
622
+ onKeyDown={(e) => {
623
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
624
+ e.preventDefault();
625
+ void primaryAction();
626
+ }
627
+ if (e.key === 'Escape') {
628
+ e.preventDefault();
629
+ onDone();
630
+ }
631
+ }}
632
+ />
633
+ {attachments.length > 0 && (
634
+ <div className="attachments message-editor-attachments">
635
+ {attachments.map((a, i) => (
636
+ <AttachCard
637
+ key={i}
638
+ name={a.name}
639
+ onRemove={() => setAttachments(attachments.filter((_, j) => j !== i))}
640
+ />
641
+ ))}
642
+ </div>
643
+ )}
644
+ </div>
645
+ <div className="modal-foot message-editor-actions">
646
+ <input
647
+ ref={fileRef}
648
+ id={`message-editor-attachment-${msg.id}`}
649
+ type="file"
650
+ accept="image/*,video/*"
651
+ multiple
652
+ hidden
653
+ onChange={(e) => {
654
+ const files = e.target.files;
655
+ if (files) {
656
+ Array.from(files).forEach((f) => readFileAsDataUrl(f).then((url) => setAttachments((prev) => [...prev, { dataUrl: url, name: f.name }])));
657
+ }
658
+ e.target.value = '';
659
+ }}
660
+ />
661
+ <label className="btn btn-sm message-editor-attachment" htmlFor={`message-editor-attachment-${msg.id}`}>
662
+ <Icon name="image" size={12} /> {t('chat.addAttachment')}
663
+ </label>
664
+ <span className="message-editor-hint">{t('chat.saveHint')}</span>
665
+ <button className="btn btn-sm" onClick={onDone}>{t('common.cancel')}</button>
666
+ <button className="btn btn-sm btn-primary" disabled={!text.trim() || streaming} onClick={() => void primaryAction()}>
667
+ {isUserMsg ? t('chat.saveRegenerate') : t('chat.saveOnly')}
668
+ </button>
669
+ </div>
670
+ </Modal>
548
671
  );
549
672
  }
550
673
 
551
- function UserBubble({ msg, session, userPersonaName, editing, loopIndex }: { msg: ChatMessage; session: ChatSession; userPersonaName: string | null; editing?: boolean; loopIndex?: number | null }) {
674
+ function UserBubble({ msg, session, userPersonaName, loopIndex }: { msg: ChatMessage; session: ChatSession; userPersonaName: string | null; loopIndex?: number | null }) {
552
675
  const t = useT();
553
676
  const participants = useStore((s) => (session.id != null ? (s.participants[session.id] ?? []) : []));
554
677
  const longPressProps = useMessageLongPress(msg, session);
@@ -570,22 +693,18 @@ function UserBubble({ msg, session, userPersonaName, editing, loopIndex }: { msg
570
693
  <span className="msg-time">{fmtTime(msg.timestamp)}</span>
571
694
  <span className="msg-name">{t('common.you')}</span>
572
695
  </div>
573
- {editing ? (
574
- <BubbleEditor msg={msg} session={session} onDone={stopEditingMsg} />
575
- ) : (
576
- <div className="bubble bubble-user">
577
- {msg.attachments.length > 0 && (
578
- <div className="attachments">
579
- {msg.attachments.map((a, i) => (
580
- <AttachCard key={i} name={msg.attachmentInfos?.[i]?.displayName || attachmentName(a)} />
581
- ))}
582
- </div>
583
- )}
584
- <div className="bubble-text">
585
- {msg.content && <Markdown text={displayUserPersonaPlaceholder(msg.content, userPersonaName)} mentionNames={mentionNames} />}
696
+ <div className="bubble bubble-user">
697
+ {msg.attachments.length > 0 && (
698
+ <div className="attachments">
699
+ {msg.attachments.map((a, i) => (
700
+ <AttachCard key={i} name={msg.attachmentInfos?.[i]?.displayName || attachmentName(a)} />
701
+ ))}
586
702
  </div>
703
+ )}
704
+ <div className="bubble-text">
705
+ {msg.content && <Markdown text={displayUserPersonaPlaceholder(msg.content, userPersonaName)} mentionNames={mentionNames} />}
587
706
  </div>
588
- )}
707
+ </div>
589
708
  </div>
590
709
  </div>
591
710
  );
@@ -661,6 +780,7 @@ export function ChatInput({ sessionId }: { sessionId: number }) {
661
780
  const [showModels, setShowModels] = useState(false);
662
781
  const fileRef = useRef<HTMLInputElement>(null);
663
782
  const sendMessage = useStore((s) => s.sendMessage);
783
+ const addToast = useStore((s) => s.addToast);
664
784
  const streaming = useStore((s) => s.streaming.sessionId != null);
665
785
  const stopStreaming = useStore((s) => s.stopStreaming);
666
786
  const session = useStore((s) => s.sessions.find((x) => x.id === sessionId));
@@ -685,7 +805,13 @@ export function ChatInput({ sessionId }: { sessionId: number }) {
685
805
  sendingText,
686
806
  sendingAttachments.map((a) => a.dataUrl),
687
807
  sendingAttachments.map((a) => a.name)
688
- ).finally(() => {
808
+ ).catch((error: unknown) => {
809
+ // 发送链路本身也可能失败(例如另一个浏览器持有了写锁):不能静默丢掉
810
+ // 用户输入,恢复草稿并提示原因。
811
+ console.error('Failed to send message', error);
812
+ setText((current) => current || sendingText);
813
+ addToast(t('toast.genFailed', { msg: error instanceof Error ? error.message : String(error) }), 'error');
814
+ }).finally(() => {
689
815
  setTyping(false);
690
816
  });
691
817
  };
@@ -1128,17 +1254,15 @@ function closeMsgMenu() {
1128
1254
  notifyMsgMenu();
1129
1255
  }
1130
1256
 
1131
- // ---- 内联编辑全局状态(编辑目标由 MessageBubble 监听) ----
1132
- let editingMsgState: { msgId: number } | null = null;
1133
- let editingMsgListeners: Array<() => void> = [];
1134
- function startEditingMsg(msgId: number) {
1135
- editingMsgState = { msgId };
1136
- editingMsgListeners.forEach((fn) => fn());
1257
+ // ---- 消息编辑弹窗状态(由全局消息菜单打开) ----
1258
+ let editingMsgState: { msg: ChatMessage; session: ChatSession } | null = null;
1259
+ function startEditingMsg(msg: ChatMessage, session: ChatSession) {
1260
+ editingMsgState = { msg, session };
1137
1261
  closeMsgMenu();
1138
1262
  }
1139
1263
  function stopEditingMsg() {
1140
1264
  editingMsgState = null;
1141
- editingMsgListeners.forEach((fn) => fn());
1265
+ notifyMsgMenu();
1142
1266
  }
1143
1267
 
1144
1268
  export function MessageMenu() {
@@ -1151,6 +1275,7 @@ export function MessageMenu() {
1151
1275
  const npcs = useStore((s) => s.npcs);
1152
1276
  const [rawLog, setRawLog] = useState<{ data: RawLogData; title: string; filename: string } | null>(null);
1153
1277
  const [compressionSummary, setCompressionSummary] = useState<{ content: string; title: string } | null>(null);
1278
+ const editingMessage = editingMsgState;
1154
1279
 
1155
1280
  useEffect(() => {
1156
1281
  const fn = () => force((n) => n + 1);
@@ -1160,6 +1285,10 @@ export function MessageMenu() {
1160
1285
  };
1161
1286
  }, []);
1162
1287
 
1288
+ if (editingMessage) {
1289
+ return <MessageEditorModal msg={editingMessage.msg} session={editingMessage.session} onDone={stopEditingMsg} />;
1290
+ }
1291
+
1163
1292
  // 原始日志弹窗(独立状态,优先于右键菜单)
1164
1293
  if (rawLog) {
1165
1294
  return (
@@ -1183,7 +1312,7 @@ export function MessageMenu() {
1183
1312
 
1184
1313
  if (compressionSummary) {
1185
1314
  return (
1186
- <Modal onClose={() => setCompressionSummary(null)} width="min(680px, calc(100vw - 40px))">
1315
+ <Modal onClose={() => setCompressionSummary(null)} size="wide">
1187
1316
  <div className="modal-head">
1188
1317
  <span>{compressionSummary.title}</span>
1189
1318
  <button className="icon-btn" onClick={() => setCompressionSummary(null)} aria-label={t('common.close')}>×</button>
@@ -1221,7 +1350,7 @@ export function MessageMenu() {
1221
1350
  </PopoverMenu>
1222
1351
  );
1223
1352
  }
1224
- const { x, y, msg } = msgMenuState;
1353
+ const { x, y, msg, session } = msgMenuState;
1225
1354
 
1226
1355
  const actions: Array<{ label: string; icon: string; onClick: () => void; danger?: boolean }> = [];
1227
1356
  if (msg.content.trim()) {
@@ -1241,7 +1370,7 @@ export function MessageMenu() {
1241
1370
  label: t('chat.editMsg'),
1242
1371
  icon: 'settings',
1243
1372
  onClick: () => {
1244
- startEditingMsg(msg.id!);
1373
+ startEditingMsg(msg, session);
1245
1374
  },
1246
1375
  });
1247
1376
  }
@@ -1314,7 +1443,7 @@ function RawLogModal({ rawLog, title, onClose, onExport }: { rawLog: RawLogData;
1314
1443
  const [autoWrap, setAutoWrap] = useState(true);
1315
1444
 
1316
1445
  return (
1317
- <Modal onClose={onClose} className="raw-log-modal" width="min(760px, calc(100vw - 40px))">
1446
+ <Modal onClose={onClose} size="wide" className="raw-log-modal">
1318
1447
  <div className="modal-head raw-modal-head">
1319
1448
  <span className="raw-modal-title">
1320
1449
  <Icon name="file" size={15} /> {title}
@@ -1,9 +1,10 @@
1
- import { useEffect, useMemo, useState } from 'react';
1
+ import { memo, useEffect, useMemo, useState } from 'react';
2
2
  import { createSession, useStore } from '../store/store';
3
3
  import { db } from '../db/database';
4
- import { Icon, MasonryGrid, SessionVisual } from './shared';
4
+ import { Avatar, Icon, MasonryGrid, SessionVisual } from './shared';
5
5
  import { NewSessionMenu } from './NewSessionMenu';
6
6
  import { useT, currentLocale } from '../core/i18n';
7
+ import { textPreview } from '../core/textPreview';
7
8
  import type { SessionMember } from './shared';
8
9
  import type { NpcCharacter } from '../types/models';
9
10
 
@@ -11,7 +12,7 @@ import type { NpcCharacter } from '../types/models';
11
12
  // 仪表盘(无活动会话时的起始页)
12
13
  // ============================================================
13
14
 
14
- export function Dashboard() {
15
+ export const Dashboard = memo(function Dashboard() {
15
16
  const npcs = useStore((s) => s.npcs);
16
17
  const sessions = useStore((s) => s.sessions);
17
18
  const t = useT();
@@ -161,13 +162,16 @@ export function Dashboard() {
161
162
  {npcs.map((n) => (
162
163
  <div key={n.id} className="char-card">
163
164
  <div className="cname">
164
- <span className="avatar xs" style={{ background: `linear-gradient(135deg, var(--grad-from), var(--grad-to))` }}>
165
- {n.name.slice(0, 1)}
166
- </span>
165
+ <Avatar
166
+ name={n.name}
167
+ colorOrdinal={n.avatarColorOrdinal}
168
+ imageUrl={n.avatarDataUrl}
169
+ size="xs"
170
+ />
167
171
  {n.name}
168
172
  {n.isBuiltIn && <span className="tag">{t('common.builtin')}</span>}
169
173
  </div>
170
- <div className="cgreet">{n.greeting}</div>
174
+ <div className="cgreet">{textPreview(n.greeting)}</div>
171
175
  <div className="cactions">
172
176
  <button className="btn btn-sm btn-primary" onClick={() => startNpc(n.id!)}>{t('dash.startChat')}</button>
173
177
  </div>
@@ -180,4 +184,4 @@ export function Dashboard() {
180
184
  {showNew && <NewSessionMenu onClose={() => setShowNew(false)} />}
181
185
  </div>
182
186
  );
183
- }
187
+ });
@@ -23,7 +23,7 @@ export function DeleteConfirmDialog({
23
23
  const t = useT();
24
24
  const danger = dangerText ?? t('common.delete');
25
25
  return (
26
- <Modal onClose={onCancel} width="min(400px, calc(100vw - 32px))">
26
+ <Modal onClose={onCancel} size="compact">
27
27
  <div className="modal-head">
28
28
  <span style={{ fontWeight: 800, fontSize: 15 }}>{title}</span>
29
29
  <button className="icon-btn" onClick={onCancel}><Icon name="x" /></button>
@@ -483,7 +483,7 @@ export function FileDisplayModal() {
483
483
 
484
484
  return createPortal(
485
485
  <>
486
- {!isPictureInPicture && <div key="overlay" className="overlay" onClick={close} />}
486
+ {!isPictureInPicture && <div key="overlay" className="overlay" />}
487
487
  <div
488
488
  key="display-container"
489
489
  className={isPictureInPicture ? 'display-pip card' : 'modal-root'}
@@ -46,7 +46,7 @@ export function GameplayExportModal({
46
46
  };
47
47
 
48
48
  return (
49
- <Modal onClose={onClose} width={420}>
49
+ <Modal onClose={onClose} size="compact">
50
50
  <div className="modal-head">
51
51
  <span style={{ fontWeight: 800, fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>
52
52
  <Icon name="share" size={15} /> {t('gameplay.exportTitle')}
@@ -150,7 +150,7 @@ export function GameplayImportModal({
150
150
  };
151
151
 
152
152
  return (
153
- <Modal onClose={onClose} width={420}>
153
+ <Modal onClose={onClose} size="compact">
154
154
  <div className="modal-head">
155
155
  <span style={{ fontWeight: 800, fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>
156
156
  <Icon name="import" size={15} /> {t('gameplay.importTitle')}