@nextclaw/ui 0.5.25 → 0.5.28
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 +22 -0
- package/dist/assets/{ChannelsList-C2h4dVWt.js → ChannelsList-DgkIu7_t.js} +1 -1
- package/dist/assets/ChatPage-CjzR-76f.js +32 -0
- package/dist/assets/{CronConfig-CtiTohyP.js → CronConfig-DREWLzKD.js} +1 -1
- package/dist/assets/{DocBrowser-BDOIcX3y.js → DocBrowser-fXdmzwRi.js} +1 -1
- package/dist/assets/{MarketplacePage-BPc8FmE5.js → MarketplacePage-zKC-b_O_.js} +1 -1
- package/dist/assets/{ModelConfig-pX1wV-UJ.js → ModelConfig-BdogzFBq.js} +1 -1
- package/dist/assets/{ProvidersList-BJTtF6S5.js → ProvidersList-BLScOe9j.js} +1 -1
- package/dist/assets/{RuntimeConfig-BqN1F3nP.js → RuntimeConfig-CGsoLtsV.js} +1 -1
- package/dist/assets/{SecretsConfig-CkqJq4s6.js → SecretsConfig-B8ZDpRgB.js} +1 -1
- package/dist/assets/{SessionsConfig-ti41bz9T.js → SessionsConfig-CcpfGazP.js} +1 -1
- package/dist/assets/{action-link-C4fhtxYl.js → action-link-RjYHzlQk.js} +1 -1
- package/dist/assets/{card-C0sHz144.js → card-Bt8T3JA3.js} +1 -1
- package/dist/assets/chat-message-D0s61C4e.js +5 -0
- package/dist/assets/{dialog-CJMXeCpK.js → dialog-BNi5ymWD.js} +1 -1
- package/dist/assets/index-B_OeEGic.css +1 -0
- package/dist/assets/{index-BTE5dHqH.js → index-CtNWUrVR.js} +2 -2
- package/dist/assets/{label-DeI2vCVA.js → label-BDCnjFl3.js} +1 -1
- package/dist/assets/{page-layout-C_pPVkYP.js → page-layout-DWVnm0X8.js} +1 -1
- package/dist/assets/{switch-_UxC2xoH.js → switch-CrmdAOBw.js} +1 -1
- package/dist/assets/{tabs-custom-DgryVfGt.js → tabs-custom-WJxQwDQy.js} +1 -1
- package/dist/assets/useConfig-COoN7EVf.js +6 -0
- package/dist/assets/{useConfirmDialog-DqTlJNqa.js → useConfirmDialog-C5hEiEeb.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/api/config.ts +14 -1
- package/src/api/types.ts +14 -0
- package/src/components/chat/ChatPage.tsx +134 -54
- package/src/components/chat/ChatThread.tsx +58 -32
- package/src/lib/chat-message.ts +169 -153
- package/dist/assets/ChatPage-B4xQTj78.js +0 -32
- package/dist/assets/chat-message-Jxa8JFA_.js +0 -9
- package/dist/assets/index-BsDasSXm.css +0 -1
- package/dist/assets/useConfig-DDPQwvDe.js +0 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import type { SessionEntryView,
|
|
2
|
+
import type { SessionEntryView, SessionEventView } from '@/api/types';
|
|
3
3
|
import { sendChatTurnStream } from '@/api/config';
|
|
4
4
|
import { useConfig, useDeleteSession, useSessionHistory, useSessions } from '@/hooks/useConfig';
|
|
5
5
|
import { useConfirmDialog } from '@/hooks/useConfirmDialog';
|
|
@@ -9,10 +9,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|
|
9
9
|
import { PageHeader, PageLayout } from '@/components/layout/page-layout';
|
|
10
10
|
import { ChatThread } from '@/components/chat/ChatThread';
|
|
11
11
|
import { cn } from '@/lib/utils';
|
|
12
|
+
import { buildFallbackEventsFromMessages } from '@/lib/chat-message';
|
|
12
13
|
import { formatDateTime, t } from '@/lib/i18n';
|
|
13
14
|
import { MessageSquareText, Plus, RefreshCw, Search, Send, Trash2 } from 'lucide-react';
|
|
14
15
|
|
|
15
16
|
const CHAT_SESSION_STORAGE_KEY = 'nextclaw.ui.chat.activeSession';
|
|
17
|
+
const UNKNOWN_CHAT_CHANNEL_KEY = '__unknown_channel__';
|
|
16
18
|
|
|
17
19
|
function readStoredSessionKey(): string | null {
|
|
18
20
|
if (typeof window === 'undefined') {
|
|
@@ -63,6 +65,22 @@ function sessionDisplayName(session: SessionEntryView): string {
|
|
|
63
65
|
return chunks[chunks.length - 1] || session.key;
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
function resolveChannelFromSessionKey(key: string): string {
|
|
69
|
+
const separator = key.indexOf(':');
|
|
70
|
+
if (separator <= 0) {
|
|
71
|
+
return UNKNOWN_CHAT_CHANNEL_KEY;
|
|
72
|
+
}
|
|
73
|
+
const channel = key.slice(0, separator).trim();
|
|
74
|
+
return channel || UNKNOWN_CHAT_CHANNEL_KEY;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function displayChannelName(channel: string): string {
|
|
78
|
+
if (channel === UNKNOWN_CHAT_CHANNEL_KEY) {
|
|
79
|
+
return t('sessionsUnknownChannel');
|
|
80
|
+
}
|
|
81
|
+
return channel;
|
|
82
|
+
}
|
|
83
|
+
|
|
66
84
|
type PendingChatMessage = {
|
|
67
85
|
id: number;
|
|
68
86
|
message: string;
|
|
@@ -72,11 +90,13 @@ type PendingChatMessage = {
|
|
|
72
90
|
|
|
73
91
|
export function ChatPage() {
|
|
74
92
|
const [query, setQuery] = useState('');
|
|
93
|
+
const [selectedChannel, setSelectedChannel] = useState('all');
|
|
75
94
|
const [draft, setDraft] = useState('');
|
|
76
95
|
const [selectedSessionKey, setSelectedSessionKey] = useState<string | null>(() => readStoredSessionKey());
|
|
77
96
|
const [selectedAgentId, setSelectedAgentId] = useState('main');
|
|
78
|
-
const [
|
|
79
|
-
const [
|
|
97
|
+
const [streamingSessionEvents, setStreamingSessionEvents] = useState<SessionEventView[]>([]);
|
|
98
|
+
const [streamingAssistantText, setStreamingAssistantText] = useState('');
|
|
99
|
+
const [streamingAssistantTimestamp, setStreamingAssistantTimestamp] = useState<string | null>(null);
|
|
80
100
|
const [isSending, setIsSending] = useState(false);
|
|
81
101
|
const [queuedMessages, setQueuedMessages] = useState<PendingChatMessage[]>([]);
|
|
82
102
|
|
|
@@ -103,31 +123,60 @@ export function ChatPage() {
|
|
|
103
123
|
}, [configQuery.data?.agents.list]);
|
|
104
124
|
|
|
105
125
|
const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
|
|
126
|
+
const channelOptions = useMemo(() => {
|
|
127
|
+
const unique = new Set<string>();
|
|
128
|
+
for (const session of sessions) {
|
|
129
|
+
unique.add(resolveChannelFromSessionKey(session.key));
|
|
130
|
+
}
|
|
131
|
+
return Array.from(unique).sort((a, b) => {
|
|
132
|
+
if (a === UNKNOWN_CHAT_CHANNEL_KEY) return 1;
|
|
133
|
+
if (b === UNKNOWN_CHAT_CHANNEL_KEY) return -1;
|
|
134
|
+
return a.localeCompare(b);
|
|
135
|
+
});
|
|
136
|
+
}, [sessions]);
|
|
137
|
+
const filteredSessions = useMemo(() => {
|
|
138
|
+
if (selectedChannel === 'all') {
|
|
139
|
+
return sessions;
|
|
140
|
+
}
|
|
141
|
+
return sessions.filter((session) => resolveChannelFromSessionKey(session.key) === selectedChannel);
|
|
142
|
+
}, [selectedChannel, sessions]);
|
|
106
143
|
const selectedSession = useMemo(
|
|
107
144
|
() => sessions.find((session) => session.key === selectedSessionKey) ?? null,
|
|
108
145
|
[selectedSessionKey, sessions]
|
|
109
146
|
);
|
|
110
147
|
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
148
|
+
const historyData = historyQuery.data;
|
|
149
|
+
const historyMessages = historyData?.messages ?? [];
|
|
150
|
+
const historyEvents =
|
|
151
|
+
historyData?.events && historyData.events.length > 0
|
|
152
|
+
? historyData.events
|
|
153
|
+
: buildFallbackEventsFromMessages(historyMessages);
|
|
154
|
+
const mergedEvents = useMemo(() => {
|
|
155
|
+
const next = [...historyEvents, ...streamingSessionEvents];
|
|
156
|
+
if (streamingAssistantText.trim()) {
|
|
157
|
+
const maxSeq = next.reduce((max, event) => {
|
|
158
|
+
const seq = Number.isFinite(event.seq) ? event.seq : 0;
|
|
159
|
+
return seq > max ? seq : max;
|
|
160
|
+
}, 0);
|
|
161
|
+
next.push({
|
|
162
|
+
seq: maxSeq + 1,
|
|
163
|
+
type: 'stream.assistant_delta',
|
|
164
|
+
timestamp: streamingAssistantTimestamp ?? new Date().toISOString(),
|
|
165
|
+
message: {
|
|
166
|
+
role: 'assistant',
|
|
167
|
+
content: streamingAssistantText,
|
|
168
|
+
timestamp: streamingAssistantTimestamp ?? new Date().toISOString()
|
|
169
|
+
}
|
|
170
|
+
});
|
|
122
171
|
}
|
|
123
172
|
return next;
|
|
124
|
-
}, [
|
|
173
|
+
}, [historyEvents, streamingAssistantText, streamingAssistantTimestamp, streamingSessionEvents]);
|
|
125
174
|
|
|
126
175
|
useEffect(() => {
|
|
127
|
-
if (!selectedSessionKey &&
|
|
128
|
-
setSelectedSessionKey(
|
|
176
|
+
if (!selectedSessionKey && filteredSessions.length > 0) {
|
|
177
|
+
setSelectedSessionKey(filteredSessions[0].key);
|
|
129
178
|
}
|
|
130
|
-
}, [
|
|
179
|
+
}, [filteredSessions, selectedSessionKey]);
|
|
131
180
|
|
|
132
181
|
useEffect(() => {
|
|
133
182
|
writeStoredSessionKey(selectedSessionKey);
|
|
@@ -153,7 +202,7 @@ export function ChatPage() {
|
|
|
153
202
|
return;
|
|
154
203
|
}
|
|
155
204
|
element.scrollTop = element.scrollHeight;
|
|
156
|
-
}, [
|
|
205
|
+
}, [mergedEvents, isSending, selectedSessionKey]);
|
|
157
206
|
|
|
158
207
|
useEffect(() => {
|
|
159
208
|
return () => {
|
|
@@ -165,10 +214,11 @@ export function ChatPage() {
|
|
|
165
214
|
streamRunIdRef.current += 1;
|
|
166
215
|
setIsSending(false);
|
|
167
216
|
setQueuedMessages([]);
|
|
168
|
-
|
|
217
|
+
setStreamingSessionEvents([]);
|
|
218
|
+
setStreamingAssistantText('');
|
|
219
|
+
setStreamingAssistantTimestamp(null);
|
|
169
220
|
const next = buildNewSessionKey(selectedAgentId);
|
|
170
221
|
setSelectedSessionKey(next);
|
|
171
|
-
setOptimisticUserMessage(null);
|
|
172
222
|
};
|
|
173
223
|
|
|
174
224
|
const handleDeleteSession = async () => {
|
|
@@ -190,9 +240,10 @@ export function ChatPage() {
|
|
|
190
240
|
streamRunIdRef.current += 1;
|
|
191
241
|
setIsSending(false);
|
|
192
242
|
setQueuedMessages([]);
|
|
193
|
-
|
|
243
|
+
setStreamingSessionEvents([]);
|
|
244
|
+
setStreamingAssistantText('');
|
|
245
|
+
setStreamingAssistantTimestamp(null);
|
|
194
246
|
setSelectedSessionKey(null);
|
|
195
|
-
setOptimisticUserMessage(null);
|
|
196
247
|
await sessionsQuery.refetch();
|
|
197
248
|
}
|
|
198
249
|
}
|
|
@@ -203,17 +254,15 @@ export function ChatPage() {
|
|
|
203
254
|
streamRunIdRef.current += 1;
|
|
204
255
|
const runId = streamRunIdRef.current;
|
|
205
256
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
content: item.message,
|
|
210
|
-
timestamp: new Date().toISOString()
|
|
211
|
-
});
|
|
257
|
+
setStreamingSessionEvents([]);
|
|
258
|
+
setStreamingAssistantText('');
|
|
259
|
+
setStreamingAssistantTimestamp(null);
|
|
212
260
|
setIsSending(true);
|
|
213
261
|
|
|
214
262
|
try {
|
|
215
263
|
let streamText = '';
|
|
216
264
|
const streamTimestamp = new Date().toISOString();
|
|
265
|
+
setStreamingAssistantTimestamp(streamTimestamp);
|
|
217
266
|
|
|
218
267
|
const result = await sendChatTurnStream({
|
|
219
268
|
message: item.message,
|
|
@@ -235,17 +284,30 @@ export function ChatPage() {
|
|
|
235
284
|
return;
|
|
236
285
|
}
|
|
237
286
|
streamText += event.delta;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
287
|
+
setStreamingAssistantText(streamText);
|
|
288
|
+
},
|
|
289
|
+
onSessionEvent: (event) => {
|
|
290
|
+
if (runId !== streamRunIdRef.current) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
setStreamingSessionEvents((prev) => {
|
|
294
|
+
const next = [...prev];
|
|
295
|
+
const hit = next.findIndex((item) => item.seq === event.data.seq);
|
|
296
|
+
if (hit >= 0) {
|
|
297
|
+
next[hit] = event.data;
|
|
298
|
+
} else {
|
|
299
|
+
next.push(event.data);
|
|
300
|
+
}
|
|
301
|
+
return next;
|
|
242
302
|
});
|
|
303
|
+
if (event.data.message?.role === 'assistant') {
|
|
304
|
+
setStreamingAssistantText('');
|
|
305
|
+
}
|
|
243
306
|
}
|
|
244
307
|
});
|
|
245
308
|
if (runId !== streamRunIdRef.current) {
|
|
246
309
|
return;
|
|
247
310
|
}
|
|
248
|
-
setOptimisticUserMessage(null);
|
|
249
311
|
if (result.sessionKey !== item.sessionKey) {
|
|
250
312
|
setSelectedSessionKey(result.sessionKey);
|
|
251
313
|
}
|
|
@@ -254,7 +316,9 @@ export function ChatPage() {
|
|
|
254
316
|
if (!activeSessionKey || activeSessionKey === item.sessionKey || activeSessionKey === result.sessionKey) {
|
|
255
317
|
await historyQuery.refetch();
|
|
256
318
|
}
|
|
257
|
-
|
|
319
|
+
setStreamingSessionEvents([]);
|
|
320
|
+
setStreamingAssistantText('');
|
|
321
|
+
setStreamingAssistantTimestamp(null);
|
|
258
322
|
setIsSending(false);
|
|
259
323
|
} catch {
|
|
260
324
|
if (runId !== streamRunIdRef.current) {
|
|
@@ -262,8 +326,9 @@ export function ChatPage() {
|
|
|
262
326
|
}
|
|
263
327
|
streamRunIdRef.current += 1;
|
|
264
328
|
setIsSending(false);
|
|
265
|
-
|
|
266
|
-
|
|
329
|
+
setStreamingSessionEvents([]);
|
|
330
|
+
setStreamingAssistantText('');
|
|
331
|
+
setStreamingAssistantTimestamp(null);
|
|
267
332
|
if (options?.restoreDraftOnError) {
|
|
268
333
|
setDraft((prev) => prev.trim().length === 0 ? item.message : prev);
|
|
269
334
|
}
|
|
@@ -338,6 +403,19 @@ export function ChatPage() {
|
|
|
338
403
|
className="pl-8 h-9 rounded-lg text-xs"
|
|
339
404
|
/>
|
|
340
405
|
</div>
|
|
406
|
+
<Select value={selectedChannel} onValueChange={setSelectedChannel}>
|
|
407
|
+
<SelectTrigger className="h-9 rounded-lg text-xs">
|
|
408
|
+
<SelectValue placeholder={t('sessionsAllChannels')} />
|
|
409
|
+
</SelectTrigger>
|
|
410
|
+
<SelectContent>
|
|
411
|
+
<SelectItem value="all">{t('sessionsAllChannels')}</SelectItem>
|
|
412
|
+
{channelOptions.map((channel) => (
|
|
413
|
+
<SelectItem key={channel} value={channel}>
|
|
414
|
+
{displayChannelName(channel)}
|
|
415
|
+
</SelectItem>
|
|
416
|
+
))}
|
|
417
|
+
</SelectContent>
|
|
418
|
+
</Select>
|
|
341
419
|
<div className="grid grid-cols-2 gap-2">
|
|
342
420
|
<Button variant="outline" size="sm" className="rounded-lg" onClick={() => sessionsQuery.refetch()}>
|
|
343
421
|
<RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', sessionsQuery.isFetching && 'animate-spin')} />
|
|
@@ -353,14 +431,14 @@ export function ChatPage() {
|
|
|
353
431
|
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-2">
|
|
354
432
|
{sessionsQuery.isLoading ? (
|
|
355
433
|
<div className="text-sm text-gray-500 p-4">{t('sessionsLoading')}</div>
|
|
356
|
-
) :
|
|
434
|
+
) : filteredSessions.length === 0 ? (
|
|
357
435
|
<div className="p-5 m-2 rounded-xl border border-dashed border-gray-200 text-center text-sm text-gray-500">
|
|
358
436
|
<MessageSquareText className="h-7 w-7 mx-auto mb-2 text-gray-300" />
|
|
359
437
|
{t('sessionsEmpty')}
|
|
360
438
|
</div>
|
|
361
439
|
) : (
|
|
362
440
|
<div className="space-y-1">
|
|
363
|
-
{
|
|
441
|
+
{filteredSessions.map((session) => {
|
|
364
442
|
const active = selectedSessionKey === session.key;
|
|
365
443
|
return (
|
|
366
444
|
<button
|
|
@@ -387,8 +465,9 @@ export function ChatPage() {
|
|
|
387
465
|
</aside>
|
|
388
466
|
|
|
389
467
|
<section className="flex-1 min-h-0 rounded-2xl border border-gray-200 bg-gradient-to-b from-gray-50/60 to-white shadow-card flex flex-col overflow-hidden">
|
|
390
|
-
<div className="px-5 py-4 border-b border-gray-200/80 bg-white/80 backdrop-blur-sm
|
|
391
|
-
<div className="
|
|
468
|
+
<div className="px-5 py-4 border-b border-gray-200/80 bg-white/80 backdrop-blur-sm">
|
|
469
|
+
<div className="grid gap-3 lg:grid-cols-[minmax(220px,300px)_minmax(0,1fr)_auto] items-end">
|
|
470
|
+
<div className="min-w-0">
|
|
392
471
|
<div className="text-[11px] text-gray-500 mb-1">{t('chatAgentLabel')}</div>
|
|
393
472
|
<Select value={selectedAgentId} onValueChange={setSelectedAgentId}>
|
|
394
473
|
<SelectTrigger className="h-9 rounded-lg">
|
|
@@ -404,23 +483,24 @@ export function ChatPage() {
|
|
|
404
483
|
</Select>
|
|
405
484
|
</div>
|
|
406
485
|
|
|
407
|
-
|
|
486
|
+
<div className="min-w-0">
|
|
408
487
|
<div className="text-[11px] text-gray-500 mb-1">{t('chatSessionLabel')}</div>
|
|
409
488
|
<div className="h-9 rounded-lg border border-gray-200 bg-white px-3 text-xs text-gray-600 flex items-center truncate">
|
|
410
489
|
{selectedSessionKey ?? t('chatNoSession')}
|
|
411
490
|
</div>
|
|
412
491
|
</div>
|
|
413
492
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
493
|
+
<Button
|
|
494
|
+
variant="outline"
|
|
495
|
+
size="sm"
|
|
496
|
+
className="rounded-lg"
|
|
497
|
+
onClick={handleDeleteSession}
|
|
498
|
+
disabled={!selectedSession || deleteSession.isPending}
|
|
499
|
+
>
|
|
500
|
+
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
501
|
+
{t('chatDeleteSession')}
|
|
502
|
+
</Button>
|
|
503
|
+
</div>
|
|
424
504
|
</div>
|
|
425
505
|
|
|
426
506
|
<div ref={threadRef} className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-5 py-5">
|
|
@@ -436,10 +516,10 @@ export function ChatPage() {
|
|
|
436
516
|
<div className="text-sm text-gray-500">{t('chatHistoryLoading')}</div>
|
|
437
517
|
) : (
|
|
438
518
|
<>
|
|
439
|
-
{
|
|
519
|
+
{mergedEvents.length === 0 ? (
|
|
440
520
|
<div className="text-sm text-gray-500">{t('chatNoMessages')}</div>
|
|
441
521
|
) : (
|
|
442
|
-
<ChatThread
|
|
522
|
+
<ChatThread events={mergedEvents} isSending={isSending && !streamingAssistantText.trim()} />
|
|
443
523
|
)}
|
|
444
524
|
</>
|
|
445
525
|
)}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
|
-
import type { SessionMessageView } from '@/api/types';
|
|
2
|
+
import type { SessionEventView, SessionMessageView } from '@/api/types';
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
buildChatTimeline,
|
|
6
6
|
extractMessageText,
|
|
7
7
|
extractToolCards,
|
|
8
|
-
|
|
8
|
+
normalizeChatRole,
|
|
9
9
|
type ChatRole,
|
|
10
|
+
type ChatTimelineAssistantFlowItem,
|
|
10
11
|
type ToolCard
|
|
11
12
|
} from '@/lib/chat-message';
|
|
12
13
|
import { formatDateTime, t } from '@/lib/i18n';
|
|
@@ -16,7 +17,7 @@ import remarkGfm from 'remark-gfm';
|
|
|
16
17
|
import { Bot, Clock3, FileSearch, Globe, Search, SendHorizontal, Terminal, User, Wrench } from 'lucide-react';
|
|
17
18
|
|
|
18
19
|
type ChatThreadProps = {
|
|
19
|
-
|
|
20
|
+
events: SessionEventView[];
|
|
20
21
|
isSending: boolean;
|
|
21
22
|
className?: string;
|
|
22
23
|
};
|
|
@@ -142,12 +143,26 @@ function ToolCardView({ card }: { card: ToolCard }) {
|
|
|
142
143
|
);
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
function
|
|
146
|
-
|
|
146
|
+
function ReasoningBlock({ reasoning, isUser }: { reasoning: string; isUser: boolean }) {
|
|
147
|
+
return (
|
|
148
|
+
<details className="mt-3">
|
|
149
|
+
<summary className={cn('cursor-pointer text-xs', isUser ? 'text-primary-100' : 'text-gray-500')}>
|
|
150
|
+
{t('chatReasoning')}
|
|
151
|
+
</summary>
|
|
152
|
+
<pre className={cn('mt-2 text-[11px] whitespace-pre-wrap break-words rounded-lg p-2', isUser ? 'bg-primary-700/60' : 'bg-gray-100')}>
|
|
153
|
+
{reasoning}
|
|
154
|
+
</pre>
|
|
155
|
+
</details>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function MessageCard({ message }: { message: SessionMessageView }) {
|
|
160
|
+
const role = normalizeChatRole(message);
|
|
161
|
+
const primaryText = extractMessageText(message.content).trim();
|
|
162
|
+
const primaryReasoning = typeof message.reasoning_content === 'string' ? message.reasoning_content.trim() : '';
|
|
147
163
|
const toolCards = extractToolCards(message);
|
|
148
|
-
const reasoning = typeof message.reasoning_content === 'string' ? message.reasoning_content.trim() : '';
|
|
149
|
-
const shouldRenderText = Boolean(text) && !(role === 'tool' && toolCards.length > 0);
|
|
150
164
|
const isUser = role === 'user';
|
|
165
|
+
const shouldRenderPrimaryText = Boolean(primaryText) && !(role === 'tool' && toolCards.length > 0);
|
|
151
166
|
|
|
152
167
|
return (
|
|
153
168
|
<div
|
|
@@ -160,19 +175,10 @@ function MessageCard({ message, role }: { message: SessionMessageView; role: Cha
|
|
|
160
175
|
: 'bg-orange-50/70 text-gray-900 border-orange-200/80'
|
|
161
176
|
)}
|
|
162
177
|
>
|
|
163
|
-
{
|
|
164
|
-
{
|
|
165
|
-
<details className="mt-3">
|
|
166
|
-
<summary className={cn('cursor-pointer text-xs', isUser ? 'text-primary-100' : 'text-gray-500')}>
|
|
167
|
-
{t('chatReasoning')}
|
|
168
|
-
</summary>
|
|
169
|
-
<pre className={cn('mt-2 text-[11px] whitespace-pre-wrap break-words rounded-lg p-2', isUser ? 'bg-primary-700/60' : 'bg-gray-100')}>
|
|
170
|
-
{reasoning}
|
|
171
|
-
</pre>
|
|
172
|
-
</details>
|
|
173
|
-
)}
|
|
178
|
+
{shouldRenderPrimaryText && <MarkdownBlock text={primaryText} role={role} />}
|
|
179
|
+
{primaryReasoning && <ReasoningBlock reasoning={primaryReasoning} isUser={isUser} />}
|
|
174
180
|
{toolCards.length > 0 && (
|
|
175
|
-
<div className=
|
|
181
|
+
<div className={cn('space-y-2', (shouldRenderPrimaryText || primaryReasoning) && 'mt-3')}>
|
|
176
182
|
{toolCards.map((card, index) => (
|
|
177
183
|
<ToolCardView key={`${card.kind}-${card.name}-${card.callId ?? index}`} card={card} />
|
|
178
184
|
))}
|
|
@@ -182,26 +188,46 @@ function MessageCard({ message, role }: { message: SessionMessageView; role: Cha
|
|
|
182
188
|
);
|
|
183
189
|
}
|
|
184
190
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
191
|
+
function AssistantFlowCard({ item }: { item: ChatTimelineAssistantFlowItem }) {
|
|
192
|
+
return (
|
|
193
|
+
<div className="rounded-2xl border px-4 py-3 shadow-sm bg-white text-gray-900 border-gray-200">
|
|
194
|
+
{item.primaryText && <MarkdownBlock text={item.primaryText} role="assistant" />}
|
|
195
|
+
{item.primaryReasoning && <ReasoningBlock reasoning={item.primaryReasoning} isUser={false} />}
|
|
196
|
+
{item.toolCards.length > 0 && (
|
|
197
|
+
<div className={cn('space-y-2', (item.primaryText || item.primaryReasoning) && 'mt-3')}>
|
|
198
|
+
{item.toolCards.map((card, index) => (
|
|
199
|
+
<ToolCardView key={`${card.kind}-${card.name}-${card.callId ?? index}`} card={card} />
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
{item.followupReasoning && <ReasoningBlock reasoning={item.followupReasoning} isUser={false} />}
|
|
204
|
+
{item.followupText && <div className="mt-3"><MarkdownBlock text={item.followupText} role="assistant" /></div>}
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function ChatThread({ events, isSending, className }: ChatThreadProps) {
|
|
210
|
+
const timeline = useMemo(() => buildChatTimeline(events), [events]);
|
|
188
211
|
|
|
189
212
|
return (
|
|
190
213
|
<div className={cn('space-y-5', className)}>
|
|
191
|
-
{
|
|
192
|
-
const
|
|
214
|
+
{timeline.map((item) => {
|
|
215
|
+
const role = item.kind === 'assistant_flow' ? 'assistant' : item.role;
|
|
216
|
+
const isUser = role === 'user';
|
|
193
217
|
return (
|
|
194
|
-
<div key={
|
|
195
|
-
{!isUser && <RoleAvatar role={
|
|
218
|
+
<div key={item.key} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
|
|
219
|
+
{!isUser && <RoleAvatar role={role} />}
|
|
196
220
|
<div className={cn('max-w-[88%] min-w-[280px] space-y-2', isUser && 'flex flex-col items-end')}>
|
|
197
|
-
{
|
|
198
|
-
<
|
|
199
|
-
)
|
|
221
|
+
{item.kind === 'assistant_flow' ? (
|
|
222
|
+
<AssistantFlowCard item={item} />
|
|
223
|
+
) : (
|
|
224
|
+
<MessageCard message={item.message} />
|
|
225
|
+
)}
|
|
200
226
|
<div className={cn('text-[11px] px-1', isUser ? 'text-primary-300' : 'text-gray-400')}>
|
|
201
|
-
{roleTitle(
|
|
227
|
+
{roleTitle(role)} · {formatDateTime(item.timestamp)}
|
|
202
228
|
</div>
|
|
203
229
|
</div>
|
|
204
|
-
{isUser && <RoleAvatar role={
|
|
230
|
+
{isUser && <RoleAvatar role={role} />}
|
|
205
231
|
</div>
|
|
206
232
|
);
|
|
207
233
|
})}
|