tavern-harness 0.1.13 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/index.html +1 -1
- package/package.json +1 -1
- package/persistence-server.mjs +54 -2
- package/src/App.tsx +91 -25
- package/src/components/AchievementModal.tsx +4 -2
- package/src/components/CharactersView.tsx +53 -25
- package/src/components/ChatView.tsx +264 -135
- package/src/components/Dashboard.tsx +12 -8
- package/src/components/DeleteConfirmDialog.tsx +1 -1
- package/src/components/FileDisplayModal.tsx +1 -1
- package/src/components/GameplayDialogs.tsx +2 -2
- package/src/components/NewSessionMenu.tsx +1 -1
- package/src/components/SettingsView.tsx +46 -29
- package/src/components/Sidebar.tsx +30 -3
- package/src/components/StatsView.tsx +55 -45
- package/src/components/VersionHistoryModal.tsx +1 -1
- package/src/components/WorkspaceFileManagerModal.tsx +1 -1
- package/src/components/shared.tsx +84 -24
- package/src/core/achievements.ts +22 -4
- package/src/core/avatarThumbnail.ts +245 -0
- package/src/core/characterBook.ts +2 -1
- package/src/core/i18n.ts +42 -6
- package/src/core/mobileViewport.ts +18 -3
- package/src/core/openai.ts +41 -19
- package/src/core/randomId.ts +19 -0
- package/src/core/rowFingerprint.ts +75 -0
- package/src/core/stats.ts +50 -5
- package/src/core/stickyBottom.ts +10 -1
- package/src/core/textPreview.ts +16 -0
- package/src/db/database.ts +101 -13
- package/src/generated/changelog.json +87 -1
- package/src/main.tsx +12 -21
- package/src/store/store.ts +179 -53
- package/src/theme/chat.css +62 -71
- package/src/theme/global.css +85 -21
- package/src/theme/theme.ts +13 -10
- package/src/theme/views.css +82 -5
- package/src/types/models.ts +2 -0
- package/vite.config.ts +8 -2
|
@@ -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
|
|
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
|
-
|
|
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
|
|
189
|
-
|
|
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}
|
|
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 &&
|
|
395
|
-
<Collapse
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
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
|
-
<
|
|
490
|
-
<
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
).
|
|
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
|
-
// ----
|
|
1132
|
-
let editingMsgState: {
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
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)}
|
|
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
|
|
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"
|
|
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
|
-
<
|
|
165
|
-
{n.name
|
|
166
|
-
|
|
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}
|
|
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"
|
|
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}
|
|
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}
|
|
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')}
|