tavern-harness 0.1.0
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/README.md +161 -0
- package/README.zh.md +123 -0
- package/bin/cli.js +48 -0
- package/index.html +35 -0
- package/package.json +50 -0
- package/public/favicon.png +0 -0
- package/public/tavern-harness-logo.png +0 -0
- package/sandbox-server.mjs +997 -0
- package/src/App.tsx +268 -0
- package/src/assets/tavern-handbook.md +39 -0
- package/src/components/AchievementModal.tsx +158 -0
- package/src/components/CharactersView.tsx +904 -0
- package/src/components/ChatView.tsx +1639 -0
- package/src/components/ConfirmationDialog.tsx +56 -0
- package/src/components/Dashboard.tsx +180 -0
- package/src/components/DeleteConfirmDialog.tsx +48 -0
- package/src/components/FileDisplayModal.tsx +556 -0
- package/src/components/GameplayDialogs.tsx +191 -0
- package/src/components/NewSessionMenu.tsx +362 -0
- package/src/components/SettingsView.tsx +517 -0
- package/src/components/Sidebar.tsx +392 -0
- package/src/components/StatsView.tsx +189 -0
- package/src/components/Toasts.tsx +20 -0
- package/src/components/WorkspaceFileManagerModal.tsx +216 -0
- package/src/components/shared.tsx +369 -0
- package/src/core/achievements.ts +85 -0
- package/src/core/fileDownload.ts +67 -0
- package/src/core/gameplay.ts +521 -0
- package/src/core/horizontalWheel.ts +93 -0
- package/src/core/i18n.ts +1403 -0
- package/src/core/linkedScroll.ts +236 -0
- package/src/core/markdown.ts +170 -0
- package/src/core/openai.ts +451 -0
- package/src/core/openingGreeting.ts +41 -0
- package/src/core/prompts.ts +254 -0
- package/src/core/proxy.ts +58 -0
- package/src/core/sillyTavernImporter.ts +317 -0
- package/src/core/stats.ts +91 -0
- package/src/core/strings.ts +19 -0
- package/src/core/toolDefinitions.ts +400 -0
- package/src/core/tools/builtinTools.ts +38 -0
- package/src/core/tools/generatedSkillExecutor.ts +706 -0
- package/src/core/tools/generatedWorkspace.ts +13 -0
- package/src/core/tools/toolExecutor.ts +718 -0
- package/src/core/turnLoop.ts +211 -0
- package/src/db/database.ts +647 -0
- package/src/main.tsx +23 -0
- package/src/store/store.ts +2419 -0
- package/src/theme/chat.css +1227 -0
- package/src/theme/global.css +1377 -0
- package/src/theme/theme.ts +143 -0
- package/src/theme/views.css +1430 -0
- package/src/types/models.ts +364 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +273 -0
|
@@ -0,0 +1,1639 @@
|
|
|
1
|
+
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { useStore } from '../store/store';
|
|
3
|
+
import { type ChatMessage, type ChatParticipant, type ChatSession, type ContextCompression, type ToolCallRecord, displayModelName } from '../types/models';
|
|
4
|
+
import { Avatar, Icon, Markdown, Collapse, Modal, AttachCard } from './shared';
|
|
5
|
+
import { FileDisplayViewButton, parseStoredDisplayRef } from './FileDisplayModal';
|
|
6
|
+
import { formatMetrics } from '../core/stats';
|
|
7
|
+
import { saveTextFile } from '../core/fileDownload';
|
|
8
|
+
import { effectiveDisplayQueue, speakerLabel, suggestMagicCommands } from '../core/turnLoop';
|
|
9
|
+
import { onChatScroll, onQueueScroll, onQueueScrollIntent, registerChatEl, registerQueueEl, scrollChatTo, scrollQueueToLoop } from '../core/linkedScroll';
|
|
10
|
+
import { useT, translate } from '../core/i18n';
|
|
11
|
+
import { escapeRegExp, prettyJson, trimEdgeNewlines } from '../core/strings';
|
|
12
|
+
|
|
13
|
+
function fmtTime(ts: number): string {
|
|
14
|
+
const d = new Date(ts);
|
|
15
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
16
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
17
|
+
return `${hh}:${mm}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderComposerRichNodes(text: string, names: string[]): ReactNode[] {
|
|
21
|
+
if (!text) return [];
|
|
22
|
+
const mentionRe =
|
|
23
|
+
names.length > 0
|
|
24
|
+
? new RegExp(`@(?:${[...names].sort((a, b) => b.length - a.length).map(escapeRegExp).join('|')})(?=\\s|[,。!?,.!?]|$)`, 'g')
|
|
25
|
+
: null;
|
|
26
|
+
const hits: Array<{ start: number; end: number; cls: string }> = [];
|
|
27
|
+
if (mentionRe) {
|
|
28
|
+
for (const m of text.matchAll(mentionRe)) {
|
|
29
|
+
hits.push({ start: m.index ?? 0, end: (m.index ?? 0) + m[0].length, cls: 'mention mention-live' });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// 行首魔法指令前缀(/new /pass 及它们的前缀)也用高亮颜色
|
|
33
|
+
const cmdMatch = /^(\/[A-Za-z]*)/.exec(text);
|
|
34
|
+
const cmdTok = cmdMatch ? cmdMatch[1] : '';
|
|
35
|
+
if (cmdTok && suggestMagicCommands(cmdTok).length > 0) {
|
|
36
|
+
hits.push({ start: 0, end: cmdTok.length, cls: 'cmd-live' });
|
|
37
|
+
}
|
|
38
|
+
if (hits.length === 0) return [];
|
|
39
|
+
hits.sort((a, b) => a.start - b.start);
|
|
40
|
+
const out: ReactNode[] = [];
|
|
41
|
+
let last = 0;
|
|
42
|
+
let i = 0;
|
|
43
|
+
for (const h of hits) {
|
|
44
|
+
if (h.start < last) continue;
|
|
45
|
+
if (h.start > last) out.push(text.slice(last, h.start));
|
|
46
|
+
out.push(<span key={`h-${i++}`} className={h.cls}>{text.slice(h.start, h.end)}</span>);
|
|
47
|
+
last = h.end;
|
|
48
|
+
}
|
|
49
|
+
if (last < text.length) out.push(text.slice(last));
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 从 dataUrl / URL 猜测一个可显示的文件名 */
|
|
54
|
+
function attachmentName(a: string): string {
|
|
55
|
+
const attach = translate('common.attachment');
|
|
56
|
+
if (a.startsWith('data:')) {
|
|
57
|
+
const m = /^data:([^;]+);/.exec(a);
|
|
58
|
+
const mime = m ? m[1] : '';
|
|
59
|
+
const ext = mime.split('/')[1] || 'bin';
|
|
60
|
+
return `${attach}.${ext}`;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const u = new URL(a);
|
|
64
|
+
const name = u.pathname.split('/').pop();
|
|
65
|
+
if (name) return decodeURIComponent(name);
|
|
66
|
+
} catch {
|
|
67
|
+
/* ignore */
|
|
68
|
+
}
|
|
69
|
+
return attach;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================
|
|
73
|
+
// 聊天内容区
|
|
74
|
+
// ============================================================
|
|
75
|
+
|
|
76
|
+
export function ChatView({
|
|
77
|
+
session,
|
|
78
|
+
messages,
|
|
79
|
+
participants,
|
|
80
|
+
compressions,
|
|
81
|
+
streaming,
|
|
82
|
+
}: {
|
|
83
|
+
session: ChatSession;
|
|
84
|
+
messages: ChatMessage[];
|
|
85
|
+
participants: ChatParticipant[];
|
|
86
|
+
compressions: ContextCompression[];
|
|
87
|
+
streaming: boolean;
|
|
88
|
+
}) {
|
|
89
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
90
|
+
const positionedSessionRef = useRef<{ id: number | undefined; messageCount: number } | null>(null);
|
|
91
|
+
const updateStreaming = useStore((s) => s.streaming.sessionId === session.id);
|
|
92
|
+
const t = useT();
|
|
93
|
+
const compressionsByEndMessageId = useMemo(
|
|
94
|
+
() => new Map(compressions.map((compression) => [compression.endMessageId, compression])),
|
|
95
|
+
[compressions],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const el = scrollRef.current;
|
|
100
|
+
// 注册滚动容器,供「对话⇄队列」双向联动(registerChatEl 内部会重置抑制标志)
|
|
101
|
+
registerChatEl(el);
|
|
102
|
+
return () => registerChatEl(null); // 卸载时解除引用,避免跨会话联动到已卸载的容器
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
// 进入或切换会话时直接定位到最新消息,不播放滚动动画。
|
|
106
|
+
useLayoutEffect(() => {
|
|
107
|
+
const el = scrollRef.current;
|
|
108
|
+
if (!el) return;
|
|
109
|
+
const positioned = positionedSessionRef.current;
|
|
110
|
+
const enteringSession = positioned?.id !== session.id;
|
|
111
|
+
const historyJustLoaded = positioned != null && positioned.id === session.id && positioned.messageCount === 0 && messages.length > 0;
|
|
112
|
+
if (!enteringSession && !historyJustLoaded) return;
|
|
113
|
+
el.scrollTop = el.scrollHeight;
|
|
114
|
+
positionedSessionRef.current = { id: session.id, messageCount: messages.length };
|
|
115
|
+
}, [session.id, messages.length]);
|
|
116
|
+
|
|
117
|
+
// 自动滚动到底(新消息 / 流式更新时):群聊流式生成时同样跟随最新内容
|
|
118
|
+
const isGroup = session.mode === 'GROUP';
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const el = scrollRef.current;
|
|
121
|
+
if (!el) return;
|
|
122
|
+
if (positionedSessionRef.current?.id !== session.id) return;
|
|
123
|
+
const selection = document.getSelection();
|
|
124
|
+
if (selection && !selection.isCollapsed) return;
|
|
125
|
+
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
|
|
126
|
+
// 群聊:仅在已处于底部或正在流式生成时自动吸附(流式画布增长时继续跟随),
|
|
127
|
+
// 用户主动向上回看时不打断
|
|
128
|
+
if (!isGroup || nearBottom || updateStreaming) {
|
|
129
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
130
|
+
}
|
|
131
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
132
|
+
}, [messages.length, updateStreaming, messages[messages.length - 1]?.content.length, isGroup]);
|
|
133
|
+
|
|
134
|
+
const npcId = session.associatedId;
|
|
135
|
+
const npc = useStore((s) => s.npcs.find((n) => n.id === npcId));
|
|
136
|
+
|
|
137
|
+
// 工具调用结果按 toolCallId 分组;已由 assistant 消息工具卡片展示的调用 id,其结果并入卡片内部渲染
|
|
138
|
+
const toolResultsByCallId = useMemo(() => {
|
|
139
|
+
const map = new Map<string, ChatMessage[]>();
|
|
140
|
+
for (const m of messages) {
|
|
141
|
+
if (m.role === 'tool' && m.toolCallId) {
|
|
142
|
+
const arr = map.get(m.toolCallId);
|
|
143
|
+
if (arr) arr.push(m);
|
|
144
|
+
else map.set(m.toolCallId, [m]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return map;
|
|
148
|
+
}, [messages]);
|
|
149
|
+
const displayedToolCallIds = useMemo(() => {
|
|
150
|
+
const ids = new Set<string>();
|
|
151
|
+
for (const m of messages) {
|
|
152
|
+
if (m.role !== 'assistant') continue;
|
|
153
|
+
try {
|
|
154
|
+
for (const tc of JSON.parse(m.toolCallsJson || '[]') as ToolCallRecord[]) {
|
|
155
|
+
if (tc.id) ids.add(tc.id);
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
/* ignore */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return ids;
|
|
162
|
+
}, [messages]);
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div className="chat-scroll" ref={scrollRef} onScroll={onChatScroll}>
|
|
166
|
+
<div className="chat-col">
|
|
167
|
+
{messages.length === 0 && (
|
|
168
|
+
<div className="empty-state fade-up">
|
|
169
|
+
<div className="big">🫖</div>
|
|
170
|
+
<div>
|
|
171
|
+
{session.mode === 'STANDARD' && <><b>{t('chat.emptyStandardTitle')}</b><br />{t('chat.emptyStandardSub')}</>}
|
|
172
|
+
{session.mode === 'NPC' && <><b>{t('chat.emptyNpcTitle', { name: npc?.name ?? t('chat.speakerChar') })}</b><br />{npc?.greeting}</>}
|
|
173
|
+
{session.mode === 'GROUP' && <><b>{t('chat.emptyGroupTitle')}</b><br />{t('chat.emptyGroupSub')}</>}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
{messages.map((m) => (
|
|
178
|
+
<Fragment key={m.id}>
|
|
179
|
+
<MessageBubble
|
|
180
|
+
msg={m}
|
|
181
|
+
session={session}
|
|
182
|
+
participants={participants}
|
|
183
|
+
loopIndex={isGroup ? (m.loopIndex ?? 0) : null}
|
|
184
|
+
streaming={streaming && m.id === lastMsgId(messages)}
|
|
185
|
+
toolResultsByCallId={toolResultsByCallId}
|
|
186
|
+
suppressedResultIds={displayedToolCallIds}
|
|
187
|
+
/>
|
|
188
|
+
{compressionsByEndMessageId.has(m.id!) && (
|
|
189
|
+
<div className="compression-banner-wrap">
|
|
190
|
+
<div
|
|
191
|
+
className="sys-banner fade-up compression-banner"
|
|
192
|
+
onContextMenu={(e) => {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
const compression = compressionsByEndMessageId.get(m.id!);
|
|
195
|
+
if (compression) openCompressionMenu(e, compression.summary, compression, t('chat.contextCompressed'));
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
⟐ {t('chat.contextCompressed')}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
</Fragment>
|
|
203
|
+
))}
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function lastMsgId(messages: ChatMessage[]): number | undefined {
|
|
210
|
+
return messages.length > 0 ? messages[messages.length - 1].id : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ============================================================
|
|
214
|
+
// 单条消息气泡
|
|
215
|
+
// ============================================================
|
|
216
|
+
|
|
217
|
+
function MessageBubble({
|
|
218
|
+
msg,
|
|
219
|
+
session,
|
|
220
|
+
participants,
|
|
221
|
+
streaming,
|
|
222
|
+
loopIndex,
|
|
223
|
+
toolResultsByCallId,
|
|
224
|
+
suppressedResultIds,
|
|
225
|
+
}: {
|
|
226
|
+
msg: ChatMessage;
|
|
227
|
+
session: ChatSession;
|
|
228
|
+
participants: ChatParticipant[];
|
|
229
|
+
streaming: boolean;
|
|
230
|
+
/** 群聊循环号(0 起);非群聊为 null */
|
|
231
|
+
loopIndex: number | null;
|
|
232
|
+
/** 工具结果按 toolCallId 分组(用于并入发起它的调用卡片内部展示) */
|
|
233
|
+
toolResultsByCallId: Map<string, ChatMessage[]>;
|
|
234
|
+
/** 已在 assistant 消息工具卡片内展示结果的 toolCallId 集合(独立结果行不再重复渲染) */
|
|
235
|
+
suppressedResultIds: Set<string>;
|
|
236
|
+
}) {
|
|
237
|
+
const t = useT();
|
|
238
|
+
const providers = useStore((s) => s.providers);
|
|
239
|
+
const turnStreaming = useStore((s) => s.streaming.sessionId === session.id);
|
|
240
|
+
// System 消息(/new 标记)
|
|
241
|
+
if (msg.role === 'system') {
|
|
242
|
+
return (
|
|
243
|
+
<div className="sys-banner fade-up" data-loop={loopIndex ?? undefined}>⟐ {msg.content}</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 工具消息
|
|
248
|
+
if (msg.role === 'tool') {
|
|
249
|
+
// 该结果已并入发起它的工具调用卡片内部 → 不再独立渲染
|
|
250
|
+
if (msg.toolCallId && suppressedResultIds.has(msg.toolCallId)) return null;
|
|
251
|
+
const isError = msg.content.startsWith('ERROR:') || msg.content.startsWith('CANCELLED:');
|
|
252
|
+
// 展示类工具:结果里带有 DISPLAY_REF 前缀,收起前缀展示干净的摘要
|
|
253
|
+
const displayPayload = msg.displayRef ? parseStoredDisplayRef(msg.displayRef) : null;
|
|
254
|
+
const displayContent = displayPayload
|
|
255
|
+
? (msg.content.replace(/^DISPLAY_REF: [^\n]*\n?/, '') || t('display.view'))
|
|
256
|
+
: msg.content;
|
|
257
|
+
return (
|
|
258
|
+
<div className="msg-row tool-row fade-up" data-loop={loopIndex ?? undefined} data-speaker={msg.speakerParticipantId != null ? String(msg.speakerParticipantId) : undefined}>
|
|
259
|
+
<div className="msg-body">
|
|
260
|
+
<div className={`tool-result ${isError ? 'err' : ''}`}>
|
|
261
|
+
<Icon name={isError ? 'cancel' : 'check'} size={13} />
|
|
262
|
+
<span className="mono">{displayContent.slice(0, 200)}{displayContent.length > 200 ? '…' : ''}</span>
|
|
263
|
+
</div>
|
|
264
|
+
{/* 展示类工具(file_display):保留「查看」按钮,随时重开已展示的文件 */}
|
|
265
|
+
{msg.displayRef && <FileDisplayViewButton displayRef={msg.displayRef} />}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const toolCalls = useMemo(() => {
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(msg.toolCallsJson || '[]') as ToolCallRecord[];
|
|
274
|
+
} catch {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
}, [msg.toolCallsJson]);
|
|
278
|
+
|
|
279
|
+
// ---- 内联编辑状态(右键菜单 → 编辑消息,直接在气泡内编辑,不弹窗) ----
|
|
280
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
281
|
+
useEffect(() => {
|
|
282
|
+
const fn = () => {
|
|
283
|
+
const isTarget = editingMsgState?.msgId === msg.id;
|
|
284
|
+
setIsEditing(isTarget);
|
|
285
|
+
};
|
|
286
|
+
editingMsgListeners.push(fn);
|
|
287
|
+
return () => {
|
|
288
|
+
editingMsgListeners = editingMsgListeners.filter((f) => f !== fn);
|
|
289
|
+
};
|
|
290
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
291
|
+
}, [msg.id]);
|
|
292
|
+
|
|
293
|
+
const isUser = msg.role === 'user';
|
|
294
|
+
const speaker = msg.speakerParticipantId != null
|
|
295
|
+
? participants.find((p) => p.participantId === msg.speakerParticipantId)
|
|
296
|
+
: undefined;
|
|
297
|
+
const speakerName = msg.speakerName ?? (isUser ? t('common.user') : session.mode === 'NPC' ? t('chat.speakerChar') : t('chat.speakerAssistant'));
|
|
298
|
+
const npcHue = speaker?.npcId ? useStore((s) => s.npcs.find((n) => n.id === speaker.npcId)?.avatarColorOrdinal ?? 0) : 0;
|
|
299
|
+
const npcAvatar = speaker?.npcId ? useStore((s) => s.npcs.find((n) => n.id === speaker.npcId)?.avatarDataUrl ?? null) : null;
|
|
300
|
+
const visibleContent = isUser ? msg.content : trimEdgeNewlines(msg.content);
|
|
301
|
+
const displayVisibleContent = session.mode === 'GROUP'
|
|
302
|
+
? visibleContent.replace(/@(?:用户|用戶)(?=\s|[,。!?,.!?]|$)/g, '@User')
|
|
303
|
+
: visibleContent;
|
|
304
|
+
const displayContent = msg.displayHandoffMention
|
|
305
|
+
? `${displayVisibleContent}${displayVisibleContent ? ' ' : ''}@User`
|
|
306
|
+
: displayVisibleContent;
|
|
307
|
+
|
|
308
|
+
if (isUser) {
|
|
309
|
+
return <UserBubble msg={msg} session={session} editing={isEditing} loopIndex={loopIndex} />;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 用于「点击队列发言 → 对话定位」:NPC 用 participantId,玩家/未知用 null(定位到循环首条)
|
|
313
|
+
const speakerKey = msg.speakerParticipantId != null ? String(msg.speakerParticipantId) : null;
|
|
314
|
+
|
|
315
|
+
// 群聊:NPC 发言的 @台词 高亮(斜体加粗 + 特殊颜色)
|
|
316
|
+
const mentionNames = session.mode === 'GROUP'
|
|
317
|
+
? participants
|
|
318
|
+
.filter((p) => p.participantId !== msg.speakerParticipantId)
|
|
319
|
+
.map((p) => p.kind === 'PLAYER' ? 'User' : p.displayName)
|
|
320
|
+
: [];
|
|
321
|
+
|
|
322
|
+
if (isEditing) {
|
|
323
|
+
return (
|
|
324
|
+
<div className="msg-row fade-up" data-loop={loopIndex ?? undefined} data-speaker={speakerKey ?? undefined}>
|
|
325
|
+
<Avatar name={speakerName} colorOrdinal={npcHue} imageUrl={npcAvatar} />
|
|
326
|
+
<div className="msg-body">
|
|
327
|
+
<div className="msg-head">
|
|
328
|
+
<span className="msg-name">{speakerName}</span>
|
|
329
|
+
<span className="msg-time">{fmtTime(msg.timestamp)}</span>
|
|
330
|
+
</div>
|
|
331
|
+
<BubbleEditor msg={msg} session={session} onDone={stopEditingMsg} />
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div
|
|
339
|
+
className={`msg-row fade-up ${streaming ? 'streaming' : ''}`}
|
|
340
|
+
data-loop={loopIndex ?? undefined}
|
|
341
|
+
data-speaker={speakerKey ?? undefined}
|
|
342
|
+
onContextMenu={(e) => { e.preventDefault(); openMsgMenu(e, msg, session); }}
|
|
343
|
+
>
|
|
344
|
+
<Avatar name={speakerName} colorOrdinal={npcHue} imageUrl={npcAvatar} />
|
|
345
|
+
<div className="msg-body">
|
|
346
|
+
<div className="msg-head">
|
|
347
|
+
<span className="msg-name">{speakerName}</span>
|
|
348
|
+
{msg.modelUsed && !msg.modelUsed.startsWith('scheduled:') && <span className="msg-model" title={msg.modelUsed}>{displayModelName(providers, msg.modelUsed)}</span>}
|
|
349
|
+
<span className="msg-time">{fmtTime(msg.timestamp)}</span>
|
|
350
|
+
</div>
|
|
351
|
+
{msg.thinkingContent && streaming && (
|
|
352
|
+
<Collapse title={t('chat.thinking')} icon="🧠" accent="think" preview={msg.thinkingContent} live>
|
|
353
|
+
<Markdown text={msg.thinkingContent} mathEnabled={false} />
|
|
354
|
+
</Collapse>
|
|
355
|
+
)}
|
|
356
|
+
{msg.thinkingContent && !streaming && (
|
|
357
|
+
<Collapse title={t('chat.thinking')} icon="🧠" accent="think" defaultOpen={false} preview={msg.thinkingContent}>
|
|
358
|
+
<Markdown text={msg.thinkingContent} mathEnabled={false} />
|
|
359
|
+
</Collapse>
|
|
360
|
+
)}
|
|
361
|
+
{toolCalls.length > 0 && (
|
|
362
|
+
<div className="tool-calls">
|
|
363
|
+
{toolCalls.map((tc, i) => (
|
|
364
|
+
<ToolCallCard
|
|
365
|
+
key={i}
|
|
366
|
+
tc={tc}
|
|
367
|
+
executing={turnStreaming && (toolResultsByCallId.get(tc.id) ?? []).length === 0}
|
|
368
|
+
results={toolResultsByCallId.get(tc.id) ?? []}
|
|
369
|
+
/>
|
|
370
|
+
))}
|
|
371
|
+
</div>
|
|
372
|
+
)}
|
|
373
|
+
{(displayContent || msg.attachments.length > 0) && (
|
|
374
|
+
<div className={`bubble ${isUser ? 'bubble-user' : ''}`}>
|
|
375
|
+
{msg.attachments.length > 0 && (
|
|
376
|
+
<div className="attachments">
|
|
377
|
+
{msg.attachments.map((a, i) => (
|
|
378
|
+
<AttachCard key={i} name={msg.attachmentInfos?.[i]?.displayName || attachmentName(a)} />
|
|
379
|
+
))}
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
<div className="bubble-content-row">
|
|
383
|
+
<div className="bubble-text">
|
|
384
|
+
{displayContent ? <Markdown text={displayContent} mentionNames={mentionNames} /> : streaming && <span className="stream-cursor" />}
|
|
385
|
+
</div>
|
|
386
|
+
{/* 编辑按钮:位于正文气泡内最右侧,铅笔图标 */}
|
|
387
|
+
<button className="msg-edit-btn" title={t('chat.editMsg')} onClick={() => startEditingMsg(msg.id!)}>
|
|
388
|
+
<Icon name="pencil" size={13} />
|
|
389
|
+
</button>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
{(msg.latencyMs != null || msg.promptTokens > 0 || msg.completionTokens > 0) && !streaming && (
|
|
394
|
+
<div className="msg-metrics">{formatMetrics(msg)}</div>
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 气泡内联编辑器(替换弹窗编辑,支持管理附件) */
|
|
402
|
+
function BubbleEditor({
|
|
403
|
+
msg,
|
|
404
|
+
session,
|
|
405
|
+
onDone,
|
|
406
|
+
}: {
|
|
407
|
+
msg: ChatMessage;
|
|
408
|
+
session: ChatSession;
|
|
409
|
+
onDone: () => void;
|
|
410
|
+
}) {
|
|
411
|
+
const t = useT();
|
|
412
|
+
const [text, setText] = useState(msg.content);
|
|
413
|
+
const [attachments, setAttachments] = useState<{ dataUrl: string; name: string }[]>(
|
|
414
|
+
msg.attachments.map((a, i) => ({ dataUrl: a, name: msg.attachmentInfos?.[i]?.displayName || attachmentName(a) }))
|
|
415
|
+
);
|
|
416
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
417
|
+
const editMessage = useStore((s) => s.editMessage);
|
|
418
|
+
const saveMessageOnly = useStore((s) => s.saveMessageOnly);
|
|
419
|
+
const streaming = useStore((s) => s.streaming.sessionId === session.id);
|
|
420
|
+
// 玩家(user)消息:保存并重新生成;NPC(assistant)消息:仅保存、不触发生成
|
|
421
|
+
const isUserMsg = msg.role === 'user';
|
|
422
|
+
|
|
423
|
+
const doSave = async () => {
|
|
424
|
+
if (!text.trim() || streaming) return;
|
|
425
|
+
// 立即退回消息气泡状态;保存 + 重新生成在后台继续
|
|
426
|
+
onDone();
|
|
427
|
+
await editMessage(
|
|
428
|
+
msg.id!,
|
|
429
|
+
text,
|
|
430
|
+
session.id!,
|
|
431
|
+
attachments.map((a) => a.dataUrl),
|
|
432
|
+
attachments.map((a) => a.name)
|
|
433
|
+
);
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const doSaveOnly = async () => {
|
|
437
|
+
if (!text.trim() || streaming) return;
|
|
438
|
+
onDone();
|
|
439
|
+
await saveMessageOnly(
|
|
440
|
+
msg.id!,
|
|
441
|
+
text,
|
|
442
|
+
session.id!,
|
|
443
|
+
attachments.map((a) => a.dataUrl),
|
|
444
|
+
attachments.map((a) => a.name)
|
|
445
|
+
);
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// 主按钮动作:玩家 → 保存并重新生成;NPC → 仅保存
|
|
449
|
+
const primaryAction = isUserMsg ? doSave : doSaveOnly;
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<div className="bubble-editor">
|
|
453
|
+
<textarea
|
|
454
|
+
className="textarea mono"
|
|
455
|
+
rows={Math.max(3, Math.min(12, text.split('\n').length))}
|
|
456
|
+
value={text}
|
|
457
|
+
onChange={(e) => setText(e.target.value)}
|
|
458
|
+
autoFocus
|
|
459
|
+
onKeyDown={(e) => {
|
|
460
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
461
|
+
e.preventDefault();
|
|
462
|
+
primaryAction();
|
|
463
|
+
}
|
|
464
|
+
if (e.key === 'Escape') {
|
|
465
|
+
e.preventDefault();
|
|
466
|
+
onDone();
|
|
467
|
+
}
|
|
468
|
+
}}
|
|
469
|
+
/>
|
|
470
|
+
{/* 附件管理 */}
|
|
471
|
+
{attachments.length > 0 && (
|
|
472
|
+
<div className="attachments" style={{ marginBottom: 0 }}>
|
|
473
|
+
{attachments.map((a, i) => (
|
|
474
|
+
<AttachCard
|
|
475
|
+
key={i}
|
|
476
|
+
name={a.name}
|
|
477
|
+
onRemove={() => setAttachments(attachments.filter((_, j) => j !== i))}
|
|
478
|
+
/>
|
|
479
|
+
))}
|
|
480
|
+
</div>
|
|
481
|
+
)}
|
|
482
|
+
<div className="bubble-editor-actions">
|
|
483
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
484
|
+
<input
|
|
485
|
+
ref={fileRef}
|
|
486
|
+
type="file"
|
|
487
|
+
accept="image/*,video/*"
|
|
488
|
+
multiple
|
|
489
|
+
hidden
|
|
490
|
+
onChange={(e) => {
|
|
491
|
+
const files = e.target.files;
|
|
492
|
+
if (files) {
|
|
493
|
+
Array.from(files).forEach((f) => readFileAsDataUrl(f).then((url) => setAttachments((prev) => [...prev, { dataUrl: url, name: f.name }])));
|
|
494
|
+
}
|
|
495
|
+
e.target.value = '';
|
|
496
|
+
}}
|
|
497
|
+
/>
|
|
498
|
+
<button className="btn btn-sm" onClick={() => fileRef.current?.click()}>
|
|
499
|
+
<Icon name="image" size={12} /> {t('chat.addAttachment')}
|
|
500
|
+
</button>
|
|
501
|
+
</div>
|
|
502
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
503
|
+
<span className="bubble-editor-hint">{t('chat.saveHint')}</span>
|
|
504
|
+
<button className="btn btn-sm" onClick={onDone}>{t('common.cancel')}</button>
|
|
505
|
+
<button className="btn btn-sm btn-primary" disabled={!text.trim() || streaming} onClick={primaryAction}>
|
|
506
|
+
{isUserMsg ? t('chat.saveRegenerate') : t('chat.saveOnly')}
|
|
507
|
+
</button>
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function UserBubble({ msg, session, editing, loopIndex }: { msg: ChatMessage; session: ChatSession; editing?: boolean; loopIndex?: number | null }) {
|
|
515
|
+
const t = useT();
|
|
516
|
+
const participants = useStore((s) => (session.id != null ? (s.participants[session.id] ?? []) : []));
|
|
517
|
+
// 群聊中「用户消息」的 @角色名 也作为特殊指令高亮(点名)
|
|
518
|
+
const mentionNames =
|
|
519
|
+
session.mode === 'GROUP'
|
|
520
|
+
? participants.filter((p) => p.kind === 'NPC').map((p) => p.displayName)
|
|
521
|
+
: [];
|
|
522
|
+
return (
|
|
523
|
+
<div className="msg-row user-row fade-up" data-loop={loopIndex ?? undefined} data-speaker="player" onContextMenu={(e) => { e.preventDefault(); openMsgMenu(e, msg, session); }}>
|
|
524
|
+
<div className="avatar sm user-avatar">{t('common.me')}</div>
|
|
525
|
+
<div className="msg-body">
|
|
526
|
+
<div className="msg-head right">
|
|
527
|
+
<span className="msg-time">{fmtTime(msg.timestamp)}</span>
|
|
528
|
+
<span className="msg-name">{t('common.you')}</span>
|
|
529
|
+
</div>
|
|
530
|
+
{editing ? (
|
|
531
|
+
<BubbleEditor msg={msg} session={session} onDone={stopEditingMsg} />
|
|
532
|
+
) : (
|
|
533
|
+
<div className="bubble bubble-user">
|
|
534
|
+
{msg.attachments.length > 0 && (
|
|
535
|
+
<div className="attachments">
|
|
536
|
+
{msg.attachments.map((a, i) => (
|
|
537
|
+
<AttachCard key={i} name={msg.attachmentInfos?.[i]?.displayName || attachmentName(a)} />
|
|
538
|
+
))}
|
|
539
|
+
</div>
|
|
540
|
+
)}
|
|
541
|
+
<div className="bubble-content-row">
|
|
542
|
+
<div className="bubble-text">
|
|
543
|
+
{msg.content && <Markdown text={msg.content} mentionNames={mentionNames} />}
|
|
544
|
+
</div>
|
|
545
|
+
<button className="msg-edit-btn" title={t('chat.editMsg')} onClick={() => startEditingMsg(msg.id!)}>
|
|
546
|
+
<Icon name="pencil" size={13} />
|
|
547
|
+
</button>
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
)}
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function ToolCallCard({ tc, executing, results }: { tc: ToolCallRecord; executing: boolean; results: ChatMessage[] }) {
|
|
557
|
+
const t = useT();
|
|
558
|
+
const [open, setOpen] = useState(false);
|
|
559
|
+
const formattedArgs = useMemo(() => {
|
|
560
|
+
if (!open) return '';
|
|
561
|
+
return prettyJson(tc.argumentsJson);
|
|
562
|
+
}, [open, tc.argumentsJson]);
|
|
563
|
+
const hasResult = results.length > 0;
|
|
564
|
+
const isError = results.some((r) => r.content.startsWith('ERROR:') || r.content.startsWith('CANCELLED:'));
|
|
565
|
+
// 展示类工具(file_display)结果:去掉 DISPLAY_REF / OK 前缀再拼展示文本
|
|
566
|
+
const displayRef = results.find((r) => r.displayRef)?.displayRef ?? null;
|
|
567
|
+
const resultText = results
|
|
568
|
+
.map((r) => {
|
|
569
|
+
if (r.displayRef && parseStoredDisplayRef(r.displayRef)) {
|
|
570
|
+
return r.content.replace(/^DISPLAY_REF: [^\n]*\n?/, '');
|
|
571
|
+
}
|
|
572
|
+
return r.content;
|
|
573
|
+
})
|
|
574
|
+
.join('\n');
|
|
575
|
+
return (
|
|
576
|
+
<div className={`tool-card ${executing ? 'executing' : ''} ${hasResult ? (isError ? 'has-error' : 'has-result') : ''}`}>
|
|
577
|
+
<button className="tool-card-head" onClick={() => setOpen(!open)}>
|
|
578
|
+
<span className="tool-card-icon">
|
|
579
|
+
{executing ? <span className="spinner" style={{ width: 12, height: 12 }} /> : hasResult ? <Icon name={isError ? 'cancel' : 'check'} size={13} /> : '🔧'}
|
|
580
|
+
</span>
|
|
581
|
+
<span className="tool-card-name">{t('chat.toolCall', { name: tc.name })}</span>
|
|
582
|
+
<span className="collp-arrow" style={{ transform: open ? 'rotate(180deg)' : undefined }}>▾</span>
|
|
583
|
+
</button>
|
|
584
|
+
{/* 未展开时展示工具调用结果摘要行(单行折叠,便于扫读) */}
|
|
585
|
+
{hasResult && !open && (
|
|
586
|
+
<div className={`tool-card-result-line ${isError ? 'err' : ''}`}>
|
|
587
|
+
<Icon name={isError ? 'cancel' : 'check'} size={12} />
|
|
588
|
+
<span className="mono">{resultText.trim().split('\n').filter(Boolean).slice(0, 2).join(' · ') || resultText.replace(/\s+/g, ' ').slice(0, 160)}{resultText.length > 160 ? '…' : ''}</span>
|
|
589
|
+
{displayRef && <FileDisplayViewButton displayRef={displayRef} />}
|
|
590
|
+
</div>
|
|
591
|
+
)}
|
|
592
|
+
{open && (
|
|
593
|
+
<div className="tool-card-body">
|
|
594
|
+
<pre className="tool-args mono">{formattedArgs}</pre>
|
|
595
|
+
{hasResult && (
|
|
596
|
+
<div className={`tool-result ${isError ? 'err' : ''}`}>
|
|
597
|
+
<Icon name={isError ? 'cancel' : 'check'} size={13} />
|
|
598
|
+
<span className="mono">{resultText.length > 2000 ? resultText.slice(0, 2000) + '…' : resultText}</span>
|
|
599
|
+
{displayRef && <FileDisplayViewButton displayRef={displayRef} />}
|
|
600
|
+
</div>
|
|
601
|
+
)}
|
|
602
|
+
</div>
|
|
603
|
+
)}
|
|
604
|
+
</div>
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ============================================================
|
|
609
|
+
// 输入区
|
|
610
|
+
// ============================================================
|
|
611
|
+
|
|
612
|
+
export function ChatInput({ sessionId }: { sessionId: number }) {
|
|
613
|
+
const t = useT();
|
|
614
|
+
const [text, setText] = useState('');
|
|
615
|
+
const [attachments, setAttachments] = useState<{ dataUrl: string; name: string }[]>([]);
|
|
616
|
+
const [showCmd, setShowCmd] = useState(false);
|
|
617
|
+
const [showMention, setShowMention] = useState(false);
|
|
618
|
+
const [mentionQuery, setMentionQuery] = useState('');
|
|
619
|
+
const mentionRef = useRef<HTMLDivElement>(null);
|
|
620
|
+
const richRef = useRef<HTMLDivElement>(null);
|
|
621
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
622
|
+
const [typing, setTyping] = useState(false);
|
|
623
|
+
const [showModels, setShowModels] = useState(false);
|
|
624
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
625
|
+
const sendMessage = useStore((s) => s.sendMessage);
|
|
626
|
+
const streaming = useStore((s) => s.streaming.sessionId != null);
|
|
627
|
+
const stopStreaming = useStore((s) => s.stopStreaming);
|
|
628
|
+
const session = useStore((s) => s.sessions.find((x) => x.id === sessionId));
|
|
629
|
+
const providers = useStore((s) => s.providers);
|
|
630
|
+
const defaultModel = useStore((s) => s.settings?.defaultModel?.trim() || '');
|
|
631
|
+
const defaultProviderId = useStore((s) => s.settings?.defaultProviderId ?? null);
|
|
632
|
+
|
|
633
|
+
const canSend = text.trim().length > 0 || attachments.length > 0;
|
|
634
|
+
|
|
635
|
+
const doSend = () => {
|
|
636
|
+
if (!canSend || typing) return;
|
|
637
|
+
if (streaming) return;
|
|
638
|
+
const sendingText = text.trim();
|
|
639
|
+
const sendingAttachments = [...attachments];
|
|
640
|
+
setText('');
|
|
641
|
+
setAttachments([]);
|
|
642
|
+
setShowCmd(false);
|
|
643
|
+
setShowMention(false);
|
|
644
|
+
setTyping(true);
|
|
645
|
+
sendMessage(
|
|
646
|
+
sendingText,
|
|
647
|
+
sendingAttachments.map((a) => a.dataUrl),
|
|
648
|
+
sendingAttachments.map((a) => a.name)
|
|
649
|
+
).finally(() => {
|
|
650
|
+
setTyping(false);
|
|
651
|
+
});
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
// 输入区 @ 自动补全(仅在群聊、且匹配到成员时展示;不支持 @自己)
|
|
655
|
+
const sessionParticipants = useStore((s) => s.participants[sessionId] ?? []);
|
|
656
|
+
const groupMembers = useMemo(() => {
|
|
657
|
+
return sessionParticipants
|
|
658
|
+
.filter((p) => p.kind === 'NPC')
|
|
659
|
+
.map((p) => p.displayName)
|
|
660
|
+
.sort((a, b) => a.length - b.length);
|
|
661
|
+
}, [sessionParticipants]);
|
|
662
|
+
const mentionCandidates = useMemo(() => {
|
|
663
|
+
if (!showMention || session?.mode !== 'GROUP') return [];
|
|
664
|
+
const q = mentionQuery.trim().toLowerCase();
|
|
665
|
+
return groupMembers.filter((n) => !q || n.toLowerCase().includes(q));
|
|
666
|
+
}, [showMention, mentionQuery, groupMembers, session?.mode]);
|
|
667
|
+
const composerRichNodes = useMemo(
|
|
668
|
+
() => renderComposerRichNodes(text, session?.mode === 'GROUP' ? groupMembers : []),
|
|
669
|
+
[session?.mode, text, groupMembers]
|
|
670
|
+
);
|
|
671
|
+
const [activeMentionIdx, setActiveMentionIdx] = useState(0);
|
|
672
|
+
const cmdRef = useRef<HTMLDivElement>(null);
|
|
673
|
+
const [activeCmdIdx, setActiveCmdIdx] = useState(0);
|
|
674
|
+
|
|
675
|
+
const closeMention = () => {
|
|
676
|
+
setShowMention(false);
|
|
677
|
+
setMentionQuery('');
|
|
678
|
+
setActiveMentionIdx(0);
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const applyMention = (name: string) => {
|
|
682
|
+
if (!textareaRef.current) return;
|
|
683
|
+
const el = textareaRef.current;
|
|
684
|
+
const pos = el.selectionStart ?? text.length;
|
|
685
|
+
const before = text.slice(0, pos);
|
|
686
|
+
const after = text.slice(pos);
|
|
687
|
+
const atIdx = before.lastIndexOf('@');
|
|
688
|
+
if (atIdx < 0) return;
|
|
689
|
+
const hasSpaceAfter = /^\s/.test(after);
|
|
690
|
+
const suffix = hasSpaceAfter ? '' : ' ';
|
|
691
|
+
const next = before.slice(0, atIdx) + `@${name}` + suffix + after;
|
|
692
|
+
setText(next);
|
|
693
|
+
closeMention();
|
|
694
|
+
requestAnimationFrame(() => {
|
|
695
|
+
el.focus();
|
|
696
|
+
const caret = before.slice(0, atIdx).length + name.length + 1 + suffix.length;
|
|
697
|
+
el.setSelectionRange(caret, caret);
|
|
698
|
+
});
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const detectMention = (value: string, caret: number) => {
|
|
702
|
+
if (session?.mode !== 'GROUP') {
|
|
703
|
+
setShowMention(false);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (caret < 0 || caret > value.length) {
|
|
707
|
+
setShowMention(false);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const before = value.slice(0, caret);
|
|
711
|
+
const m = /(?:^|[^A-Za-z0-9_@])(@[\u4e00-\u9fa5A-Za-z0-9_]{0,30})$/.exec(before);
|
|
712
|
+
if (!m) {
|
|
713
|
+
setShowMention(false);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const q = m[1].slice(1).toLowerCase();
|
|
717
|
+
// 仅在查询词变化(含面板首次打开)时重置选中项;
|
|
718
|
+
// 否则方向键选中后紧跟的 onKeyUp 会把高亮打回第一项(“选不中”/“跳回去”问题)
|
|
719
|
+
if (!(showMention && q === mentionQuery)) setActiveMentionIdx(0);
|
|
720
|
+
setMentionQuery(q);
|
|
721
|
+
setShowMention(true);
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
// 拖放 / 粘贴附件
|
|
725
|
+
useEffect(() => {
|
|
726
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
727
|
+
if (streaming) return;
|
|
728
|
+
const files = e.clipboardData?.files;
|
|
729
|
+
if (files && files.length > 0 && files[0].type.startsWith('image')) {
|
|
730
|
+
e.preventDefault();
|
|
731
|
+
const f = files[0];
|
|
732
|
+
readFileAsDataUrl(f).then((url) => setAttachments((a) => [...a, { dataUrl: url, name: f.name || translate('chat.pasteImage', { ext: f.type.split('/')[1] || 'png' }) }]));
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
window.addEventListener('paste', onPaste);
|
|
736
|
+
return () => window.removeEventListener('paste', onPaste);
|
|
737
|
+
}, [streaming]);
|
|
738
|
+
|
|
739
|
+
useEffect(() => {
|
|
740
|
+
if (!streaming) return;
|
|
741
|
+
setShowCmd(false);
|
|
742
|
+
setShowMention(false);
|
|
743
|
+
setShowModels(false);
|
|
744
|
+
}, [streaming]);
|
|
745
|
+
|
|
746
|
+
// 点击自动补全面板外部时关闭
|
|
747
|
+
useEffect(() => {
|
|
748
|
+
if (!showMention) return;
|
|
749
|
+
const onDoc = (e: MouseEvent) => {
|
|
750
|
+
if (mentionRef.current && !mentionRef.current.contains(e.target as Node)) closeMention();
|
|
751
|
+
};
|
|
752
|
+
document.addEventListener('mousedown', onDoc);
|
|
753
|
+
return () => document.removeEventListener('mousedown', onDoc);
|
|
754
|
+
}, [showMention]);
|
|
755
|
+
|
|
756
|
+
// 键盘选中项滚动到可视区(@ 点名 / 魔法指令候选)
|
|
757
|
+
useEffect(() => {
|
|
758
|
+
if (!showMention || !mentionRef.current) return;
|
|
759
|
+
mentionRef.current.querySelector('.mention-item.active')?.scrollIntoView({ block: 'nearest' });
|
|
760
|
+
}, [activeMentionIdx, showMention]);
|
|
761
|
+
|
|
762
|
+
useEffect(() => {
|
|
763
|
+
if (!showCmd || !cmdRef.current) return;
|
|
764
|
+
cmdRef.current.querySelector('.cmd-item.active')?.scrollIntoView({ block: 'nearest' });
|
|
765
|
+
}, [activeCmdIdx, showCmd]);
|
|
766
|
+
|
|
767
|
+
// 输入区 / 魔法指令自动补全候选 + 键盘选中项(列表缩短时自动收敛)
|
|
768
|
+
const cmdCandidates = useMemo(() => suggestMagicCommands(text), [text]);
|
|
769
|
+
const cmdIdx = Math.min(activeCmdIdx, Math.max(0, cmdCandidates.length - 1));
|
|
770
|
+
|
|
771
|
+
const applyCommand = (cmd: string) => {
|
|
772
|
+
setText(cmd);
|
|
773
|
+
setShowCmd(false);
|
|
774
|
+
setActiveCmdIdx(0);
|
|
775
|
+
requestAnimationFrame(() => {
|
|
776
|
+
const el = textareaRef.current;
|
|
777
|
+
if (!el) return;
|
|
778
|
+
el.focus();
|
|
779
|
+
el.setSelectionRange(cmd.length, cmd.length);
|
|
780
|
+
});
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
return (
|
|
784
|
+
<div className="composer">
|
|
785
|
+
{attachments.length > 0 && (
|
|
786
|
+
<div className="attach-preview">
|
|
787
|
+
{attachments.map((a, i) => (
|
|
788
|
+
<AttachCard
|
|
789
|
+
key={i}
|
|
790
|
+
name={a.name}
|
|
791
|
+
onRemove={() => setAttachments(attachments.filter((_, j) => j !== i))}
|
|
792
|
+
/>
|
|
793
|
+
))}
|
|
794
|
+
</div>
|
|
795
|
+
)}
|
|
796
|
+
{showCmd && cmdCandidates.length > 0 && (
|
|
797
|
+
<div className="cmd-menu" ref={cmdRef}>
|
|
798
|
+
{cmdCandidates.map((c, i) => (
|
|
799
|
+
<button
|
|
800
|
+
key={c.text}
|
|
801
|
+
className={`cmd-item ${i === cmdIdx ? 'active' : ''}`}
|
|
802
|
+
onMouseEnter={() => setActiveCmdIdx(i)}
|
|
803
|
+
onClick={() => {
|
|
804
|
+
setText(c.text);
|
|
805
|
+
setShowCmd(false);
|
|
806
|
+
setActiveCmdIdx(0);
|
|
807
|
+
}}
|
|
808
|
+
>
|
|
809
|
+
<span className="cmd-text">{c.text}</span>
|
|
810
|
+
<span className="cmd-desc">{c.description}</span>
|
|
811
|
+
</button>
|
|
812
|
+
))}
|
|
813
|
+
</div>
|
|
814
|
+
)}
|
|
815
|
+
<div className="composer-box">
|
|
816
|
+
{/* 模型选择器:输入框左侧,弹出列表(非弹窗) */}
|
|
817
|
+
<div className="composer-model">
|
|
818
|
+
<button
|
|
819
|
+
className={`composer-model-btn ${showModels ? 'open' : ''}`}
|
|
820
|
+
title={t('chat.selectModel')}
|
|
821
|
+
disabled={streaming}
|
|
822
|
+
onClick={() => setShowModels(!showModels)}
|
|
823
|
+
>
|
|
824
|
+
<span className="composer-model-label" title={defaultModel}>{defaultModel ? displayModelName(providers, defaultModel, defaultProviderId) : t('chat.chooseModel')}</span>
|
|
825
|
+
</button>
|
|
826
|
+
{showModels && (
|
|
827
|
+
<ModelPickerPanel onClose={() => setShowModels(false)} />
|
|
828
|
+
)}
|
|
829
|
+
</div>
|
|
830
|
+
<button
|
|
831
|
+
className="icon-btn composer-attach"
|
|
832
|
+
title={t('chat.attachTip')}
|
|
833
|
+
disabled={streaming}
|
|
834
|
+
onClick={() => fileRef.current?.click()}
|
|
835
|
+
>
|
|
836
|
+
<Icon name="image" size={18} />
|
|
837
|
+
</button>
|
|
838
|
+
<input
|
|
839
|
+
ref={fileRef}
|
|
840
|
+
type="file"
|
|
841
|
+
accept="image/*,video/*"
|
|
842
|
+
multiple
|
|
843
|
+
hidden
|
|
844
|
+
disabled={streaming}
|
|
845
|
+
onChange={(e) => {
|
|
846
|
+
const files = e.target.files;
|
|
847
|
+
if (files) {
|
|
848
|
+
Array.from(files).forEach((f) => readFileAsDataUrl(f).then((url) => setAttachments((a) => [...a, { dataUrl: url, name: f.name }])));
|
|
849
|
+
}
|
|
850
|
+
e.target.value = '';
|
|
851
|
+
}}
|
|
852
|
+
/>
|
|
853
|
+
<div className="composer-input-wrap">
|
|
854
|
+
{composerRichNodes.length > 0 && (
|
|
855
|
+
<div
|
|
856
|
+
className="composer-rich"
|
|
857
|
+
ref={richRef}
|
|
858
|
+
aria-hidden
|
|
859
|
+
>
|
|
860
|
+
{composerRichNodes}
|
|
861
|
+
</div>
|
|
862
|
+
)}
|
|
863
|
+
<textarea
|
|
864
|
+
ref={textareaRef}
|
|
865
|
+
className={composerRichNodes.length > 0 ? 'with-rich' : ''}
|
|
866
|
+
value={text}
|
|
867
|
+
disabled={streaming}
|
|
868
|
+
onChange={(e) => {
|
|
869
|
+
const v = e.target.value;
|
|
870
|
+
setText(v);
|
|
871
|
+
const cmdOpen = v.startsWith('/') && !v.includes('\n');
|
|
872
|
+
setShowCmd(cmdOpen);
|
|
873
|
+
// 面板重新打开时选中回到第一项
|
|
874
|
+
if (cmdOpen && !showCmd) setActiveCmdIdx(0);
|
|
875
|
+
detectMention(v, e.target.selectionStart ?? v.length);
|
|
876
|
+
}}
|
|
877
|
+
onKeyDown={(e) => {
|
|
878
|
+
const composing = e.nativeEvent.isComposing;
|
|
879
|
+
// 输入法组词期间不劫持方向键/回车(否则拼音候选选择被破坏)
|
|
880
|
+
const mentionOpen = !composing && showMention && mentionCandidates.length > 0;
|
|
881
|
+
// 仅在输入仍是「前缀」时拦截回车/Tab;键入完整命令(如 /new)时回车直接发送
|
|
882
|
+
const cmdOpen =
|
|
883
|
+
!composing &&
|
|
884
|
+
showCmd &&
|
|
885
|
+
cmdCandidates.length > 0 &&
|
|
886
|
+
cmdCandidates.some((c) => c.text !== text);
|
|
887
|
+
if (mentionOpen || cmdOpen) {
|
|
888
|
+
if (e.key === 'ArrowDown') {
|
|
889
|
+
e.preventDefault();
|
|
890
|
+
if (mentionOpen) {
|
|
891
|
+
setActiveMentionIdx((idx) => (idx + 1) % mentionCandidates.length);
|
|
892
|
+
} else {
|
|
893
|
+
setActiveCmdIdx((idx) => (idx + 1) % cmdCandidates.length);
|
|
894
|
+
}
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (e.key === 'ArrowUp') {
|
|
898
|
+
e.preventDefault();
|
|
899
|
+
if (mentionOpen) {
|
|
900
|
+
setActiveMentionIdx((idx) => (idx - 1 + mentionCandidates.length) % mentionCandidates.length);
|
|
901
|
+
} else {
|
|
902
|
+
setActiveCmdIdx((idx) => (idx - 1 + cmdCandidates.length) % cmdCandidates.length);
|
|
903
|
+
}
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
907
|
+
e.preventDefault();
|
|
908
|
+
if (mentionOpen) {
|
|
909
|
+
applyMention(mentionCandidates[activeMentionIdx]);
|
|
910
|
+
} else {
|
|
911
|
+
applyCommand(cmdCandidates[cmdIdx].text);
|
|
912
|
+
}
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (e.key === 'Escape') {
|
|
916
|
+
e.preventDefault();
|
|
917
|
+
if (mentionOpen) {
|
|
918
|
+
closeMention();
|
|
919
|
+
} else {
|
|
920
|
+
setShowCmd(false);
|
|
921
|
+
setActiveCmdIdx(0);
|
|
922
|
+
}
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (e.key === 'Enter' && !e.shiftKey && !composing) {
|
|
927
|
+
e.preventDefault();
|
|
928
|
+
doSend();
|
|
929
|
+
}
|
|
930
|
+
if (e.key === 'Escape') {
|
|
931
|
+
setShowCmd(false);
|
|
932
|
+
setActiveCmdIdx(0);
|
|
933
|
+
closeMention();
|
|
934
|
+
}
|
|
935
|
+
}}
|
|
936
|
+
onScroll={(e) => {
|
|
937
|
+
const rich = richRef.current;
|
|
938
|
+
if (!rich) return;
|
|
939
|
+
rich.scrollTop = e.currentTarget.scrollTop;
|
|
940
|
+
rich.scrollLeft = e.currentTarget.scrollLeft;
|
|
941
|
+
}}
|
|
942
|
+
onClick={(e) => detectMention(text, e.currentTarget.selectionStart ?? text.length)}
|
|
943
|
+
onKeyUp={(e) => detectMention(text, e.currentTarget.selectionStart ?? text.length)}
|
|
944
|
+
placeholder={
|
|
945
|
+
session?.mode === 'GROUP'
|
|
946
|
+
? t('chat.phGroup')
|
|
947
|
+
: t('chat.phSingle')
|
|
948
|
+
}
|
|
949
|
+
rows={1}
|
|
950
|
+
style={{ height: 'auto', minHeight: 38 }}
|
|
951
|
+
/>
|
|
952
|
+
{showMention && mentionCandidates.length > 0 && (
|
|
953
|
+
<div className="mention-pop" ref={mentionRef}>
|
|
954
|
+
<div className="mention-pop-head">{t('chat.mentionHead')}</div>
|
|
955
|
+
{mentionCandidates.map((n, i) => (
|
|
956
|
+
<button
|
|
957
|
+
key={n}
|
|
958
|
+
className={`mention-item ${i === activeMentionIdx ? 'active' : ''}`}
|
|
959
|
+
onMouseEnter={() => { setActiveMentionIdx(i); }}
|
|
960
|
+
onClick={() => applyMention(n)}
|
|
961
|
+
>
|
|
962
|
+
<span className="mention-item-at">@</span>
|
|
963
|
+
{n}
|
|
964
|
+
</button>
|
|
965
|
+
))}
|
|
966
|
+
</div>
|
|
967
|
+
)}
|
|
968
|
+
</div>
|
|
969
|
+
{streaming ? (
|
|
970
|
+
<button className="btn btn-primary btn-round" onClick={stopStreaming} title={t('chat.stopTip')}>
|
|
971
|
+
<Icon name="stop" size={15} />
|
|
972
|
+
</button>
|
|
973
|
+
) : (
|
|
974
|
+
<button className="btn btn-primary btn-round" disabled={!canSend} onClick={doSend} title={t('chat.sendTip')}>
|
|
975
|
+
<Icon name="send" size={15} />
|
|
976
|
+
</button>
|
|
977
|
+
)}
|
|
978
|
+
</div>
|
|
979
|
+
<div className="composer-hint">
|
|
980
|
+
<span>{t('chat.hintSend')}</span>
|
|
981
|
+
<span className="composer-hint-right">{t('chat.hintMagic')}{session?.mode === 'GROUP' ? t('chat.hintMention') : ''}</span>
|
|
982
|
+
</div>
|
|
983
|
+
</div>
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** 模型选择下拉列表(弹出在输入框左侧上方) */
|
|
988
|
+
function ModelPickerPanel({ onClose }: { onClose: () => void }) {
|
|
989
|
+
const t = useT();
|
|
990
|
+
const settings = useStore((s) => s.settings);
|
|
991
|
+
const models = useStore((s) => s.modelsList);
|
|
992
|
+
const selectModel = useStore((s) => s.selectModel);
|
|
993
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
994
|
+
|
|
995
|
+
useEffect(() => {
|
|
996
|
+
const onDocClick = (e: MouseEvent) => {
|
|
997
|
+
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
998
|
+
};
|
|
999
|
+
const onEsc = (e: KeyboardEvent) => {
|
|
1000
|
+
if (e.key === 'Escape') onClose();
|
|
1001
|
+
};
|
|
1002
|
+
document.addEventListener('mousedown', onDocClick);
|
|
1003
|
+
document.addEventListener('keydown', onEsc);
|
|
1004
|
+
return () => {
|
|
1005
|
+
document.removeEventListener('mousedown', onDocClick);
|
|
1006
|
+
document.removeEventListener('keydown', onEsc);
|
|
1007
|
+
};
|
|
1008
|
+
}, [onClose]);
|
|
1009
|
+
|
|
1010
|
+
const selected = settings?.defaultModel?.trim() || '';
|
|
1011
|
+
const selectedProviderId = settings?.defaultProviderId ?? null;
|
|
1012
|
+
|
|
1013
|
+
const pick = async (option: (typeof models)[number]) => {
|
|
1014
|
+
await selectModel(option);
|
|
1015
|
+
onClose();
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
return (
|
|
1019
|
+
<div className="model-popover card" ref={ref} onClick={(e) => e.stopPropagation()}>
|
|
1020
|
+
<div className="model-popover-head">
|
|
1021
|
+
<span>{t('chat.selectModel')}</span>
|
|
1022
|
+
<button className="icon-btn" onClick={onClose} title={t('common.close')}><Icon name="x" size={12} /></button>
|
|
1023
|
+
</div>
|
|
1024
|
+
{models.length === 0 ? (
|
|
1025
|
+
<div className="model-popover-empty">
|
|
1026
|
+
{t('chat.modelEmpty')}
|
|
1027
|
+
</div>
|
|
1028
|
+
) : (
|
|
1029
|
+
<div className="model-popover-list">
|
|
1030
|
+
{models.map((option) => {
|
|
1031
|
+
const active = option.providerId === selectedProviderId && option.modelId === selected;
|
|
1032
|
+
return (
|
|
1033
|
+
<button
|
|
1034
|
+
key={`${option.providerId}:${option.modelId}`}
|
|
1035
|
+
className={`model-option ${active ? 'active' : ''}`}
|
|
1036
|
+
onClick={() => pick(option)}
|
|
1037
|
+
>
|
|
1038
|
+
<span className="model-option-dot">{active ? '●' : '○'}</span>
|
|
1039
|
+
<span className="model-option-name" title={option.modelId}>{option.label}</span>
|
|
1040
|
+
</button>
|
|
1041
|
+
);
|
|
1042
|
+
})}
|
|
1043
|
+
</div>
|
|
1044
|
+
)}
|
|
1045
|
+
</div>
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function readFileAsDataUrl(file: File): Promise<string> {
|
|
1050
|
+
return new Promise((resolve, reject) => {
|
|
1051
|
+
const reader = new FileReader();
|
|
1052
|
+
reader.onload = () => resolve(reader.result as string);
|
|
1053
|
+
reader.onerror = reject;
|
|
1054
|
+
reader.readAsDataURL(file);
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// ============================================================
|
|
1059
|
+
// 消息操作菜单(右键:编辑 / 重新生成 / 原始日志 / 分叉)
|
|
1060
|
+
// ============================================================
|
|
1061
|
+
|
|
1062
|
+
type CompressionMenuSource = {
|
|
1063
|
+
x: number;
|
|
1064
|
+
y: number;
|
|
1065
|
+
summary?: string;
|
|
1066
|
+
rawLog?: RawLogData;
|
|
1067
|
+
title: string;
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
let msgMenuState:
|
|
1071
|
+
| { x: number; y: number; msg: ChatMessage; session: ChatSession }
|
|
1072
|
+
| CompressionMenuSource
|
|
1073
|
+
| null = null;
|
|
1074
|
+
let msgMenuListeners: Array<() => void> = [];
|
|
1075
|
+
function notifyMsgMenu() {
|
|
1076
|
+
msgMenuListeners.forEach((fn) => fn());
|
|
1077
|
+
}
|
|
1078
|
+
function openMsgMenu(e: React.MouseEvent, msg: ChatMessage, session: ChatSession) {
|
|
1079
|
+
msgMenuState = { x: e.clientX, y: e.clientY, msg, session };
|
|
1080
|
+
notifyMsgMenu();
|
|
1081
|
+
}
|
|
1082
|
+
function openCompressionMenu(e: React.MouseEvent, summary: string | undefined, rawLog: RawLogData | undefined, title: string) {
|
|
1083
|
+
msgMenuState = { x: e.clientX, y: e.clientY, summary, rawLog, title };
|
|
1084
|
+
notifyMsgMenu();
|
|
1085
|
+
}
|
|
1086
|
+
function closeMsgMenu() {
|
|
1087
|
+
msgMenuState = null;
|
|
1088
|
+
notifyMsgMenu();
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// ---- 内联编辑全局状态(编辑目标由 MessageBubble 监听) ----
|
|
1092
|
+
let editingMsgState: { msgId: number } | null = null;
|
|
1093
|
+
let editingMsgListeners: Array<() => void> = [];
|
|
1094
|
+
function startEditingMsg(msgId: number) {
|
|
1095
|
+
editingMsgState = { msgId };
|
|
1096
|
+
editingMsgListeners.forEach((fn) => fn());
|
|
1097
|
+
closeMsgMenu();
|
|
1098
|
+
}
|
|
1099
|
+
function stopEditingMsg() {
|
|
1100
|
+
editingMsgState = null;
|
|
1101
|
+
editingMsgListeners.forEach((fn) => fn());
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export function MessageMenu() {
|
|
1105
|
+
const [, force] = useState(0);
|
|
1106
|
+
const t = useT();
|
|
1107
|
+
const regenerateMessage = useStore((s) => s.regenerateMessage);
|
|
1108
|
+
const forkSession = useStore((s) => s.forkSession);
|
|
1109
|
+
const addToast = useStore((s) => s.addToast);
|
|
1110
|
+
const messages = useStore((s) => s.messages);
|
|
1111
|
+
const npcs = useStore((s) => s.npcs);
|
|
1112
|
+
const [rawLog, setRawLog] = useState<{ data: RawLogData; title: string; filename: string } | null>(null);
|
|
1113
|
+
const [compressionSummary, setCompressionSummary] = useState<{ content: string; title: string } | null>(null);
|
|
1114
|
+
|
|
1115
|
+
useEffect(() => {
|
|
1116
|
+
const fn = () => force((n) => n + 1);
|
|
1117
|
+
msgMenuListeners.push(fn);
|
|
1118
|
+
return () => {
|
|
1119
|
+
msgMenuListeners = msgMenuListeners.filter((f) => f !== fn);
|
|
1120
|
+
};
|
|
1121
|
+
}, []);
|
|
1122
|
+
|
|
1123
|
+
// 原始日志弹窗(独立状态,优先于右键菜单)
|
|
1124
|
+
if (rawLog) {
|
|
1125
|
+
return (
|
|
1126
|
+
<RawLogModal
|
|
1127
|
+
rawLog={rawLog.data}
|
|
1128
|
+
title={rawLog.title}
|
|
1129
|
+
onClose={() => setRawLog(null)}
|
|
1130
|
+
onExport={async () => {
|
|
1131
|
+
const raw = buildRawLog(rawLog.data);
|
|
1132
|
+
const result = await saveTextFile(raw, 'text/plain', rawLog.filename);
|
|
1133
|
+
if (result === 'canceled') {
|
|
1134
|
+
addToast(t('toast.exportCanceled'));
|
|
1135
|
+
} else {
|
|
1136
|
+
addToast(t('toast.rawExported'));
|
|
1137
|
+
setRawLog(null);
|
|
1138
|
+
}
|
|
1139
|
+
}}
|
|
1140
|
+
/>
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (compressionSummary) {
|
|
1145
|
+
return (
|
|
1146
|
+
<Modal onClose={() => setCompressionSummary(null)} width="min(680px, calc(100vw - 40px))">
|
|
1147
|
+
<div className="modal-head">
|
|
1148
|
+
<span>{compressionSummary.title}</span>
|
|
1149
|
+
<button className="icon-btn" onClick={() => setCompressionSummary(null)} aria-label={t('common.close')}>×</button>
|
|
1150
|
+
</div>
|
|
1151
|
+
<div className="compression-summary-body"><Markdown text={compressionSummary.content} /></div>
|
|
1152
|
+
</Modal>
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if (!msgMenuState) return null;
|
|
1157
|
+
if (!('msg' in msgMenuState)) {
|
|
1158
|
+
const { x, y, summary, rawLog: compressionRawLog, title } = msgMenuState;
|
|
1159
|
+
return (
|
|
1160
|
+
<>
|
|
1161
|
+
<div className="overlay-msg" onClick={closeMsgMenu} />
|
|
1162
|
+
<div className="msg-menu card" style={{ left: Math.min(x, window.innerWidth - 190), top: Math.min(y, window.innerHeight - 200) }}>
|
|
1163
|
+
<button
|
|
1164
|
+
className="msg-menu-item"
|
|
1165
|
+
disabled={!summary}
|
|
1166
|
+
onClick={() => {
|
|
1167
|
+
if (summary) setCompressionSummary({ content: summary, title });
|
|
1168
|
+
closeMsgMenu();
|
|
1169
|
+
}}
|
|
1170
|
+
>
|
|
1171
|
+
<Icon name="eye" size={13} /> {t('chat.viewMemory')}
|
|
1172
|
+
</button>
|
|
1173
|
+
<button
|
|
1174
|
+
className="msg-menu-item"
|
|
1175
|
+
disabled={!compressionRawLog?.rawRequestBody && !compressionRawLog?.rawResponseBody}
|
|
1176
|
+
onClick={() => {
|
|
1177
|
+
if (compressionRawLog) setRawLog({ data: compressionRawLog, title, filename: 'raw-log-context-compression.txt' });
|
|
1178
|
+
closeMsgMenu();
|
|
1179
|
+
}}
|
|
1180
|
+
>
|
|
1181
|
+
<Icon name="file" size={13} /> {t('chat.viewRaw')}
|
|
1182
|
+
</button>
|
|
1183
|
+
</div>
|
|
1184
|
+
</>
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
const { x, y, msg } = msgMenuState;
|
|
1188
|
+
|
|
1189
|
+
const actions: Array<{ label: string; icon: string; onClick: () => void; danger?: boolean }> = [];
|
|
1190
|
+
if (msg.role === 'user' || msg.role === 'assistant') {
|
|
1191
|
+
actions.push({
|
|
1192
|
+
label: t('chat.editMsg'),
|
|
1193
|
+
icon: 'settings',
|
|
1194
|
+
onClick: () => {
|
|
1195
|
+
startEditingMsg(msg.id!);
|
|
1196
|
+
},
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
const isOpeningGreeting = msg.isOpeningGreeting
|
|
1200
|
+
?? (msg.role === 'assistant'
|
|
1201
|
+
&& messages[msg.sessionId]?.find((message) => message.role === 'assistant')?.id === msg.id
|
|
1202
|
+
&& npcs.some((npc) => npc.id === msg.speakerParticipantId && [npc.greeting, ...(npc.alternateGreetings ?? [])].includes(msg.content)));
|
|
1203
|
+
if (msg.role === 'assistant' && !isOpeningGreeting) {
|
|
1204
|
+
actions.push({
|
|
1205
|
+
label: t('chat.regenerate'),
|
|
1206
|
+
icon: 'refresh',
|
|
1207
|
+
onClick: async () => {
|
|
1208
|
+
closeMsgMenu();
|
|
1209
|
+
await regenerateMessage(msg.id!);
|
|
1210
|
+
},
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
if (msg.rawRequestBody || msg.rawResponseBody) {
|
|
1214
|
+
actions.push({
|
|
1215
|
+
label: t('chat.viewRaw'),
|
|
1216
|
+
icon: 'file',
|
|
1217
|
+
onClick: () => {
|
|
1218
|
+
setRawLog({
|
|
1219
|
+
data: msg,
|
|
1220
|
+
title: t('chat.rawLogTitle', { id: msg.id ?? '' }),
|
|
1221
|
+
filename: `raw-log-${msg.id}.txt`,
|
|
1222
|
+
});
|
|
1223
|
+
closeMsgMenu();
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
// 分支 / Fork:从该消息复制出新会话
|
|
1228
|
+
actions.push({
|
|
1229
|
+
label: t('chat.fork'),
|
|
1230
|
+
icon: 'git-branch',
|
|
1231
|
+
onClick: async () => {
|
|
1232
|
+
closeMsgMenu();
|
|
1233
|
+
await forkSession(msg.id!);
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
return (
|
|
1238
|
+
<>
|
|
1239
|
+
<div className="overlay-msg" onClick={closeMsgMenu} />
|
|
1240
|
+
<div className="msg-menu card" style={{ left: Math.min(x, window.innerWidth - 190), top: Math.min(y, window.innerHeight - 200) }}>
|
|
1241
|
+
{actions.map((a) => (
|
|
1242
|
+
<button
|
|
1243
|
+
key={a.label}
|
|
1244
|
+
className="msg-menu-item"
|
|
1245
|
+
onClick={() => {
|
|
1246
|
+
closeMsgMenu();
|
|
1247
|
+
a.onClick();
|
|
1248
|
+
}}
|
|
1249
|
+
>
|
|
1250
|
+
<Icon name={a.icon} size={13} /> {a.label}
|
|
1251
|
+
</button>
|
|
1252
|
+
))}
|
|
1253
|
+
{actions.length === 0 && <div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('chat.noActions')}</div>}
|
|
1254
|
+
</div>
|
|
1255
|
+
</>
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
type RawLogData = Pick<ChatMessage, 'rawRequestBody' | 'rawResponseBody'> & Partial<Pick<ChatMessage, 'id' | 'role'>>;
|
|
1260
|
+
|
|
1261
|
+
/** 原始日志弹窗:将响应 JSON 展示为完整回复,便于阅读 */
|
|
1262
|
+
function RawLogModal({ rawLog, title, onClose, onExport }: { rawLog: RawLogData; title: string; onClose: () => void; onExport: () => void }) {
|
|
1263
|
+
const t = useT();
|
|
1264
|
+
const req = rawLog.rawRequestBody ? prettyJson(rawLog.rawRequestBody) : '';
|
|
1265
|
+
const merged = parseRawResponse(rawLog.rawResponseBody);
|
|
1266
|
+
|
|
1267
|
+
const [tab, setTab] = useState<'request' | 'merged'>('request');
|
|
1268
|
+
const [autoWrap, setAutoWrap] = useState(true);
|
|
1269
|
+
|
|
1270
|
+
return (
|
|
1271
|
+
<Modal onClose={onClose} className="raw-log-modal" width="min(760px, calc(100vw - 40px))">
|
|
1272
|
+
<div className="modal-head raw-modal-head">
|
|
1273
|
+
<span className="raw-modal-title">
|
|
1274
|
+
<Icon name="file" size={15} /> {title}
|
|
1275
|
+
</span>
|
|
1276
|
+
<div className="raw-tabs">
|
|
1277
|
+
<button className={`raw-tab ${tab === 'request' ? 'active' : ''}`} onClick={() => setTab('request')}>{t('chat.requestBody')}</button>
|
|
1278
|
+
<button className={`raw-tab ${tab === 'merged' ? 'active' : ''}`} onClick={() => setTab('merged')}>{t('chat.fullResponse')}</button>
|
|
1279
|
+
</div>
|
|
1280
|
+
<div className="raw-head-actions">
|
|
1281
|
+
<button
|
|
1282
|
+
className={`icon-btn ${autoWrap ? 'active' : ''}`}
|
|
1283
|
+
title={t(autoWrap ? 'display.disableWrap' : 'display.enableWrap')}
|
|
1284
|
+
aria-label={t(autoWrap ? 'display.disableWrap' : 'display.enableWrap')}
|
|
1285
|
+
aria-pressed={autoWrap}
|
|
1286
|
+
onClick={() => setAutoWrap((wrapped) => !wrapped)}
|
|
1287
|
+
>
|
|
1288
|
+
<Icon name="text-wrap" size={17} />
|
|
1289
|
+
</button>
|
|
1290
|
+
<button
|
|
1291
|
+
className="icon-btn"
|
|
1292
|
+
onClick={onExport}
|
|
1293
|
+
title={t('chat.exportTip')}
|
|
1294
|
+
aria-label={t('chat.exportTip')}
|
|
1295
|
+
>
|
|
1296
|
+
<Icon name="share" size={17} />
|
|
1297
|
+
</button>
|
|
1298
|
+
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
|
|
1299
|
+
</div>
|
|
1300
|
+
</div>
|
|
1301
|
+
<div className="modal-body" style={{ padding: 0 }}>
|
|
1302
|
+
{tab === 'request' && (
|
|
1303
|
+
<pre className={`raw-pre mono ${autoWrap ? '' : 'raw-pre-nowrap'}`}>{req || t('chat.noRequestBody')}</pre>
|
|
1304
|
+
)}
|
|
1305
|
+
{tab === 'merged' && (
|
|
1306
|
+
merged ? (
|
|
1307
|
+
<div className="raw-merged">
|
|
1308
|
+
<div className="raw-merged-head">
|
|
1309
|
+
<span>{t('chat.fullResponseJson')}</span>
|
|
1310
|
+
<span className="mono" style={{ fontSize: 11, color: 'var(--text-faint)' }}>{t('chat.fromSse')}</span>
|
|
1311
|
+
</div>
|
|
1312
|
+
<pre className={`raw-pre mono ${autoWrap ? '' : 'raw-pre-nowrap'}`}>{JSON.stringify(merged, null, 2)}</pre>
|
|
1313
|
+
</div>
|
|
1314
|
+
) : (
|
|
1315
|
+
<div className="raw-empty">{t('chat.noRawContent')}</div>
|
|
1316
|
+
)
|
|
1317
|
+
)}
|
|
1318
|
+
</div>
|
|
1319
|
+
</Modal>
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** 从流式 SSE 原始行拼装出完整回复 JSON(合并所有分片)
|
|
1324
|
+
* 注意:rawResponseBody 存的是去掉 "data: " 前缀后的裸 JSON 行,
|
|
1325
|
+
* 因此这里同时兼容裸 JSON 行与带 data: 前缀的完整行。 */
|
|
1326
|
+
interface ChoiceAccum {
|
|
1327
|
+
role?: string;
|
|
1328
|
+
content: string;
|
|
1329
|
+
finish_reason?: string | null;
|
|
1330
|
+
toolCalls: Map<number, { id: string; type: string; name: string; args: string }>;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function assembleFullResponseJson(rawBody: string | null): Record<string, unknown> | null {
|
|
1334
|
+
if (!rawBody) return null;
|
|
1335
|
+
const lines = rawBody.split('\n');
|
|
1336
|
+
|
|
1337
|
+
// 用于收集分片信息
|
|
1338
|
+
let merged: Record<string, unknown> = {};
|
|
1339
|
+
const choicesAccum = new Map<number, ChoiceAccum>();
|
|
1340
|
+
let usage: Record<string, unknown> | null = null;
|
|
1341
|
+
|
|
1342
|
+
const applyChunk = (chunk: Record<string, unknown>) => {
|
|
1343
|
+
const id = chunk.id;
|
|
1344
|
+
const object = chunk.object;
|
|
1345
|
+
const created = chunk.created;
|
|
1346
|
+
const model = chunk.model;
|
|
1347
|
+
if (typeof id === 'string') merged.id = id;
|
|
1348
|
+
if (typeof object === 'string') merged.object = object;
|
|
1349
|
+
if (typeof created === 'number') merged.created = created;
|
|
1350
|
+
if (typeof model === 'string') merged.model = model;
|
|
1351
|
+
|
|
1352
|
+
const choices = chunk.choices as Array<{ index?: number; delta?: Record<string, unknown>; finish_reason?: string | null }> | undefined;
|
|
1353
|
+
if (choices) {
|
|
1354
|
+
for (const c of choices) {
|
|
1355
|
+
const idx = c.index ?? 0;
|
|
1356
|
+
let acc = choicesAccum.get(idx);
|
|
1357
|
+
if (!acc) {
|
|
1358
|
+
acc = { content: '', toolCalls: new Map() };
|
|
1359
|
+
choicesAccum.set(idx, acc);
|
|
1360
|
+
}
|
|
1361
|
+
const delta = c.delta ?? {};
|
|
1362
|
+
if (typeof delta.role === 'string' && !acc.role) acc.role = delta.role;
|
|
1363
|
+
if (typeof delta.content === 'string') acc.content += delta.content;
|
|
1364
|
+
if (typeof c.finish_reason === 'string') acc.finish_reason = c.finish_reason;
|
|
1365
|
+
// 工具调用增量按 index 组装(name / arguments 递增拼接)
|
|
1366
|
+
const tcDeltas = delta.tool_calls as Array<{ index?: number; id?: string; type?: string; function?: { name?: string; arguments?: string } }> | undefined;
|
|
1367
|
+
if (tcDeltas) {
|
|
1368
|
+
for (const tc of tcDeltas) {
|
|
1369
|
+
const tci = tc.index ?? 0;
|
|
1370
|
+
const cur = acc.toolCalls.get(tci) ?? { id: '', type: 'function', name: '', args: '' };
|
|
1371
|
+
if (tc.id) cur.id = tc.id;
|
|
1372
|
+
if (tc.type) cur.type = tc.type;
|
|
1373
|
+
if (tc.function?.name) cur.name += tc.function.name;
|
|
1374
|
+
if (tc.function?.arguments) cur.args += tc.function.arguments;
|
|
1375
|
+
acc.toolCalls.set(tci, cur);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
if (chunk.usage) usage = chunk.usage as Record<string, unknown>;
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
for (const line of lines) {
|
|
1385
|
+
const trimmed = line.trim();
|
|
1386
|
+
const data = trimmed.startsWith('data:') ? trimmed.slice(5).trim() : trimmed;
|
|
1387
|
+
if (!data || data === '[DONE]') continue;
|
|
1388
|
+
try {
|
|
1389
|
+
applyChunk(JSON.parse(data));
|
|
1390
|
+
} catch {
|
|
1391
|
+
/* 忽略非 JSON 行 */
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (Object.keys(merged).length === 0) return null;
|
|
1396
|
+
|
|
1397
|
+
// 组装 choices:message 结构 + finish_reason
|
|
1398
|
+
const mergedChoices = Array.from(choicesAccum.entries())
|
|
1399
|
+
.sort((a, b) => a[0] - b[0])
|
|
1400
|
+
.map(([idx, acc]) => {
|
|
1401
|
+
const message: Record<string, unknown> = {};
|
|
1402
|
+
if (acc.role) message.role = acc.role;
|
|
1403
|
+
if (acc.content) message.content = acc.content;
|
|
1404
|
+
if (acc.toolCalls.size > 0) {
|
|
1405
|
+
message.tool_calls = Array.from(acc.toolCalls.entries())
|
|
1406
|
+
.sort((a, b) => a[0] - b[0])
|
|
1407
|
+
.map(([, tc]) => ({
|
|
1408
|
+
id: tc.id,
|
|
1409
|
+
type: tc.type,
|
|
1410
|
+
function: { name: tc.name, arguments: tc.args },
|
|
1411
|
+
}));
|
|
1412
|
+
}
|
|
1413
|
+
const choice: Record<string, unknown> = { index: idx, message };
|
|
1414
|
+
if (acc.finish_reason != null) choice.finish_reason = acc.finish_reason;
|
|
1415
|
+
return choice;
|
|
1416
|
+
});
|
|
1417
|
+
merged.choices = mergedChoices;
|
|
1418
|
+
if (usage) merged.usage = usage;
|
|
1419
|
+
|
|
1420
|
+
return merged;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function parseRawResponse(rawBody: string | null): Record<string, unknown> | null {
|
|
1424
|
+
if (!rawBody) return null;
|
|
1425
|
+
try {
|
|
1426
|
+
return JSON.parse(rawBody) as Record<string, unknown>;
|
|
1427
|
+
} catch {
|
|
1428
|
+
return assembleFullResponseJson(rawBody);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function buildRawLog(rawLog: RawLogData): string {
|
|
1433
|
+
const req = rawLog.rawRequestBody ? prettyJson(rawLog.rawRequestBody) : null;
|
|
1434
|
+
const resp = rawLog.rawResponseBody ? prettyJson(rawLog.rawResponseBody) : null;
|
|
1435
|
+
return [
|
|
1436
|
+
rawLog.id != null ? `# Raw Log — message #${rawLog.id}` : '# Raw Log — context compression',
|
|
1437
|
+
rawLog.role ? `role: ${rawLog.role}` : '',
|
|
1438
|
+
req ? `\n## Request\n${req}` : '',
|
|
1439
|
+
resp ? `\n## Response\n${resp}` : '',
|
|
1440
|
+
].join('\n');
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// ============================================================
|
|
1444
|
+
// 发言队列面板(群聊右侧):按循环展示完整发言顺序历史 + 实时指示正在发言者
|
|
1445
|
+
// ============================================================
|
|
1446
|
+
|
|
1447
|
+
export function TurnQueuePanel({
|
|
1448
|
+
session,
|
|
1449
|
+
participants,
|
|
1450
|
+
}: {
|
|
1451
|
+
session: ChatSession;
|
|
1452
|
+
participants: ChatParticipant[];
|
|
1453
|
+
}) {
|
|
1454
|
+
// ⚠️ 保持 hooks 无条件执行:本组件在 App 中始终挂载,切换会话(群聊 ⇄ 单人)
|
|
1455
|
+
// 时若 hooks 数量变化会触发 “Rendered fewer hooks than expected” 导致整页白屏。
|
|
1456
|
+
// 因此把模式判断放在所有 hooks 之后,仅用返回 JSX 与否控制渲染。
|
|
1457
|
+
const t = useT();
|
|
1458
|
+
const byId = useMemo(() => new Map(participants.map((p) => [p.participantId, p])), [participants]);
|
|
1459
|
+
const npcs = useStore((s) => s.npcs);
|
|
1460
|
+
const streamingSessionId = useStore((s) => s.streaming.sessionId);
|
|
1461
|
+
const sessionMessages = useStore((s) => (session.id != null ? (s.messages[session.id] ?? []) : []));
|
|
1462
|
+
// 订阅实时队列快照(每个回合推进 / 发言开始 / 玩家轮到都会更新)
|
|
1463
|
+
const live = useStore((s) => s.liveQueueBySession[session.id!]);
|
|
1464
|
+
const isStreamingThisSession = streamingSessionId === session.id;
|
|
1465
|
+
const playerId = useMemo(() => participants.find((p) => p.kind === 'PLAYER')?.participantId ?? -1, [participants]);
|
|
1466
|
+
const loopLabel = (live?.loopIndex ?? session.loopIndex) + 1;
|
|
1467
|
+
const currentLoopIndex = loopLabel - 1;
|
|
1468
|
+
// 正在发言者 = 剩余队列队首(live.queue[0];随发言推进而变化)且正在流式生成
|
|
1469
|
+
const liveQueue = live?.queue ?? effectiveDisplayQueue(session, participants);
|
|
1470
|
+
const currentId = liveQueue.length > 0 ? parseInt(liveQueue[0], 10) : null;
|
|
1471
|
+
const current = currentId != null ? byId.get(currentId) : null;
|
|
1472
|
+
const isCurrentPlaying = current != null && current.kind === 'NPC' && isStreamingThisSession;
|
|
1473
|
+
const spokenByLoop = useMemo(() => {
|
|
1474
|
+
const map = new Map<number, string[]>();
|
|
1475
|
+
const list = [...sessionMessages]
|
|
1476
|
+
.filter((m) => m.loopIndex != null && (m.role === 'assistant' || m.role === 'user'))
|
|
1477
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
1478
|
+
for (const m of list) {
|
|
1479
|
+
const loop = m.loopIndex as number;
|
|
1480
|
+
const speakerId = m.role === 'user' ? playerId : m.speakerParticipantId;
|
|
1481
|
+
if (m.role === 'assistant' && !trimEdgeNewlines(m.content)) continue;
|
|
1482
|
+
if (speakerId == null) continue;
|
|
1483
|
+
const arr = map.get(loop) ?? [];
|
|
1484
|
+
const key = String(speakerId);
|
|
1485
|
+
if (arr[arr.length - 1] !== key) arr.push(key);
|
|
1486
|
+
map.set(loop, arr);
|
|
1487
|
+
}
|
|
1488
|
+
return map;
|
|
1489
|
+
}, [sessionMessages, playerId]);
|
|
1490
|
+
const currentSpoken = spokenByLoop.get(currentLoopIndex) ?? [];
|
|
1491
|
+
const currentRemaining = useMemo(() => {
|
|
1492
|
+
const rem = [...liveQueue];
|
|
1493
|
+
if (isCurrentPlaying && currentSpoken.length > 0 && rem.length > 0 && rem[0] === currentSpoken[currentSpoken.length - 1]) {
|
|
1494
|
+
rem.shift();
|
|
1495
|
+
}
|
|
1496
|
+
return rem;
|
|
1497
|
+
}, [liveQueue, currentSpoken, isCurrentPlaying]);
|
|
1498
|
+
const currentDisplay = useMemo(
|
|
1499
|
+
() => [...currentSpoken, ...currentRemaining],
|
|
1500
|
+
[currentSpoken, currentRemaining]
|
|
1501
|
+
);
|
|
1502
|
+
const currentSpeakingIndex = useMemo(() => {
|
|
1503
|
+
if (!isCurrentPlaying || currentId == null) return null;
|
|
1504
|
+
const cid = String(currentId);
|
|
1505
|
+
if (currentSpoken.length > 0 && currentSpoken[currentSpoken.length - 1] === cid) return currentSpoken.length - 1;
|
|
1506
|
+
return currentSpoken.length;
|
|
1507
|
+
}, [isCurrentPlaying, currentId, currentSpoken]);
|
|
1508
|
+
const currentWaitingIndex = useMemo(() => {
|
|
1509
|
+
if (isCurrentPlaying || currentId == null) return null;
|
|
1510
|
+
return currentSpoken.length;
|
|
1511
|
+
}, [isCurrentPlaying, currentId, currentSpoken]);
|
|
1512
|
+
// 展示历史:当前循环显示“已发言 + 剩余队列”;已结束循环显示“真实已发言顺序”
|
|
1513
|
+
const loops = useMemo(() => {
|
|
1514
|
+
if (live) {
|
|
1515
|
+
const loopIndices = [
|
|
1516
|
+
...Array.from(spokenByLoop.keys()),
|
|
1517
|
+
...live.history.map((_, i) => i),
|
|
1518
|
+
currentLoopIndex,
|
|
1519
|
+
];
|
|
1520
|
+
const maxLoop = Math.max(0, ...loopIndices);
|
|
1521
|
+
const out: string[][] = [];
|
|
1522
|
+
for (let i = 0; i <= maxLoop; i++) {
|
|
1523
|
+
if (i === currentLoopIndex) {
|
|
1524
|
+
out.push(currentDisplay.length > 0 ? currentDisplay : (live.history[i] ?? []));
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
const spoken = spokenByLoop.get(i) ?? [];
|
|
1528
|
+
if (spoken.length > 0) {
|
|
1529
|
+
out.push(spoken);
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
out.push(live.history[i] ?? []);
|
|
1533
|
+
}
|
|
1534
|
+
return out;
|
|
1535
|
+
}
|
|
1536
|
+
const fallback = effectiveDisplayQueue(session, participants);
|
|
1537
|
+
return [fallback];
|
|
1538
|
+
}, [live, spokenByLoop, currentLoopIndex, currentDisplay, session, participants]);
|
|
1539
|
+
|
|
1540
|
+
// ---- 联动滚动:注册队列滚动容器,双向驱动由 linkedScroll 协调 ----
|
|
1541
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
1542
|
+
// 注册容器(registerQueueEl 内部重置抑制标志),body 只做注册,不含自驱动滚动
|
|
1543
|
+
const isGroup = session.mode === 'GROUP';
|
|
1544
|
+
useEffect(() => {
|
|
1545
|
+
// 仅群聊时注册队列滚动容器,避免非群聊下误注册/联动
|
|
1546
|
+
if (!isGroup) return;
|
|
1547
|
+
registerQueueEl(bodyRef.current);
|
|
1548
|
+
return () => registerQueueEl(null); // 卸载时解除引用
|
|
1549
|
+
}, [isGroup]);
|
|
1550
|
+
// 新循环加入(轮数变化)→ 队列自动滚到当前循环(底部对齐,整组可见)
|
|
1551
|
+
const loopsLen = loops.length;
|
|
1552
|
+
useEffect(() => {
|
|
1553
|
+
// 非群聊下不触发联动滚动
|
|
1554
|
+
if (!isGroup) return;
|
|
1555
|
+
if (loopsLen > 0) scrollQueueToLoop(loopLabel);
|
|
1556
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1557
|
+
}, [loopsLen, isGroup]);
|
|
1558
|
+
|
|
1559
|
+
// 非群聊:不渲染发言队列(hooks 已无条件执行,只在这里返回空)
|
|
1560
|
+
if (!isGroup) return null;
|
|
1561
|
+
|
|
1562
|
+
const handleQueueItemClick = (loopNum: number, speakerKey: string | null) => {
|
|
1563
|
+
scrollChatTo(loopNum - 1, speakerKey);
|
|
1564
|
+
};
|
|
1565
|
+
|
|
1566
|
+
const renderLoop = (loopQueue: string[], loopNum: number) => {
|
|
1567
|
+
const isCurrent = loopNum === loopLabel;
|
|
1568
|
+
return (
|
|
1569
|
+
<div
|
|
1570
|
+
className="turn-queue-loop-group"
|
|
1571
|
+
key={loopNum}
|
|
1572
|
+
data-loop={loopNum - 1}
|
|
1573
|
+
>
|
|
1574
|
+
<div className="turn-queue-loop-head">
|
|
1575
|
+
<span>{t('chat.loop', { n: loopNum })}</span>
|
|
1576
|
+
{isCurrent && <span className="turn-queue-loop-cur">{t('chat.current')}</span>}
|
|
1577
|
+
</div>
|
|
1578
|
+
<ul className="turn-queue-list">
|
|
1579
|
+
{loopQueue.map((id, i) => {
|
|
1580
|
+
const p = byId.get(parseInt(id, 10));
|
|
1581
|
+
if (!p) return null;
|
|
1582
|
+
const isPlayer = p.kind === 'PLAYER';
|
|
1583
|
+
const npc = p.npcId ? npcs.find((n) => n.id === p.npcId) : undefined;
|
|
1584
|
+
const hue = npc?.avatarColorOrdinal ?? 0;
|
|
1585
|
+
const avatarUrl = npc?.avatarDataUrl ?? null;
|
|
1586
|
+
// 正在发言:仅当前循环中该角色 = 剩余队首(含思考阶段)
|
|
1587
|
+
const isSpeaking = isCurrent && currentSpeakingIndex != null && i === currentSpeakingIndex;
|
|
1588
|
+
// 轮到玩家(且未在发言中):队列首部 = 玩家 → 等待你
|
|
1589
|
+
const isWaitingPlayer =
|
|
1590
|
+
isCurrent &&
|
|
1591
|
+
!isSpeaking &&
|
|
1592
|
+
isPlayer &&
|
|
1593
|
+
currentWaitingIndex != null &&
|
|
1594
|
+
i === currentWaitingIndex &&
|
|
1595
|
+
currentId != null &&
|
|
1596
|
+
id === String(currentId);
|
|
1597
|
+
return (
|
|
1598
|
+
<li
|
|
1599
|
+
key={`${loopNum}-${id}-${i}`}
|
|
1600
|
+
className={`turn-queue-item ${isSpeaking ? 'active playing' : ''} ${isWaitingPlayer ? 'waiting-user' : ''}`}
|
|
1601
|
+
onClick={() => handleQueueItemClick(loopNum, isPlayer ? 'player' : id)}
|
|
1602
|
+
title={isPlayer ? t('chat.queueLocateSelf') : t('chat.queueLocateNpc', { name: speakerLabel(p) })}
|
|
1603
|
+
>
|
|
1604
|
+
<span className="turn-queue-idx">{i + 1}</span>
|
|
1605
|
+
<Avatar name={speakerLabel(p)} colorOrdinal={hue} imageUrl={avatarUrl} size="xs" />
|
|
1606
|
+
<span className="turn-queue-name">{speakerLabel(p)}</span>
|
|
1607
|
+
{isPlayer && <span className="turn-queue-seat">{t('common.you')}</span>}
|
|
1608
|
+
</li>
|
|
1609
|
+
);
|
|
1610
|
+
})}
|
|
1611
|
+
</ul>
|
|
1612
|
+
</div>
|
|
1613
|
+
);
|
|
1614
|
+
};
|
|
1615
|
+
|
|
1616
|
+
return (
|
|
1617
|
+
<div className="turn-queue">
|
|
1618
|
+
<div className="turn-queue-head">
|
|
1619
|
+
<span>{t('chat.queueTitle')}</span>
|
|
1620
|
+
<span className="turn-queue-loop">{t('chat.queueLoops', { n: loops.length })}</span>
|
|
1621
|
+
</div>
|
|
1622
|
+
<div
|
|
1623
|
+
className="turn-queue-body"
|
|
1624
|
+
ref={bodyRef}
|
|
1625
|
+
onPointerDown={onQueueScrollIntent}
|
|
1626
|
+
onTouchStart={onQueueScrollIntent}
|
|
1627
|
+
onWheel={onQueueScrollIntent}
|
|
1628
|
+
onScroll={onQueueScroll}
|
|
1629
|
+
>
|
|
1630
|
+
{loops.map((loopQueue, idx) => renderLoop(loopQueue, idx + 1))}
|
|
1631
|
+
</div>
|
|
1632
|
+
<div className="turn-queue-foot">
|
|
1633
|
+
<span>{(live?.turnOrderMode ?? session.turnOrderMode) === 'RANDOM' ? t('chat.queueRandom') : t('chat.queueFixed')}</span>
|
|
1634
|
+
</div>
|
|
1635
|
+
</div>
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
export default ChatView;
|