@hef2024/llmasaservice-ui 0.16.8
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 +162 -0
- package/dist/index.css +3239 -0
- package/dist/index.d.mts +521 -0
- package/dist/index.d.ts +521 -0
- package/dist/index.js +5885 -0
- package/dist/index.mjs +5851 -0
- package/index.ts +28 -0
- package/package.json +70 -0
- package/src/AIAgentPanel.css +1354 -0
- package/src/AIAgentPanel.tsx +1883 -0
- package/src/AIChatPanel.css +1618 -0
- package/src/AIChatPanel.tsx +1725 -0
- package/src/AgentPanel.tsx +323 -0
- package/src/ChatPanel.css +1093 -0
- package/src/ChatPanel.tsx +3583 -0
- package/src/ChatStatus.tsx +40 -0
- package/src/EmailModal.tsx +56 -0
- package/src/ToolInfoModal.tsx +49 -0
- package/src/components/ui/Button.tsx +57 -0
- package/src/components/ui/Dialog.tsx +153 -0
- package/src/components/ui/Input.tsx +33 -0
- package/src/components/ui/ScrollArea.tsx +29 -0
- package/src/components/ui/Select.tsx +156 -0
- package/src/components/ui/Tooltip.tsx +73 -0
- package/src/components/ui/index.ts +20 -0
- package/src/hooks/useAgentRegistry.ts +349 -0
- package/src/hooks/useConversationStore.ts +313 -0
- package/src/mcpClient.ts +107 -0
- package/tsconfig.json +108 -0
- package/types/declarations.d.ts +22 -0
|
@@ -0,0 +1,1725 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AIChatPanel - A modern chat interface using shadcn-style components
|
|
3
|
+
*
|
|
4
|
+
* This component provides the chat functionality for AIAgentPanel,
|
|
5
|
+
* using consistent shadcn-style UI components.
|
|
6
|
+
*/
|
|
7
|
+
import React, {
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from 'react';
|
|
14
|
+
import { LLMAsAServiceCustomer, useLLM } from 'llmasaservice-client';
|
|
15
|
+
import ReactMarkdown from 'react-markdown';
|
|
16
|
+
import remarkGfm from 'remark-gfm';
|
|
17
|
+
import rehypeRaw from 'rehype-raw';
|
|
18
|
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
19
|
+
import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/material-dark.js';
|
|
20
|
+
import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light.js';
|
|
21
|
+
import { Button, ScrollArea, Tooltip } from './components/ui';
|
|
22
|
+
import './AIChatPanel.css';
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Agent option for the agent selector dropdown
|
|
30
|
+
*/
|
|
31
|
+
export interface AgentOption {
|
|
32
|
+
value: string;
|
|
33
|
+
label: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
icon?: React.ReactNode;
|
|
36
|
+
avatarUrl?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AIChatPanelProps {
|
|
40
|
+
project_id: string;
|
|
41
|
+
initialPrompt?: string;
|
|
42
|
+
initialMessage?: string;
|
|
43
|
+
title?: string;
|
|
44
|
+
placeholder?: string;
|
|
45
|
+
hideInitialPrompt?: boolean;
|
|
46
|
+
customer?: LLMAsAServiceCustomer;
|
|
47
|
+
data?: { key: string; data: string }[];
|
|
48
|
+
thumbsUpClick?: (callId: string) => void;
|
|
49
|
+
thumbsDownClick?: (callId: string) => void;
|
|
50
|
+
theme?: 'light' | 'dark';
|
|
51
|
+
url?: string | null;
|
|
52
|
+
service?: string | null;
|
|
53
|
+
historyChangedCallback?: (history: Record<string, { content: string; callId: string }>) => void;
|
|
54
|
+
responseCompleteCallback?: (callId: string, prompt: string, response: string) => void;
|
|
55
|
+
onLoadingChange?: (isLoading: boolean) => void;
|
|
56
|
+
promptTemplate?: string;
|
|
57
|
+
actions?: {
|
|
58
|
+
pattern: string;
|
|
59
|
+
type?: string;
|
|
60
|
+
markdown?: string;
|
|
61
|
+
callback?: (match: string, groups: any[]) => void;
|
|
62
|
+
clickCode?: string;
|
|
63
|
+
style?: string;
|
|
64
|
+
}[];
|
|
65
|
+
showNewConversationButton?: boolean;
|
|
66
|
+
followOnQuestions?: string[];
|
|
67
|
+
clearFollowOnQuestionsNextPrompt?: boolean;
|
|
68
|
+
followOnPrompt?: string;
|
|
69
|
+
showPoweredBy?: boolean;
|
|
70
|
+
agent?: string | null;
|
|
71
|
+
conversation?: string | null;
|
|
72
|
+
initialHistory?: Record<string, { content: string; callId: string }>;
|
|
73
|
+
hideRagContextInPrompt?: boolean;
|
|
74
|
+
createConversationOnFirstChat?: boolean;
|
|
75
|
+
mcpServers?: any[];
|
|
76
|
+
progressiveActions?: boolean;
|
|
77
|
+
|
|
78
|
+
// Agent selector props (Cursor-style at bottom of input)
|
|
79
|
+
agentOptions?: AgentOption[];
|
|
80
|
+
currentAgentId?: string;
|
|
81
|
+
onAgentChange?: (agentId: string) => void;
|
|
82
|
+
agentsLoading?: boolean;
|
|
83
|
+
|
|
84
|
+
// Context viewer props
|
|
85
|
+
contextSections?: ContextSection[];
|
|
86
|
+
totalContextTokens?: number;
|
|
87
|
+
maxContextTokens?: number;
|
|
88
|
+
enableContextDetailView?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Context section for the context viewer
|
|
93
|
+
*/
|
|
94
|
+
export interface ContextSection {
|
|
95
|
+
id: string;
|
|
96
|
+
title: string;
|
|
97
|
+
data: Record<string, unknown>;
|
|
98
|
+
tokens?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface HistoryEntry {
|
|
102
|
+
content: string;
|
|
103
|
+
callId: string;
|
|
104
|
+
toolCalls?: any[];
|
|
105
|
+
toolResponses?: any[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface ThinkingBlock {
|
|
109
|
+
type: 'reasoning' | 'searching';
|
|
110
|
+
content: string;
|
|
111
|
+
index: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// Icons
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
const SendIcon = () => (
|
|
119
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon">
|
|
120
|
+
<path d="M10 14l11-11M21 3l-6.5 18a.55.55 0 0 1-1 0l-3.5-7-7-3.5a.55.55 0 0 1 0-1L21 3" />
|
|
121
|
+
</svg>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const ArrowUpIcon = () => (
|
|
125
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
126
|
+
<path d="M12 19V5M5 12l7-7 7 7" />
|
|
127
|
+
</svg>
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const StopIcon = () => (
|
|
131
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="ai-chat-icon">
|
|
132
|
+
<rect x="6" y="6" width="12" height="12" rx="2" />
|
|
133
|
+
</svg>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const CopyIcon = () => (
|
|
137
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
138
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
139
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
140
|
+
</svg>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const ThumbsUpIcon = () => (
|
|
144
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
145
|
+
<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" />
|
|
146
|
+
</svg>
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const ThumbsDownIcon = () => (
|
|
150
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
151
|
+
<path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17" />
|
|
152
|
+
</svg>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const RefreshIcon = () => (
|
|
156
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
157
|
+
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
|
158
|
+
<path d="M21 3v5h-5" />
|
|
159
|
+
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
|
160
|
+
<path d="M8 16H3v5" />
|
|
161
|
+
</svg>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const ChevronDownIcon = () => (
|
|
165
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
166
|
+
<path d="m6 9 6 6 6-6" />
|
|
167
|
+
</svg>
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const ChevronUpIcon = () => (
|
|
171
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
172
|
+
<path d="m18 15-6-6-6 6" />
|
|
173
|
+
</svg>
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const AgentIcon = () => (
|
|
177
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
178
|
+
<path d="M12 8V4H8" />
|
|
179
|
+
<rect width="16" height="12" x="4" y="8" rx="2" />
|
|
180
|
+
<path d="M2 14h2" />
|
|
181
|
+
<path d="M20 14h2" />
|
|
182
|
+
<path d="M15 13v2" />
|
|
183
|
+
<path d="M9 13v2" />
|
|
184
|
+
</svg>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const CheckIcon = () => (
|
|
188
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
189
|
+
<polyline points="20 6 9 17 4 12" />
|
|
190
|
+
</svg>
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const BrainIcon = () => (
|
|
194
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
195
|
+
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
|
|
196
|
+
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
|
|
197
|
+
</svg>
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const SearchIcon = () => (
|
|
201
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
202
|
+
<circle cx="11" cy="11" r="8" />
|
|
203
|
+
<path d="m21 21-4.3-4.3" />
|
|
204
|
+
</svg>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const ContextViewerIcon = () => (
|
|
208
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
|
|
209
|
+
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
|
210
|
+
<polyline points="14 2 14 8 20 8" />
|
|
211
|
+
<line x1="16" x2="8" y1="13" y2="13" />
|
|
212
|
+
<line x1="16" x2="8" y1="17" y2="17" />
|
|
213
|
+
<line x1="10" x2="8" y1="9" y2="9" />
|
|
214
|
+
</svg>
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const ChevronRightSmallIcon = () => (
|
|
218
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-xs">
|
|
219
|
+
<path d="m9 18 6-6-6-6" />
|
|
220
|
+
</svg>
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const LLMAsAServiceLogo = () => (
|
|
224
|
+
<svg width="16" height="16" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
225
|
+
<ellipse cx="14.0868" cy="59.2146" rx="7.8261" ry="7.7854" fill="#2487D8" />
|
|
226
|
+
<ellipse cx="24.9013" cy="43.0776" rx="6.11858" ry="6.08676" fill="#2487D8" />
|
|
227
|
+
<ellipse cx="45.391" cy="43.0776" rx="6.11858" ry="6.08676" fill="#2487D8" />
|
|
228
|
+
<ellipse cx="65.8813" cy="43.0776" rx="6.11858" ry="6.08676" fill="#2487D8" />
|
|
229
|
+
<ellipse cx="35.1461" cy="26.5327" rx="4.41103" ry="4.3878" fill="#2487D8" />
|
|
230
|
+
<ellipse cx="55.6364" cy="26.5327" rx="4.41103" ry="4.3878" fill="#2487D8" />
|
|
231
|
+
<ellipse cx="45.391" cy="10.3959" rx="2.70351" ry="2.68919" fill="#2487D8" />
|
|
232
|
+
</svg>
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// ============================================================================
|
|
236
|
+
// Isolated Input Component - Prevents full re-renders on every keystroke
|
|
237
|
+
// ============================================================================
|
|
238
|
+
|
|
239
|
+
interface ChatInputProps {
|
|
240
|
+
placeholder: string;
|
|
241
|
+
idle: boolean;
|
|
242
|
+
onSubmit: (text: string) => void;
|
|
243
|
+
onStop: () => void;
|
|
244
|
+
agentOptions: AgentOption[];
|
|
245
|
+
currentAgentId?: string;
|
|
246
|
+
onAgentChange?: (agentId: string) => void;
|
|
247
|
+
agentsLoading: boolean;
|
|
248
|
+
currentAgentLabel?: string;
|
|
249
|
+
currentAgentAvatarUrl?: string;
|
|
250
|
+
// Context viewer props
|
|
251
|
+
contextSections?: ContextSection[];
|
|
252
|
+
totalContextTokens?: number;
|
|
253
|
+
maxContextTokens?: number;
|
|
254
|
+
enableContextDetailView?: boolean;
|
|
255
|
+
onContextViewerToggle?: () => void;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const ChatInput = React.memo<ChatInputProps>(({
|
|
259
|
+
placeholder,
|
|
260
|
+
idle,
|
|
261
|
+
onSubmit,
|
|
262
|
+
onStop,
|
|
263
|
+
agentOptions,
|
|
264
|
+
currentAgentId,
|
|
265
|
+
onAgentChange,
|
|
266
|
+
agentsLoading,
|
|
267
|
+
currentAgentLabel,
|
|
268
|
+
currentAgentAvatarUrl,
|
|
269
|
+
contextSections = [],
|
|
270
|
+
totalContextTokens = 0,
|
|
271
|
+
maxContextTokens = 8000,
|
|
272
|
+
enableContextDetailView = false,
|
|
273
|
+
}) => {
|
|
274
|
+
const [inputValue, setInputValue] = useState('');
|
|
275
|
+
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
276
|
+
const [contextViewerOpen, setContextViewerOpen] = useState(false);
|
|
277
|
+
const [contextViewMode, setContextViewMode] = useState<'summary' | 'detail'>('summary');
|
|
278
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
279
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
280
|
+
const contextPopoverRef = useRef<HTMLDivElement | null>(null);
|
|
281
|
+
|
|
282
|
+
// Auto-resize textarea
|
|
283
|
+
const autoResize = useCallback(() => {
|
|
284
|
+
const textarea = textareaRef.current;
|
|
285
|
+
if (textarea) {
|
|
286
|
+
textarea.style.height = 'auto';
|
|
287
|
+
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
|
288
|
+
}
|
|
289
|
+
}, []);
|
|
290
|
+
|
|
291
|
+
// Handle submit
|
|
292
|
+
const handleSubmit = useCallback(() => {
|
|
293
|
+
const trimmed = inputValue.trim();
|
|
294
|
+
if (trimmed && idle) {
|
|
295
|
+
onSubmit(trimmed);
|
|
296
|
+
setInputValue('');
|
|
297
|
+
// Reset textarea height
|
|
298
|
+
if (textareaRef.current) {
|
|
299
|
+
textareaRef.current.style.height = 'auto';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}, [inputValue, idle, onSubmit]);
|
|
303
|
+
|
|
304
|
+
// Close dropdown on outside click
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
307
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
308
|
+
setDropdownOpen(false);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
if (dropdownOpen) {
|
|
312
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
313
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
314
|
+
}
|
|
315
|
+
}, [dropdownOpen]);
|
|
316
|
+
|
|
317
|
+
// Close context popover on outside click
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
320
|
+
if (contextPopoverRef.current && !contextPopoverRef.current.contains(event.target as Node)) {
|
|
321
|
+
setContextViewerOpen(false);
|
|
322
|
+
setContextViewMode('summary');
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
if (contextViewerOpen) {
|
|
326
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
327
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
328
|
+
}
|
|
329
|
+
}, [contextViewerOpen]);
|
|
330
|
+
|
|
331
|
+
// Format tokens for display
|
|
332
|
+
const formatTokens = (tokens: number): string => {
|
|
333
|
+
if (tokens >= 1000) {
|
|
334
|
+
return `${(tokens / 1000).toFixed(1)}K`;
|
|
335
|
+
}
|
|
336
|
+
return tokens.toString();
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// Calculate token percentage
|
|
340
|
+
const tokenPercentage = maxContextTokens > 0 ? Math.min((totalContextTokens / maxContextTokens) * 100, 100) : 0;
|
|
341
|
+
const isOverLimit = totalContextTokens > maxContextTokens;
|
|
342
|
+
|
|
343
|
+
// Detect content format (JSON, XML, or Markdown)
|
|
344
|
+
const detectFormat = (data: Record<string, unknown>): 'json' | 'xml' | 'markdown' => {
|
|
345
|
+
const str = JSON.stringify(data);
|
|
346
|
+
if (str.includes('<?xml') || str.includes('</')) return 'xml';
|
|
347
|
+
// Default to JSON for objects
|
|
348
|
+
return 'json';
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// Format content for display
|
|
352
|
+
const formatContent = (data: Record<string, unknown>, format: 'json' | 'xml' | 'markdown'): string => {
|
|
353
|
+
if (format === 'json') {
|
|
354
|
+
return JSON.stringify(data, null, 2);
|
|
355
|
+
}
|
|
356
|
+
// For XML or markdown, if it's stored as a string in the data, return it
|
|
357
|
+
if (typeof data === 'string') return data;
|
|
358
|
+
return JSON.stringify(data, null, 2);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
<div
|
|
363
|
+
className={`ai-chat-panel__input-container ${dropdownOpen ? 'ai-chat-panel__input-container--dropdown-open' : ''}`}
|
|
364
|
+
ref={containerRef}
|
|
365
|
+
>
|
|
366
|
+
<div className="ai-chat-panel__input">
|
|
367
|
+
<textarea
|
|
368
|
+
ref={textareaRef}
|
|
369
|
+
className="ai-chat-input"
|
|
370
|
+
placeholder={placeholder}
|
|
371
|
+
value={inputValue}
|
|
372
|
+
onChange={(e) => {
|
|
373
|
+
setInputValue(e.target.value);
|
|
374
|
+
setTimeout(autoResize, 0);
|
|
375
|
+
}}
|
|
376
|
+
onKeyDown={(e) => {
|
|
377
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
handleSubmit();
|
|
380
|
+
}
|
|
381
|
+
}}
|
|
382
|
+
rows={1}
|
|
383
|
+
/>
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
{/* Footer row with agent selector and send button */}
|
|
387
|
+
<div className="ai-chat-panel__input-footer">
|
|
388
|
+
{agentOptions.length > 0 ? (
|
|
389
|
+
<div className="ai-chat-agent-selector">
|
|
390
|
+
<button
|
|
391
|
+
className="ai-chat-agent-selector__trigger"
|
|
392
|
+
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
393
|
+
disabled={agentsLoading}
|
|
394
|
+
>
|
|
395
|
+
{currentAgentAvatarUrl ? (
|
|
396
|
+
<img
|
|
397
|
+
src={currentAgentAvatarUrl}
|
|
398
|
+
alt={currentAgentLabel || 'Agent'}
|
|
399
|
+
className="ai-chat-agent-selector__avatar"
|
|
400
|
+
/>
|
|
401
|
+
) : (
|
|
402
|
+
<AgentIcon />
|
|
403
|
+
)}
|
|
404
|
+
<span className="ai-chat-agent-selector__label">
|
|
405
|
+
{agentsLoading ? 'Loading...' : currentAgentLabel || 'Select agent'}
|
|
406
|
+
</span>
|
|
407
|
+
{dropdownOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
) : (
|
|
411
|
+
<div className="ai-chat-panel__input-footer-spacer" />
|
|
412
|
+
)}
|
|
413
|
+
|
|
414
|
+
{/* Context viewer pill - Cursor style */}
|
|
415
|
+
{contextSections.length > 0 && (
|
|
416
|
+
<div className="ai-chat-context-pill-wrapper">
|
|
417
|
+
<button
|
|
418
|
+
className={`ai-chat-context-pill ${contextViewerOpen ? 'ai-chat-context-pill--active' : ''} ${isOverLimit ? 'ai-chat-context-pill--warning' : ''}`}
|
|
419
|
+
onClick={(e) => {
|
|
420
|
+
e.preventDefault();
|
|
421
|
+
e.stopPropagation();
|
|
422
|
+
console.log('[ContextViewer] Button clicked, current state:', contextViewerOpen);
|
|
423
|
+
setContextViewerOpen(!contextViewerOpen);
|
|
424
|
+
if (!contextViewerOpen) {
|
|
425
|
+
setContextViewMode('summary');
|
|
426
|
+
}
|
|
427
|
+
}}
|
|
428
|
+
type="button"
|
|
429
|
+
title="View context"
|
|
430
|
+
>
|
|
431
|
+
<span className="ai-chat-context-pill__label">context: {contextSections.length} {contextSections.length === 1 ? 'section' : 'sections'}</span>
|
|
432
|
+
</button>
|
|
433
|
+
|
|
434
|
+
{/* Context popover - positioned relative to pill wrapper */}
|
|
435
|
+
{contextViewerOpen && (
|
|
436
|
+
<div
|
|
437
|
+
className={`ai-chat-context-popover ${contextViewMode === 'detail' ? 'ai-chat-context-popover--detail' : ''}`}
|
|
438
|
+
ref={contextPopoverRef}
|
|
439
|
+
onClick={(e) => e.stopPropagation()}
|
|
440
|
+
>
|
|
441
|
+
{/* Summary view */}
|
|
442
|
+
{contextViewMode === 'summary' && (
|
|
443
|
+
<div className="ai-chat-context-popover__summary">
|
|
444
|
+
<div className="ai-chat-context-popover__header">
|
|
445
|
+
<span className="ai-chat-context-popover__title">Context</span>
|
|
446
|
+
<button
|
|
447
|
+
className="ai-chat-context-popover__close"
|
|
448
|
+
onClick={() => setContextViewerOpen(false)}
|
|
449
|
+
type="button"
|
|
450
|
+
>
|
|
451
|
+
×
|
|
452
|
+
</button>
|
|
453
|
+
</div>
|
|
454
|
+
<div className="ai-chat-context-popover__token-bar">
|
|
455
|
+
<div className="ai-chat-context-popover__token-info">
|
|
456
|
+
<span className={isOverLimit ? 'ai-chat-context-popover__tokens--warning' : ''}>
|
|
457
|
+
{formatTokens(totalContextTokens)} tokens
|
|
458
|
+
</span>
|
|
459
|
+
<span className="ai-chat-context-popover__token-limit">
|
|
460
|
+
/ {formatTokens(maxContextTokens)} limit
|
|
461
|
+
</span>
|
|
462
|
+
</div>
|
|
463
|
+
<div className="ai-chat-context-popover__progress">
|
|
464
|
+
<div
|
|
465
|
+
className={`ai-chat-context-popover__progress-bar ${isOverLimit ? 'ai-chat-context-popover__progress-bar--warning' : ''}`}
|
|
466
|
+
style={{ width: `${Math.min(tokenPercentage, 100)}%` }}
|
|
467
|
+
/>
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
<div className="ai-chat-context-popover__sections">
|
|
471
|
+
{contextSections.map((section) => (
|
|
472
|
+
<div
|
|
473
|
+
key={section.id}
|
|
474
|
+
className={`ai-chat-context-popover__section-item ${enableContextDetailView ? 'ai-chat-context-popover__section-item--clickable' : ''}`}
|
|
475
|
+
onClick={() => {
|
|
476
|
+
if (enableContextDetailView) {
|
|
477
|
+
setContextViewMode('detail');
|
|
478
|
+
}
|
|
479
|
+
}}
|
|
480
|
+
>
|
|
481
|
+
<span className="ai-chat-context-popover__section-icon">📄</span>
|
|
482
|
+
<span className="ai-chat-context-popover__section-title">{section.title}</span>
|
|
483
|
+
<span className="ai-chat-context-popover__section-tokens">
|
|
484
|
+
{section.tokens || Math.ceil(JSON.stringify(section.data).length / 4)}
|
|
485
|
+
</span>
|
|
486
|
+
</div>
|
|
487
|
+
))}
|
|
488
|
+
</div>
|
|
489
|
+
{enableContextDetailView && (
|
|
490
|
+
<button
|
|
491
|
+
className="ai-chat-context-popover__expand-btn"
|
|
492
|
+
onClick={() => setContextViewMode('detail')}
|
|
493
|
+
type="button"
|
|
494
|
+
>
|
|
495
|
+
View details →
|
|
496
|
+
</button>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
499
|
+
)}
|
|
500
|
+
|
|
501
|
+
{/* Detail view */}
|
|
502
|
+
{contextViewMode === 'detail' && enableContextDetailView && (
|
|
503
|
+
<div className="ai-chat-context-popover__detail">
|
|
504
|
+
<div className="ai-chat-context-popover__header">
|
|
505
|
+
<button
|
|
506
|
+
className="ai-chat-context-popover__back"
|
|
507
|
+
onClick={() => setContextViewMode('summary')}
|
|
508
|
+
type="button"
|
|
509
|
+
>
|
|
510
|
+
← Back
|
|
511
|
+
</button>
|
|
512
|
+
<span className="ai-chat-context-popover__title">Context Details</span>
|
|
513
|
+
<button
|
|
514
|
+
className="ai-chat-context-popover__close"
|
|
515
|
+
onClick={() => setContextViewerOpen(false)}
|
|
516
|
+
type="button"
|
|
517
|
+
>
|
|
518
|
+
×
|
|
519
|
+
</button>
|
|
520
|
+
</div>
|
|
521
|
+
<div className="ai-chat-context-popover__token-bar">
|
|
522
|
+
<div className="ai-chat-context-popover__token-info">
|
|
523
|
+
<span className={isOverLimit ? 'ai-chat-context-popover__tokens--warning' : ''}>
|
|
524
|
+
{formatTokens(totalContextTokens)} tokens
|
|
525
|
+
</span>
|
|
526
|
+
<span className="ai-chat-context-popover__token-limit">
|
|
527
|
+
/ {formatTokens(maxContextTokens)} limit
|
|
528
|
+
</span>
|
|
529
|
+
</div>
|
|
530
|
+
<div className="ai-chat-context-popover__progress">
|
|
531
|
+
<div
|
|
532
|
+
className={`ai-chat-context-popover__progress-bar ${isOverLimit ? 'ai-chat-context-popover__progress-bar--warning' : ''}`}
|
|
533
|
+
style={{ width: `${Math.min(tokenPercentage, 100)}%` }}
|
|
534
|
+
/>
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
<div className="ai-chat-context-popover__detail-sections">
|
|
538
|
+
{contextSections.map((section) => {
|
|
539
|
+
const format = detectFormat(section.data);
|
|
540
|
+
return (
|
|
541
|
+
<details key={section.id} className="ai-chat-context-popover__detail-section" open>
|
|
542
|
+
<summary className="ai-chat-context-popover__detail-section-header">
|
|
543
|
+
<span className="ai-chat-context-popover__detail-section-title">{section.title}</span>
|
|
544
|
+
<span className="ai-chat-context-popover__detail-section-meta">
|
|
545
|
+
<code>{`{{${section.id}}}`}</code>
|
|
546
|
+
<span>~{section.tokens || Math.ceil(JSON.stringify(section.data).length / 4)}</span>
|
|
547
|
+
</span>
|
|
548
|
+
</summary>
|
|
549
|
+
<pre className="ai-chat-context-popover__detail-content">
|
|
550
|
+
<code>{formatContent(section.data, format)}</code>
|
|
551
|
+
</pre>
|
|
552
|
+
</details>
|
|
553
|
+
);
|
|
554
|
+
})}
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
)}
|
|
558
|
+
</div>
|
|
559
|
+
)}
|
|
560
|
+
</div>
|
|
561
|
+
)}
|
|
562
|
+
|
|
563
|
+
<button
|
|
564
|
+
className={`ai-chat-send-button ${idle && !inputValue.trim() ? 'ai-chat-send-button--disabled' : ''} ${!idle ? 'ai-chat-send-button--stop' : ''}`}
|
|
565
|
+
onClick={() => idle ? handleSubmit() : onStop()}
|
|
566
|
+
disabled={idle && !inputValue.trim()}
|
|
567
|
+
>
|
|
568
|
+
{idle ? <ArrowUpIcon /> : <StopIcon />}
|
|
569
|
+
</button>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
{/* Dropdown appears inside the container at the bottom */}
|
|
573
|
+
{agentOptions.length > 0 && dropdownOpen && (
|
|
574
|
+
<div className="ai-chat-agent-selector__dropdown-inside">
|
|
575
|
+
{agentOptions.map((option) => (
|
|
576
|
+
<button
|
|
577
|
+
key={option.value}
|
|
578
|
+
className={`ai-chat-agent-selector__option ${
|
|
579
|
+
option.value === currentAgentId ? 'ai-chat-agent-selector__option--selected' : ''
|
|
580
|
+
}`}
|
|
581
|
+
onClick={() => {
|
|
582
|
+
if (onAgentChange && option.value !== currentAgentId) {
|
|
583
|
+
onAgentChange(option.value);
|
|
584
|
+
}
|
|
585
|
+
setDropdownOpen(false);
|
|
586
|
+
}}
|
|
587
|
+
>
|
|
588
|
+
{option.avatarUrl ? (
|
|
589
|
+
<img
|
|
590
|
+
src={option.avatarUrl}
|
|
591
|
+
alt={option.label}
|
|
592
|
+
className="ai-chat-agent-selector__option-avatar"
|
|
593
|
+
/>
|
|
594
|
+
) : option.icon ? (
|
|
595
|
+
<span className="ai-chat-agent-selector__option-icon">{option.icon}</span>
|
|
596
|
+
) : null}
|
|
597
|
+
<span className="ai-chat-agent-selector__option-content">
|
|
598
|
+
<span className="ai-chat-agent-selector__option-label">{option.label}</span>
|
|
599
|
+
{option.description && (
|
|
600
|
+
<span className="ai-chat-agent-selector__option-description">{option.description}</span>
|
|
601
|
+
)}
|
|
602
|
+
</span>
|
|
603
|
+
{option.value === currentAgentId && (
|
|
604
|
+
<span className="ai-chat-agent-selector__option-check">
|
|
605
|
+
<CheckIcon />
|
|
606
|
+
</span>
|
|
607
|
+
)}
|
|
608
|
+
</button>
|
|
609
|
+
))}
|
|
610
|
+
</div>
|
|
611
|
+
)}
|
|
612
|
+
</div>
|
|
613
|
+
);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
ChatInput.displayName = 'ChatInput';
|
|
617
|
+
|
|
618
|
+
// ============================================================================
|
|
619
|
+
// Main Component
|
|
620
|
+
// ============================================================================
|
|
621
|
+
|
|
622
|
+
const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
|
623
|
+
project_id,
|
|
624
|
+
initialPrompt = '',
|
|
625
|
+
title = '',
|
|
626
|
+
placeholder = 'Type a message',
|
|
627
|
+
hideInitialPrompt = true,
|
|
628
|
+
customer = {},
|
|
629
|
+
data = [],
|
|
630
|
+
thumbsUpClick,
|
|
631
|
+
thumbsDownClick,
|
|
632
|
+
theme = 'light',
|
|
633
|
+
url = null,
|
|
634
|
+
initialMessage = '',
|
|
635
|
+
service = null,
|
|
636
|
+
historyChangedCallback,
|
|
637
|
+
responseCompleteCallback,
|
|
638
|
+
onLoadingChange,
|
|
639
|
+
promptTemplate = '',
|
|
640
|
+
actions = [],
|
|
641
|
+
showNewConversationButton = true,
|
|
642
|
+
followOnQuestions = [],
|
|
643
|
+
clearFollowOnQuestionsNextPrompt = false,
|
|
644
|
+
followOnPrompt = '',
|
|
645
|
+
showPoweredBy = true,
|
|
646
|
+
agent = null,
|
|
647
|
+
conversation = null,
|
|
648
|
+
initialHistory = {},
|
|
649
|
+
hideRagContextInPrompt = true,
|
|
650
|
+
createConversationOnFirstChat = true,
|
|
651
|
+
mcpServers = [],
|
|
652
|
+
progressiveActions = true,
|
|
653
|
+
agentOptions = [],
|
|
654
|
+
currentAgentId,
|
|
655
|
+
onAgentChange,
|
|
656
|
+
agentsLoading = false,
|
|
657
|
+
contextSections = [],
|
|
658
|
+
totalContextTokens = 0,
|
|
659
|
+
maxContextTokens = 8000,
|
|
660
|
+
enableContextDetailView = false,
|
|
661
|
+
}) => {
|
|
662
|
+
// ============================================================================
|
|
663
|
+
// State
|
|
664
|
+
// ============================================================================
|
|
665
|
+
const [lastController, setLastController] = useState(new AbortController());
|
|
666
|
+
const [lastMessages, setLastMessages] = useState<any[]>([]);
|
|
667
|
+
const [history, setHistory] = useState<Record<string, HistoryEntry>>(initialHistory);
|
|
668
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
669
|
+
const [lastPrompt, setLastPrompt] = useState<string | null>(null);
|
|
670
|
+
const [lastKey, setLastKey] = useState<string | null>(null);
|
|
671
|
+
const [currentConversation, setCurrentConversation] = useState<string | null>(conversation);
|
|
672
|
+
const [followOnQuestionsState, setFollowOnQuestionsState] = useState(followOnQuestions);
|
|
673
|
+
const [thinkingBlocks, setThinkingBlocks] = useState<ThinkingBlock[]>([]);
|
|
674
|
+
const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0);
|
|
675
|
+
const [newConversationConfirm, setNewConversationConfirm] = useState(false);
|
|
676
|
+
const [justReset, setJustReset] = useState(false);
|
|
677
|
+
const [copiedCallId, setCopiedCallId] = useState<string | null>(null);
|
|
678
|
+
|
|
679
|
+
// Refs
|
|
680
|
+
const bottomRef = useRef<HTMLDivElement | null>(null);
|
|
681
|
+
const responseAreaRef = useRef<HTMLDivElement | null>(null);
|
|
682
|
+
|
|
683
|
+
// Auto-scroll state - tracks if user has manually scrolled during streaming
|
|
684
|
+
const [userHasScrolled, setUserHasScrolled] = useState(false);
|
|
685
|
+
const lastScrollTopRef = useRef<number>(0);
|
|
686
|
+
const scrollRAFRef = useRef<number | null>(null);
|
|
687
|
+
const lastScrollTimeRef = useRef<number>(0);
|
|
688
|
+
|
|
689
|
+
// === NEW: Clean history management refs ===
|
|
690
|
+
// Track previous idle state to detect transitions (false→true = completion)
|
|
691
|
+
const prevIdleRef = useRef<boolean>(true);
|
|
692
|
+
// Track if we've already notified for the current response (prevent double-notify)
|
|
693
|
+
const hasNotifiedCompletionRef = useRef<boolean>(true);
|
|
694
|
+
// Store the latest processed history for callbacks (doesn't trigger re-renders)
|
|
695
|
+
const latestHistoryRef = useRef<Record<string, HistoryEntry>>(initialHistory);
|
|
696
|
+
|
|
697
|
+
// ============================================================================
|
|
698
|
+
// useLLM Hook
|
|
699
|
+
// ============================================================================
|
|
700
|
+
const llmResult = useLLM({
|
|
701
|
+
project_id,
|
|
702
|
+
customer: customer as LLMAsAServiceCustomer | undefined,
|
|
703
|
+
...(url && { url }),
|
|
704
|
+
...(service && { group_id: service }),
|
|
705
|
+
...(agent && { agent }),
|
|
706
|
+
...(mcpServers && mcpServers.length > 0 && { mcp_servers: mcpServers }),
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
const {
|
|
710
|
+
send,
|
|
711
|
+
response,
|
|
712
|
+
idle,
|
|
713
|
+
lastCallId,
|
|
714
|
+
stop,
|
|
715
|
+
setResponse,
|
|
716
|
+
} = llmResult;
|
|
717
|
+
|
|
718
|
+
// Tool-related properties (may not exist on all versions of useLLM)
|
|
719
|
+
const toolList = (llmResult as any).toolList || [];
|
|
720
|
+
const toolsLoading = (llmResult as any).toolsLoading || false;
|
|
721
|
+
const toolsFetchError = (llmResult as any).toolsFetchError || null;
|
|
722
|
+
|
|
723
|
+
// Refs to track latest values for cleanup and callbacks (must be after useLLM)
|
|
724
|
+
const historyCallbackRef = useRef(historyChangedCallback);
|
|
725
|
+
const responseCompleteCallbackRef = useRef(responseCompleteCallback);
|
|
726
|
+
const responseRef = useRef(response);
|
|
727
|
+
const lastKeyRef = useRef(lastKey);
|
|
728
|
+
const lastCallIdRef = useRef(lastCallId);
|
|
729
|
+
const lastPromptRef = useRef(lastPrompt);
|
|
730
|
+
|
|
731
|
+
// Keep refs in sync (these don't cause re-renders)
|
|
732
|
+
historyCallbackRef.current = historyChangedCallback;
|
|
733
|
+
responseCompleteCallbackRef.current = responseCompleteCallback;
|
|
734
|
+
responseRef.current = response;
|
|
735
|
+
lastKeyRef.current = lastKey;
|
|
736
|
+
lastCallIdRef.current = lastCallId;
|
|
737
|
+
lastPromptRef.current = lastPrompt;
|
|
738
|
+
|
|
739
|
+
// ============================================================================
|
|
740
|
+
// Memoized Values
|
|
741
|
+
// ============================================================================
|
|
742
|
+
const prismStyle = useMemo(
|
|
743
|
+
() => (theme === 'light' ? materialLight : materialDark),
|
|
744
|
+
[theme]
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
// Browser info for context (matches ChatPanel)
|
|
748
|
+
const browserInfo = useMemo(() => {
|
|
749
|
+
if (typeof window === 'undefined') return null;
|
|
750
|
+
return {
|
|
751
|
+
currentTimeUTC: new Date().toISOString(),
|
|
752
|
+
userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
753
|
+
userLanguage: navigator.language,
|
|
754
|
+
};
|
|
755
|
+
}, []);
|
|
756
|
+
|
|
757
|
+
// Data with extras (matches ChatPanel's dataWithExtras)
|
|
758
|
+
const dataWithExtras = useCallback(() => {
|
|
759
|
+
return [
|
|
760
|
+
...data,
|
|
761
|
+
{ key: '--customer_id', data: customer?.customer_id ?? '' },
|
|
762
|
+
{ key: '--customer_name', data: customer?.customer_name ?? '' },
|
|
763
|
+
{ key: '--customer_user_id', data: customer?.customer_user_id ?? '' },
|
|
764
|
+
{ key: '--customer_user_name', data: customer?.customer_user_name ?? '' },
|
|
765
|
+
{ key: '--customer_user_email', data: customer?.customer_user_email ?? '' },
|
|
766
|
+
{ key: '--currentTimeUTC', data: browserInfo?.currentTimeUTC ?? '' },
|
|
767
|
+
{ key: '--userTimezone', data: browserInfo?.userTimezone ?? '' },
|
|
768
|
+
{ key: '--userLanguage', data: browserInfo?.userLanguage ?? '' },
|
|
769
|
+
];
|
|
770
|
+
}, [data, customer, browserInfo]);
|
|
771
|
+
|
|
772
|
+
// Current agent info for display
|
|
773
|
+
const currentAgentInfo = useMemo(() => {
|
|
774
|
+
if (!currentAgentId || agentOptions.length === 0) return { label: null, avatarUrl: null };
|
|
775
|
+
const currentAgent = agentOptions.find(a => a.value === currentAgentId);
|
|
776
|
+
return {
|
|
777
|
+
label: currentAgent?.label || currentAgentId,
|
|
778
|
+
avatarUrl: currentAgent?.avatarUrl || null,
|
|
779
|
+
};
|
|
780
|
+
}, [currentAgentId, agentOptions]);
|
|
781
|
+
|
|
782
|
+
const currentAgentLabel = currentAgentInfo.label;
|
|
783
|
+
const currentAgentAvatarUrl = currentAgentInfo.avatarUrl;
|
|
784
|
+
|
|
785
|
+
// ============================================================================
|
|
786
|
+
// Callbacks
|
|
787
|
+
// ============================================================================
|
|
788
|
+
|
|
789
|
+
// Clean content for thinking block display
|
|
790
|
+
const cleanContentForDisplay = useCallback((content: string): string => {
|
|
791
|
+
let cleaned = content
|
|
792
|
+
.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
793
|
+
.replace(/\*(.*?)\*/g, '$1')
|
|
794
|
+
.replace(/\n+/g, ' ')
|
|
795
|
+
.replace(/\s+/g, ' ')
|
|
796
|
+
.trim();
|
|
797
|
+
if (cleaned.length > 100) {
|
|
798
|
+
cleaned = cleaned.substring(0, 100) + '...';
|
|
799
|
+
}
|
|
800
|
+
return cleaned || 'Thinking';
|
|
801
|
+
}, []);
|
|
802
|
+
|
|
803
|
+
// Process thinking tags from response
|
|
804
|
+
const processThinkingTags = useCallback((text: string): { cleanedText: string; blocks: ThinkingBlock[] } => {
|
|
805
|
+
const blocks: ThinkingBlock[] = [];
|
|
806
|
+
let index = 0;
|
|
807
|
+
|
|
808
|
+
// Extract reasoning blocks
|
|
809
|
+
const reasoningRegex = /<reasoning>([\s\S]*?)<\/reasoning>/gi;
|
|
810
|
+
let match;
|
|
811
|
+
while ((match = reasoningRegex.exec(text)) !== null) {
|
|
812
|
+
blocks.push({ type: 'reasoning', content: match[1] ?? '', index: index++ });
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Extract searching blocks
|
|
816
|
+
const searchingRegex = /<searching>([\s\S]*?)<\/searching>/gi;
|
|
817
|
+
while ((match = searchingRegex.exec(text)) !== null) {
|
|
818
|
+
blocks.push({ type: 'searching', content: match[1] ?? '', index: index++ });
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Sort by position in original text
|
|
822
|
+
blocks.sort((a, b) => a.index - b.index);
|
|
823
|
+
|
|
824
|
+
// Clean the text
|
|
825
|
+
let cleanedText = text
|
|
826
|
+
.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
|
|
827
|
+
.replace(/<searching>[\s\S]*?<\/searching>/gi, '')
|
|
828
|
+
.trim();
|
|
829
|
+
|
|
830
|
+
return { cleanedText, blocks };
|
|
831
|
+
}, []);
|
|
832
|
+
|
|
833
|
+
// Built-in action for agent suggestion cards
|
|
834
|
+
// Pattern: [SUGGEST_AGENT:agent-id|Agent Name|Brief reason]
|
|
835
|
+
const AGENT_SUGGESTION_ACTION = {
|
|
836
|
+
pattern: '\\[SUGGEST_AGENT:([^|\\]]+)\\|([^|\\]]+)\\|([^\\]]+)\\]',
|
|
837
|
+
markdown: '<agent-suggestion data-agent-id="$1" data-agent-name="$2" data-reason="$3"></agent-suggestion>',
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
// Process actions in content
|
|
841
|
+
const processActions = useCallback((content: string): string => {
|
|
842
|
+
// Combine built-in actions with user-provided actions
|
|
843
|
+
const allActions = [AGENT_SUGGESTION_ACTION, ...(actions || [])];
|
|
844
|
+
|
|
845
|
+
let processed = content;
|
|
846
|
+
allActions.forEach((action) => {
|
|
847
|
+
try {
|
|
848
|
+
const regex = new RegExp(action.pattern, 'g');
|
|
849
|
+
if (action.markdown) {
|
|
850
|
+
processed = processed.replace(regex, action.markdown);
|
|
851
|
+
}
|
|
852
|
+
} catch (e) {
|
|
853
|
+
console.error('Invalid action pattern:', action.pattern);
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
return processed;
|
|
858
|
+
}, [actions]);
|
|
859
|
+
|
|
860
|
+
// Format prompt for display - strips timestamp prefix and RAG context
|
|
861
|
+
const formatPromptForDisplay = useCallback((prompt: string): string => {
|
|
862
|
+
let displayPrompt = prompt;
|
|
863
|
+
|
|
864
|
+
// Strip timestamp prefix (matches ChatPanel)
|
|
865
|
+
const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
|
|
866
|
+
if (isoTimestampRegex.test(displayPrompt)) {
|
|
867
|
+
const colonIndex = displayPrompt.indexOf(':', 19);
|
|
868
|
+
displayPrompt = displayPrompt.substring(colonIndex + 1);
|
|
869
|
+
} else if (/^\d+:/.test(displayPrompt)) {
|
|
870
|
+
const colonIndex = displayPrompt.indexOf(':');
|
|
871
|
+
displayPrompt = displayPrompt.substring(colonIndex + 1);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Strip RAG context if enabled
|
|
875
|
+
if (hideRagContextInPrompt) {
|
|
876
|
+
const contextIndex = displayPrompt.indexOf('---context---');
|
|
877
|
+
if (contextIndex !== -1) {
|
|
878
|
+
displayPrompt = displayPrompt.substring(0, contextIndex).trim();
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return displayPrompt;
|
|
883
|
+
}, [hideRagContextInPrompt]);
|
|
884
|
+
|
|
885
|
+
// Copy to clipboard
|
|
886
|
+
const copyToClipboard = useCallback(async (text: string, callId: string) => {
|
|
887
|
+
try {
|
|
888
|
+
// Strip markdown/HTML for clipboard
|
|
889
|
+
const cleanText = text
|
|
890
|
+
.replace(/<[^>]*>/g, '')
|
|
891
|
+
.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
892
|
+
.replace(/\*(.*?)\*/g, '$1')
|
|
893
|
+
.replace(/#{1,6}\s/g, '')
|
|
894
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
|
895
|
+
|
|
896
|
+
await navigator.clipboard.writeText(cleanText);
|
|
897
|
+
setCopiedCallId(callId);
|
|
898
|
+
setTimeout(() => setCopiedCallId(null), 2000);
|
|
899
|
+
} catch (err) {
|
|
900
|
+
console.error('Failed to copy:', err);
|
|
901
|
+
}
|
|
902
|
+
}, []);
|
|
903
|
+
|
|
904
|
+
// Scroll to bottom - throttled using RAF to prevent layout thrashing
|
|
905
|
+
const scrollToBottom = useCallback(() => {
|
|
906
|
+
// Cancel any pending scroll
|
|
907
|
+
if (scrollRAFRef.current) {
|
|
908
|
+
cancelAnimationFrame(scrollRAFRef.current);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Throttle to max once per 100ms to prevent performance issues during streaming
|
|
912
|
+
const now = Date.now();
|
|
913
|
+
if (now - lastScrollTimeRef.current < 100) {
|
|
914
|
+
// Schedule for next frame instead
|
|
915
|
+
scrollRAFRef.current = requestAnimationFrame(() => {
|
|
916
|
+
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
|
|
917
|
+
lastScrollTimeRef.current = Date.now();
|
|
918
|
+
});
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Use 'auto' instead of 'smooth' during streaming for better performance
|
|
923
|
+
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
|
|
924
|
+
lastScrollTimeRef.current = now;
|
|
925
|
+
}, []);
|
|
926
|
+
|
|
927
|
+
// Continue chat (send message) - matches ChatPanel behavior exactly
|
|
928
|
+
// promptText is now required - comes from the isolated ChatInput component
|
|
929
|
+
const continueChat = useCallback((promptText: string) => {
|
|
930
|
+
// Clear thinking blocks for new response
|
|
931
|
+
setThinkingBlocks([]);
|
|
932
|
+
setCurrentThinkingIndex(0);
|
|
933
|
+
|
|
934
|
+
// Reset scroll tracking for new message - enable auto-scroll
|
|
935
|
+
setUserHasScrolled(false);
|
|
936
|
+
|
|
937
|
+
// Handle stop if not idle (matches ChatPanel)
|
|
938
|
+
if (!idle) {
|
|
939
|
+
stop(lastController);
|
|
940
|
+
setHistory((prevHistory) => ({
|
|
941
|
+
...prevHistory,
|
|
942
|
+
[lastKey ?? '']: {
|
|
943
|
+
content: processThinkingTags(response).cleanedText + '\n\n(response cancelled)',
|
|
944
|
+
callId: lastCallId || '',
|
|
945
|
+
},
|
|
946
|
+
}));
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (clearFollowOnQuestionsNextPrompt) {
|
|
951
|
+
setFollowOnQuestionsState([]);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const promptToSend = promptText;
|
|
955
|
+
|
|
956
|
+
if (!promptToSend || !promptToSend.trim()) return;
|
|
957
|
+
|
|
958
|
+
setIsLoading(true);
|
|
959
|
+
|
|
960
|
+
// Build messagesAndHistory from history (matches ChatPanel)
|
|
961
|
+
const messagesAndHistory: { role: string; content: string }[] = [];
|
|
962
|
+
Object.entries(history).forEach(([historyPrompt, historyEntry]) => {
|
|
963
|
+
// Strip timestamp prefix from prompt before using it (matches ChatPanel)
|
|
964
|
+
let promptForHistory = historyPrompt;
|
|
965
|
+
const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
|
|
966
|
+
if (isoTimestampRegex.test(historyPrompt)) {
|
|
967
|
+
const colonIndex = historyPrompt.indexOf(':', 19);
|
|
968
|
+
promptForHistory = historyPrompt.substring(colonIndex + 1);
|
|
969
|
+
} else if (/^\d+:/.test(historyPrompt)) {
|
|
970
|
+
const colonIndex = historyPrompt.indexOf(':');
|
|
971
|
+
promptForHistory = historyPrompt.substring(colonIndex + 1);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
messagesAndHistory.push({ role: 'user', content: promptForHistory });
|
|
975
|
+
messagesAndHistory.push({ role: 'assistant', content: historyEntry.content });
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
// Generate unique key using ISO timestamp prefix + prompt (matches ChatPanel)
|
|
979
|
+
const timestamp = new Date().toISOString();
|
|
980
|
+
const promptKey = `${timestamp}:${promptToSend.trim()}`;
|
|
981
|
+
|
|
982
|
+
// Set history entry before sending (matches ChatPanel)
|
|
983
|
+
setHistory((prevHistory) => ({
|
|
984
|
+
...prevHistory,
|
|
985
|
+
[promptKey]: { content: '', callId: '' },
|
|
986
|
+
}));
|
|
987
|
+
|
|
988
|
+
// Build the full prompt - only apply template for first message (matches ChatPanel)
|
|
989
|
+
let fullPromptToSend = promptToSend.trim();
|
|
990
|
+
if (Object.keys(history).length === 0 && promptTemplate) {
|
|
991
|
+
fullPromptToSend = promptTemplate.replace('{{prompt}}', fullPromptToSend);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Add follow-on prompt
|
|
995
|
+
if (followOnPrompt) {
|
|
996
|
+
fullPromptToSend += `\n\n${followOnPrompt}`;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const newController = new AbortController();
|
|
1000
|
+
setLastController(newController);
|
|
1001
|
+
|
|
1002
|
+
// Pass data array to send() for template replacement (e.g., {{Context}})
|
|
1003
|
+
// Pass service (group_id) and customer data just like ChatPanel does
|
|
1004
|
+
send(
|
|
1005
|
+
fullPromptToSend,
|
|
1006
|
+
messagesAndHistory,
|
|
1007
|
+
[
|
|
1008
|
+
...dataWithExtras(),
|
|
1009
|
+
{ key: '--messages', data: messagesAndHistory.length.toString() },
|
|
1010
|
+
],
|
|
1011
|
+
true, // stream
|
|
1012
|
+
true, // includeHistory
|
|
1013
|
+
service, // group_id from agent config
|
|
1014
|
+
currentConversation,
|
|
1015
|
+
newController
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
setLastPrompt(promptToSend.trim());
|
|
1019
|
+
setLastMessages(messagesAndHistory);
|
|
1020
|
+
setLastKey(promptKey);
|
|
1021
|
+
|
|
1022
|
+
// Scroll to bottom after adding the new prompt to show it immediately
|
|
1023
|
+
// Use setTimeout to ensure the DOM has updated with the new history entry
|
|
1024
|
+
setTimeout(() => {
|
|
1025
|
+
scrollToBottom();
|
|
1026
|
+
}, 0);
|
|
1027
|
+
}, [
|
|
1028
|
+
idle,
|
|
1029
|
+
stop,
|
|
1030
|
+
lastController,
|
|
1031
|
+
lastKey,
|
|
1032
|
+
response,
|
|
1033
|
+
lastCallId,
|
|
1034
|
+
processThinkingTags,
|
|
1035
|
+
clearFollowOnQuestionsNextPrompt,
|
|
1036
|
+
history,
|
|
1037
|
+
promptTemplate,
|
|
1038
|
+
followOnPrompt,
|
|
1039
|
+
send,
|
|
1040
|
+
service,
|
|
1041
|
+
currentConversation,
|
|
1042
|
+
dataWithExtras,
|
|
1043
|
+
scrollToBottom,
|
|
1044
|
+
]);
|
|
1045
|
+
|
|
1046
|
+
// Handle suggestion click - directly sends like ChatPanel does
|
|
1047
|
+
const handleSuggestionClick = useCallback((question: string) => {
|
|
1048
|
+
continueChat(question);
|
|
1049
|
+
}, [continueChat]);
|
|
1050
|
+
|
|
1051
|
+
// Handle stop - stable callback for ChatInput
|
|
1052
|
+
const handleStop = useCallback(() => {
|
|
1053
|
+
stop(lastController);
|
|
1054
|
+
}, [stop, lastController]);
|
|
1055
|
+
|
|
1056
|
+
// Reset conversation
|
|
1057
|
+
const handleNewConversation = useCallback(() => {
|
|
1058
|
+
if (!newConversationConfirm) {
|
|
1059
|
+
setNewConversationConfirm(true);
|
|
1060
|
+
setTimeout(() => setNewConversationConfirm(false), 3000);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
setNewConversationConfirm(false);
|
|
1065
|
+
|
|
1066
|
+
if (!idle) {
|
|
1067
|
+
stop(lastController);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
setResponse('');
|
|
1071
|
+
setHistory({});
|
|
1072
|
+
latestHistoryRef.current = {}; // Keep ref in sync
|
|
1073
|
+
hasNotifiedCompletionRef.current = true; // Prevent stale notifications
|
|
1074
|
+
setLastMessages([]);
|
|
1075
|
+
setLastPrompt(null);
|
|
1076
|
+
setLastKey(null);
|
|
1077
|
+
setIsLoading(false);
|
|
1078
|
+
setCurrentConversation(null);
|
|
1079
|
+
setFollowOnQuestionsState(followOnQuestions);
|
|
1080
|
+
setThinkingBlocks([]);
|
|
1081
|
+
setCurrentThinkingIndex(0);
|
|
1082
|
+
setJustReset(true);
|
|
1083
|
+
setLastController(new AbortController());
|
|
1084
|
+
setUserHasScrolled(false);
|
|
1085
|
+
|
|
1086
|
+
setTimeout(() => {
|
|
1087
|
+
setJustReset(false);
|
|
1088
|
+
responseAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
|
1089
|
+
}, 100);
|
|
1090
|
+
}, [newConversationConfirm, idle, stop, lastController, setResponse, followOnQuestions]);
|
|
1091
|
+
|
|
1092
|
+
// ============================================================================
|
|
1093
|
+
// Effects - CLEAN DESIGN: No overlapping effects, explicit triggers only
|
|
1094
|
+
// ============================================================================
|
|
1095
|
+
|
|
1096
|
+
// Effect 1: Process response for DISPLAY ONLY (no callbacks here)
|
|
1097
|
+
// Updates state for rendering, stores in ref for later callback use
|
|
1098
|
+
useEffect(() => {
|
|
1099
|
+
if (!response || !lastKey || justReset) return;
|
|
1100
|
+
|
|
1101
|
+
const { cleanedText, blocks } = processThinkingTags(response);
|
|
1102
|
+
const processedContent = processActions(cleanedText);
|
|
1103
|
+
|
|
1104
|
+
// Update display state
|
|
1105
|
+
setThinkingBlocks(blocks);
|
|
1106
|
+
|
|
1107
|
+
// Update history state for display AND store in ref for callbacks
|
|
1108
|
+
setHistory((prev) => {
|
|
1109
|
+
const newHistory = { ...prev };
|
|
1110
|
+
newHistory[lastKey] = {
|
|
1111
|
+
content: processedContent,
|
|
1112
|
+
callId: lastCallId || '',
|
|
1113
|
+
};
|
|
1114
|
+
// Keep ref in sync for callbacks (this doesn't trigger re-renders)
|
|
1115
|
+
latestHistoryRef.current = newHistory;
|
|
1116
|
+
return newHistory;
|
|
1117
|
+
});
|
|
1118
|
+
}, [response, lastKey, lastCallId, processThinkingTags, processActions, justReset]);
|
|
1119
|
+
|
|
1120
|
+
// Effect 2: Handle response completion - SINGLE POINT for all completion logic
|
|
1121
|
+
// Triggers ONLY when idle transitions from false → true
|
|
1122
|
+
useEffect(() => {
|
|
1123
|
+
const wasStreaming = !prevIdleRef.current;
|
|
1124
|
+
const isNowIdle = idle;
|
|
1125
|
+
|
|
1126
|
+
// Update ref for next comparison
|
|
1127
|
+
prevIdleRef.current = idle;
|
|
1128
|
+
|
|
1129
|
+
// Only act on completion transition (was streaming, now idle)
|
|
1130
|
+
if (wasStreaming && isNowIdle && !hasNotifiedCompletionRef.current) {
|
|
1131
|
+
hasNotifiedCompletionRef.current = true;
|
|
1132
|
+
|
|
1133
|
+
// Get the latest values from refs (stable, not from closure)
|
|
1134
|
+
const currentHistory = latestHistoryRef.current;
|
|
1135
|
+
const currentLastKey = lastKeyRef.current;
|
|
1136
|
+
const currentLastCallId = lastCallIdRef.current;
|
|
1137
|
+
const currentLastPrompt = lastPromptRef.current;
|
|
1138
|
+
|
|
1139
|
+
// Notify history changed (once, on completion)
|
|
1140
|
+
if (historyCallbackRef.current && Object.keys(currentHistory).length > 0) {
|
|
1141
|
+
historyCallbackRef.current(currentHistory);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Notify response complete
|
|
1145
|
+
if (responseCompleteCallbackRef.current && currentLastKey && currentLastCallId) {
|
|
1146
|
+
const entry = currentHistory[currentLastKey];
|
|
1147
|
+
if (entry && entry.content) {
|
|
1148
|
+
responseCompleteCallbackRef.current(currentLastCallId, currentLastPrompt || '', entry.content);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Reset notification flag when starting a new stream
|
|
1154
|
+
if (!isNowIdle && hasNotifiedCompletionRef.current) {
|
|
1155
|
+
hasNotifiedCompletionRef.current = false;
|
|
1156
|
+
}
|
|
1157
|
+
}, [idle]); // ONLY depends on idle - no history, no callbacks in deps
|
|
1158
|
+
|
|
1159
|
+
// Auto-scroll to bottom - only while streaming and user hasn't manually scrolled
|
|
1160
|
+
useEffect(() => {
|
|
1161
|
+
// Only auto-scroll if:
|
|
1162
|
+
// 1. We're actively streaming (!idle)
|
|
1163
|
+
// 2. User hasn't manually scrolled up during this response
|
|
1164
|
+
// 3. We have content to show (response exists)
|
|
1165
|
+
if (!idle && !userHasScrolled && response) {
|
|
1166
|
+
scrollToBottom();
|
|
1167
|
+
}
|
|
1168
|
+
}, [response, scrollToBottom, idle, userHasScrolled]); // Removed history dependency
|
|
1169
|
+
|
|
1170
|
+
// Ref to track idle state for scroll handler (avoids stale closure)
|
|
1171
|
+
const idleRef = useRef(idle);
|
|
1172
|
+
idleRef.current = idle;
|
|
1173
|
+
|
|
1174
|
+
// Ref to track userHasScrolled to avoid redundant state updates
|
|
1175
|
+
const userHasScrolledRef = useRef(userHasScrolled);
|
|
1176
|
+
userHasScrolledRef.current = userHasScrolled;
|
|
1177
|
+
|
|
1178
|
+
// Detect user scroll to disable auto-scroll
|
|
1179
|
+
useEffect(() => {
|
|
1180
|
+
const scrollArea = responseAreaRef.current;
|
|
1181
|
+
if (!scrollArea) return;
|
|
1182
|
+
|
|
1183
|
+
// Get the actual scrollable element (ScrollArea wraps a viewport)
|
|
1184
|
+
const scrollViewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement;
|
|
1185
|
+
const scrollElement = scrollViewport || scrollArea;
|
|
1186
|
+
|
|
1187
|
+
const handleScroll = () => {
|
|
1188
|
+
// Skip if not streaming or already marked as user-scrolled
|
|
1189
|
+
if (idleRef.current || userHasScrolledRef.current) return;
|
|
1190
|
+
|
|
1191
|
+
const currentScrollTop = scrollElement.scrollTop;
|
|
1192
|
+
const scrollHeight = scrollElement.scrollHeight;
|
|
1193
|
+
const clientHeight = scrollElement.clientHeight;
|
|
1194
|
+
|
|
1195
|
+
// If user scrolled UP (away from bottom), disable auto-scroll
|
|
1196
|
+
// We consider "near bottom" as within 100px to account for smooth scrolling
|
|
1197
|
+
const isNearBottom = scrollHeight - currentScrollTop - clientHeight < 100;
|
|
1198
|
+
|
|
1199
|
+
// User scrolled up if:
|
|
1200
|
+
// 1. They scrolled upward (currentScrollTop < lastScrollTopRef.current)
|
|
1201
|
+
// 2. AND they're not near the bottom
|
|
1202
|
+
if (currentScrollTop < lastScrollTopRef.current && !isNearBottom) {
|
|
1203
|
+
setUserHasScrolled(true);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
lastScrollTopRef.current = currentScrollTop;
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
|
1210
|
+
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
|
1211
|
+
}, []); // Empty deps - handler uses refs to get current values
|
|
1212
|
+
|
|
1213
|
+
// Update follow-on questions from props
|
|
1214
|
+
useEffect(() => {
|
|
1215
|
+
setFollowOnQuestionsState(followOnQuestions);
|
|
1216
|
+
}, [followOnQuestions]);
|
|
1217
|
+
|
|
1218
|
+
// Notify loading state changes
|
|
1219
|
+
useEffect(() => {
|
|
1220
|
+
const currentlyLoading = isLoading || !idle;
|
|
1221
|
+
if (onLoadingChange) {
|
|
1222
|
+
onLoadingChange(currentlyLoading);
|
|
1223
|
+
}
|
|
1224
|
+
}, [isLoading, idle, onLoadingChange]);
|
|
1225
|
+
|
|
1226
|
+
// Save history on unmount (for when switching conversations mid-stream)
|
|
1227
|
+
useEffect(() => {
|
|
1228
|
+
return () => {
|
|
1229
|
+
// Cancel any pending scroll animation frame
|
|
1230
|
+
if (scrollRAFRef.current) {
|
|
1231
|
+
cancelAnimationFrame(scrollRAFRef.current);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// On unmount, save the current history including any partial response
|
|
1235
|
+
const currentHistory = { ...latestHistoryRef.current };
|
|
1236
|
+
const currentResponse = responseRef.current;
|
|
1237
|
+
const currentLastKey = lastKeyRef.current;
|
|
1238
|
+
const currentLastCallId = lastCallIdRef.current;
|
|
1239
|
+
|
|
1240
|
+
// If there's a response in progress, make sure it's saved
|
|
1241
|
+
if (currentLastKey && currentResponse) {
|
|
1242
|
+
currentHistory[currentLastKey] = {
|
|
1243
|
+
content: currentResponse + '\n\n(response interrupted)',
|
|
1244
|
+
callId: currentLastCallId || '',
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Notify parent of final history state (only if not already notified)
|
|
1249
|
+
if (historyCallbackRef.current && Object.keys(currentHistory).length > 0 && !hasNotifiedCompletionRef.current) {
|
|
1250
|
+
historyCallbackRef.current(currentHistory);
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
}, []); // Empty deps - only run cleanup on unmount
|
|
1254
|
+
|
|
1255
|
+
// Auto-send initialPrompt when it changes (matches ChatPanel behavior)
|
|
1256
|
+
// IMPORTANT: Skip if we have initialHistory - that means we're loading an existing conversation
|
|
1257
|
+
useEffect(() => {
|
|
1258
|
+
// Don't auto-send initialPrompt if we loaded existing history
|
|
1259
|
+
// This prevents unwanted LLM calls when switching to loaded conversations
|
|
1260
|
+
const hasLoadedHistory = initialHistory && Object.keys(initialHistory).length > 0;
|
|
1261
|
+
|
|
1262
|
+
if (initialPrompt && initialPrompt !== '' && initialPrompt !== lastPrompt && !hasLoadedHistory) {
|
|
1263
|
+
setIsLoading(true);
|
|
1264
|
+
setThinkingBlocks([]);
|
|
1265
|
+
setCurrentThinkingIndex(0);
|
|
1266
|
+
setUserHasScrolled(false); // Enable auto-scroll for new prompt
|
|
1267
|
+
|
|
1268
|
+
const controller = new AbortController();
|
|
1269
|
+
setLastController(controller);
|
|
1270
|
+
|
|
1271
|
+
// Generate timestamp-prefixed key (matches ChatPanel)
|
|
1272
|
+
const timestamp = new Date().toISOString();
|
|
1273
|
+
const promptKey = `${timestamp}:${initialPrompt}`;
|
|
1274
|
+
|
|
1275
|
+
// Set history entry before sending (matches ChatPanel)
|
|
1276
|
+
setHistory({ [promptKey]: { content: '', callId: '' } });
|
|
1277
|
+
|
|
1278
|
+
// Build prompt with template
|
|
1279
|
+
let fullPrompt = initialPrompt;
|
|
1280
|
+
if (promptTemplate) {
|
|
1281
|
+
fullPrompt = promptTemplate.replace('{{prompt}}', initialPrompt);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
send(
|
|
1285
|
+
fullPrompt,
|
|
1286
|
+
[],
|
|
1287
|
+
[
|
|
1288
|
+
...dataWithExtras(),
|
|
1289
|
+
{ key: '--messages', data: '0' },
|
|
1290
|
+
],
|
|
1291
|
+
true,
|
|
1292
|
+
true,
|
|
1293
|
+
service,
|
|
1294
|
+
currentConversation,
|
|
1295
|
+
controller
|
|
1296
|
+
);
|
|
1297
|
+
|
|
1298
|
+
setLastPrompt(initialPrompt);
|
|
1299
|
+
setLastMessages([]);
|
|
1300
|
+
setLastKey(promptKey);
|
|
1301
|
+
}
|
|
1302
|
+
}, [initialPrompt, initialHistory]);
|
|
1303
|
+
|
|
1304
|
+
// ============================================================================
|
|
1305
|
+
// Render Helpers
|
|
1306
|
+
// ============================================================================
|
|
1307
|
+
|
|
1308
|
+
// Code block component for markdown
|
|
1309
|
+
const CodeBlock = useCallback(({ node, inline, className, children, ...props }: any) => {
|
|
1310
|
+
const match = /language-(\w+)/.exec(className || '');
|
|
1311
|
+
return !inline && match ? (
|
|
1312
|
+
<SyntaxHighlighter
|
|
1313
|
+
style={prismStyle}
|
|
1314
|
+
language={match[1]}
|
|
1315
|
+
PreTag="div"
|
|
1316
|
+
{...props}
|
|
1317
|
+
>
|
|
1318
|
+
{String(children).replace(/\n$/, '')}
|
|
1319
|
+
</SyntaxHighlighter>
|
|
1320
|
+
) : (
|
|
1321
|
+
<code className={className} {...props}>
|
|
1322
|
+
{children}
|
|
1323
|
+
</code>
|
|
1324
|
+
);
|
|
1325
|
+
}, [prismStyle]);
|
|
1326
|
+
|
|
1327
|
+
// Agent suggestion card component for inline agent handoff
|
|
1328
|
+
const AgentSuggestionCard = useCallback(({ agentId, agentName, reason }: {
|
|
1329
|
+
agentId: string;
|
|
1330
|
+
agentName: string;
|
|
1331
|
+
reason: string;
|
|
1332
|
+
}) => {
|
|
1333
|
+
if (!agentId || !onAgentChange) return null;
|
|
1334
|
+
|
|
1335
|
+
// Validate agent ID - must be a valid UUID or exist in agentOptions
|
|
1336
|
+
const isValidUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(agentId);
|
|
1337
|
+
const agentOption = agentOptions.find(opt => opt.value === agentId);
|
|
1338
|
+
const isKnownAgent = !!agentOption;
|
|
1339
|
+
const isValidAgent = isValidUUID || isKnownAgent;
|
|
1340
|
+
|
|
1341
|
+
// Look up avatar URL from agentOptions
|
|
1342
|
+
const avatarUrl = agentOption?.avatarUrl;
|
|
1343
|
+
|
|
1344
|
+
// Check if this is the currently active agent (already switched)
|
|
1345
|
+
const isCurrentAgent = currentAgentId === agentId;
|
|
1346
|
+
|
|
1347
|
+
// Don't render if agent ID looks like a placeholder (e.g., {agent-id}, $1, etc.)
|
|
1348
|
+
if (!isValidAgent || agentId.includes('{') || agentId.includes('$')) {
|
|
1349
|
+
// Render a disabled version
|
|
1350
|
+
// Use span instead of div to avoid nesting issues in markdown <p> tags
|
|
1351
|
+
return (
|
|
1352
|
+
<span className="ai-chat-agent-suggestion ai-chat-agent-suggestion--invalid">
|
|
1353
|
+
<span className="ai-chat-agent-suggestion__content">
|
|
1354
|
+
{avatarUrl ? (
|
|
1355
|
+
<img
|
|
1356
|
+
src={avatarUrl}
|
|
1357
|
+
alt={agentName}
|
|
1358
|
+
className="ai-chat-agent-suggestion__avatar"
|
|
1359
|
+
/>
|
|
1360
|
+
) : (
|
|
1361
|
+
<span className="ai-chat-agent-suggestion__icon">
|
|
1362
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
1363
|
+
<path d="M12 8V4H8" />
|
|
1364
|
+
<rect width="16" height="12" x="4" y="8" rx="2" />
|
|
1365
|
+
<path d="M2 14h2" />
|
|
1366
|
+
<path d="M20 14h2" />
|
|
1367
|
+
<path d="M15 13v2" />
|
|
1368
|
+
<path d="M9 13v2" />
|
|
1369
|
+
</svg>
|
|
1370
|
+
</span>
|
|
1371
|
+
)}
|
|
1372
|
+
<span className="ai-chat-agent-suggestion__text">
|
|
1373
|
+
<span className="ai-chat-agent-suggestion__label">Suggested: {agentName}</span>
|
|
1374
|
+
{reason && <span className="ai-chat-agent-suggestion__reason">{reason}</span>}
|
|
1375
|
+
</span>
|
|
1376
|
+
</span>
|
|
1377
|
+
<span className="ai-chat-agent-suggestion__unavailable">Agent unavailable</span>
|
|
1378
|
+
</span>
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Already switched to this agent
|
|
1383
|
+
if (isCurrentAgent) {
|
|
1384
|
+
return (
|
|
1385
|
+
<span className="ai-chat-agent-suggestion ai-chat-agent-suggestion--switched">
|
|
1386
|
+
<span className="ai-chat-agent-suggestion__content">
|
|
1387
|
+
{avatarUrl ? (
|
|
1388
|
+
<img
|
|
1389
|
+
src={avatarUrl}
|
|
1390
|
+
alt={agentName}
|
|
1391
|
+
className="ai-chat-agent-suggestion__avatar"
|
|
1392
|
+
/>
|
|
1393
|
+
) : (
|
|
1394
|
+
<span className="ai-chat-agent-suggestion__icon ai-chat-agent-suggestion__icon--switched">
|
|
1395
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
1396
|
+
<polyline points="20 6 9 17 4 12" />
|
|
1397
|
+
</svg>
|
|
1398
|
+
</span>
|
|
1399
|
+
)}
|
|
1400
|
+
<span className="ai-chat-agent-suggestion__text">
|
|
1401
|
+
<span className="ai-chat-agent-suggestion__label">Switched to {agentName}</span>
|
|
1402
|
+
{reason && <span className="ai-chat-agent-suggestion__reason">{reason}</span>}
|
|
1403
|
+
</span>
|
|
1404
|
+
</span>
|
|
1405
|
+
<span className="ai-chat-agent-suggestion__switched-badge">
|
|
1406
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
1407
|
+
<polyline points="20 6 9 17 4 12" />
|
|
1408
|
+
</svg>
|
|
1409
|
+
Active
|
|
1410
|
+
</span>
|
|
1411
|
+
</span>
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
return (
|
|
1416
|
+
<span className="ai-chat-agent-suggestion">
|
|
1417
|
+
<span className="ai-chat-agent-suggestion__content">
|
|
1418
|
+
{avatarUrl ? (
|
|
1419
|
+
<img
|
|
1420
|
+
src={avatarUrl}
|
|
1421
|
+
alt={agentName}
|
|
1422
|
+
className="ai-chat-agent-suggestion__avatar"
|
|
1423
|
+
/>
|
|
1424
|
+
) : (
|
|
1425
|
+
<span className="ai-chat-agent-suggestion__icon">
|
|
1426
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
1427
|
+
<path d="M12 8V4H8" />
|
|
1428
|
+
<rect width="16" height="12" x="4" y="8" rx="2" />
|
|
1429
|
+
<path d="M2 14h2" />
|
|
1430
|
+
<path d="M20 14h2" />
|
|
1431
|
+
<path d="M15 13v2" />
|
|
1432
|
+
<path d="M9 13v2" />
|
|
1433
|
+
</svg>
|
|
1434
|
+
</span>
|
|
1435
|
+
)}
|
|
1436
|
+
<span className="ai-chat-agent-suggestion__text">
|
|
1437
|
+
<span className="ai-chat-agent-suggestion__label">Suggested: {agentName}</span>
|
|
1438
|
+
{reason && <span className="ai-chat-agent-suggestion__reason">{reason}</span>}
|
|
1439
|
+
</span>
|
|
1440
|
+
</span>
|
|
1441
|
+
<Button
|
|
1442
|
+
variant="default"
|
|
1443
|
+
size="sm"
|
|
1444
|
+
className="ai-chat-agent-suggestion__button"
|
|
1445
|
+
onClick={() => onAgentChange(agentId)}
|
|
1446
|
+
>
|
|
1447
|
+
Switch
|
|
1448
|
+
</Button>
|
|
1449
|
+
</span>
|
|
1450
|
+
);
|
|
1451
|
+
}, [onAgentChange, agentOptions, currentAgentId]);
|
|
1452
|
+
|
|
1453
|
+
// Markdown components including custom agent-suggestion element
|
|
1454
|
+
const markdownComponents = useMemo(() => ({
|
|
1455
|
+
code: CodeBlock,
|
|
1456
|
+
'agent-suggestion': ({ node, ...props }: any) => {
|
|
1457
|
+
const agentId = props['data-agent-id'];
|
|
1458
|
+
const agentName = props['data-agent-name'];
|
|
1459
|
+
const reason = props['data-reason'];
|
|
1460
|
+
if (!agentId) return null;
|
|
1461
|
+
return (
|
|
1462
|
+
<AgentSuggestionCard
|
|
1463
|
+
agentId={agentId}
|
|
1464
|
+
agentName={agentName || agentId}
|
|
1465
|
+
reason={reason || ''}
|
|
1466
|
+
/>
|
|
1467
|
+
);
|
|
1468
|
+
},
|
|
1469
|
+
}), [CodeBlock, AgentSuggestionCard]);
|
|
1470
|
+
|
|
1471
|
+
// Render thinking blocks
|
|
1472
|
+
const renderThinkingBlocks = useCallback(() => {
|
|
1473
|
+
if (thinkingBlocks.length === 0) return null;
|
|
1474
|
+
|
|
1475
|
+
const currentBlock = thinkingBlocks[currentThinkingIndex];
|
|
1476
|
+
if (!currentBlock) return null;
|
|
1477
|
+
|
|
1478
|
+
const isReasoning = currentBlock.type === 'reasoning';
|
|
1479
|
+
const icon = isReasoning ? <BrainIcon /> : <SearchIcon />;
|
|
1480
|
+
const title = isReasoning ? 'Reasoning' : 'Searching';
|
|
1481
|
+
|
|
1482
|
+
return (
|
|
1483
|
+
<div className="ai-chat-thinking">
|
|
1484
|
+
<div className="ai-chat-thinking__header">
|
|
1485
|
+
<span className="ai-chat-thinking__icon">{icon}</span>
|
|
1486
|
+
<span className="ai-chat-thinking__title">{title}</span>
|
|
1487
|
+
{thinkingBlocks.length > 1 && (
|
|
1488
|
+
<span className="ai-chat-thinking__nav">
|
|
1489
|
+
<button
|
|
1490
|
+
onClick={() => setCurrentThinkingIndex(Math.max(0, currentThinkingIndex - 1))}
|
|
1491
|
+
disabled={currentThinkingIndex === 0}
|
|
1492
|
+
>
|
|
1493
|
+
‹
|
|
1494
|
+
</button>
|
|
1495
|
+
<span>{currentThinkingIndex + 1} / {thinkingBlocks.length}</span>
|
|
1496
|
+
<button
|
|
1497
|
+
onClick={() => setCurrentThinkingIndex(Math.min(thinkingBlocks.length - 1, currentThinkingIndex + 1))}
|
|
1498
|
+
disabled={currentThinkingIndex === thinkingBlocks.length - 1}
|
|
1499
|
+
>
|
|
1500
|
+
›
|
|
1501
|
+
</button>
|
|
1502
|
+
</span>
|
|
1503
|
+
)}
|
|
1504
|
+
</div>
|
|
1505
|
+
<div className="ai-chat-thinking__content">
|
|
1506
|
+
{cleanContentForDisplay(currentBlock.content)}
|
|
1507
|
+
</div>
|
|
1508
|
+
</div>
|
|
1509
|
+
);
|
|
1510
|
+
}, [thinkingBlocks, currentThinkingIndex, cleanContentForDisplay]);
|
|
1511
|
+
|
|
1512
|
+
// ============================================================================
|
|
1513
|
+
// Render
|
|
1514
|
+
// ============================================================================
|
|
1515
|
+
|
|
1516
|
+
const panelClasses = ['ai-chat-panel', theme === 'dark' ? 'dark-theme' : ''].filter(Boolean).join(' ');
|
|
1517
|
+
|
|
1518
|
+
return (
|
|
1519
|
+
<div className={panelClasses}>
|
|
1520
|
+
{/* Title */}
|
|
1521
|
+
{title && <div className="ai-chat-panel__title">{title}</div>}
|
|
1522
|
+
|
|
1523
|
+
{/* Messages Area */}
|
|
1524
|
+
<ScrollArea className="ai-chat-panel__messages" ref={responseAreaRef}>
|
|
1525
|
+
{/* Initial Message */}
|
|
1526
|
+
{initialMessage && (
|
|
1527
|
+
<div className="ai-chat-message ai-chat-message--assistant">
|
|
1528
|
+
<div className="ai-chat-message__content">
|
|
1529
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
|
1530
|
+
{initialMessage}
|
|
1531
|
+
</ReactMarkdown>
|
|
1532
|
+
</div>
|
|
1533
|
+
</div>
|
|
1534
|
+
)}
|
|
1535
|
+
|
|
1536
|
+
{/* History */}
|
|
1537
|
+
{Object.entries(history).map(([prompt, entry], index) => {
|
|
1538
|
+
const isLastEntry = index === Object.keys(history).length - 1;
|
|
1539
|
+
const { cleanedText } = processThinkingTags(entry.content);
|
|
1540
|
+
|
|
1541
|
+
return (
|
|
1542
|
+
<div key={index} className="ai-chat-entry">
|
|
1543
|
+
{/* User Message */}
|
|
1544
|
+
{!(hideInitialPrompt && index === 0) && (
|
|
1545
|
+
<div className="ai-chat-message ai-chat-message--user">
|
|
1546
|
+
<div className="ai-chat-message__content">
|
|
1547
|
+
{formatPromptForDisplay(prompt)}
|
|
1548
|
+
</div>
|
|
1549
|
+
</div>
|
|
1550
|
+
)}
|
|
1551
|
+
|
|
1552
|
+
{/* Assistant Response */}
|
|
1553
|
+
<div className="ai-chat-message ai-chat-message--assistant">
|
|
1554
|
+
<div className="ai-chat-message__content">
|
|
1555
|
+
{/* Streaming state */}
|
|
1556
|
+
{isLastEntry && (isLoading || !idle) && !justReset ? (
|
|
1557
|
+
<div className="ai-chat-streaming">
|
|
1558
|
+
{thinkingBlocks.length > 0 && renderThinkingBlocks()}
|
|
1559
|
+
|
|
1560
|
+
{cleanedText ? (
|
|
1561
|
+
<ReactMarkdown
|
|
1562
|
+
remarkPlugins={[remarkGfm]}
|
|
1563
|
+
rehypePlugins={[rehypeRaw]}
|
|
1564
|
+
components={markdownComponents}
|
|
1565
|
+
>
|
|
1566
|
+
{cleanedText}
|
|
1567
|
+
</ReactMarkdown>
|
|
1568
|
+
) : (
|
|
1569
|
+
<div className="ai-chat-loading">
|
|
1570
|
+
<span>Thinking</span>
|
|
1571
|
+
<span className="ai-chat-loading__dots">
|
|
1572
|
+
<span className="ai-chat-loading__dot" />
|
|
1573
|
+
<span className="ai-chat-loading__dot" />
|
|
1574
|
+
<span className="ai-chat-loading__dot" />
|
|
1575
|
+
</span>
|
|
1576
|
+
</div>
|
|
1577
|
+
)}
|
|
1578
|
+
</div>
|
|
1579
|
+
) : (
|
|
1580
|
+
<>
|
|
1581
|
+
{isLastEntry && thinkingBlocks.length > 0 && renderThinkingBlocks()}
|
|
1582
|
+
<ReactMarkdown
|
|
1583
|
+
remarkPlugins={[remarkGfm]}
|
|
1584
|
+
rehypePlugins={[rehypeRaw]}
|
|
1585
|
+
components={markdownComponents}
|
|
1586
|
+
>
|
|
1587
|
+
{cleanedText}
|
|
1588
|
+
</ReactMarkdown>
|
|
1589
|
+
</>
|
|
1590
|
+
)}
|
|
1591
|
+
</div>
|
|
1592
|
+
|
|
1593
|
+
{/* Action Buttons */}
|
|
1594
|
+
{idle && !isLoading && (
|
|
1595
|
+
<div className="ai-chat-message__actions">
|
|
1596
|
+
<Tooltip content={copiedCallId === entry.callId ? 'Copied!' : 'Copy'}>
|
|
1597
|
+
<Button
|
|
1598
|
+
variant="ghost"
|
|
1599
|
+
size="icon"
|
|
1600
|
+
onClick={() => copyToClipboard(entry.content, entry.callId)}
|
|
1601
|
+
>
|
|
1602
|
+
<CopyIcon />
|
|
1603
|
+
</Button>
|
|
1604
|
+
</Tooltip>
|
|
1605
|
+
|
|
1606
|
+
{thumbsUpClick && (
|
|
1607
|
+
<Tooltip content="Good response">
|
|
1608
|
+
<Button
|
|
1609
|
+
variant="ghost"
|
|
1610
|
+
size="icon"
|
|
1611
|
+
onClick={() => thumbsUpClick(entry.callId)}
|
|
1612
|
+
>
|
|
1613
|
+
<ThumbsUpIcon />
|
|
1614
|
+
</Button>
|
|
1615
|
+
</Tooltip>
|
|
1616
|
+
)}
|
|
1617
|
+
|
|
1618
|
+
{thumbsDownClick && (
|
|
1619
|
+
<Tooltip content="Poor response">
|
|
1620
|
+
<Button
|
|
1621
|
+
variant="ghost"
|
|
1622
|
+
size="icon"
|
|
1623
|
+
onClick={() => thumbsDownClick(entry.callId)}
|
|
1624
|
+
>
|
|
1625
|
+
<ThumbsDownIcon />
|
|
1626
|
+
</Button>
|
|
1627
|
+
</Tooltip>
|
|
1628
|
+
)}
|
|
1629
|
+
</div>
|
|
1630
|
+
)}
|
|
1631
|
+
</div>
|
|
1632
|
+
</div>
|
|
1633
|
+
);
|
|
1634
|
+
})}
|
|
1635
|
+
|
|
1636
|
+
{/* Follow-on Questions */}
|
|
1637
|
+
{followOnQuestionsState.length > 0 && idle && !isLoading && (
|
|
1638
|
+
<div className="ai-chat-suggestions">
|
|
1639
|
+
{followOnQuestionsState.map((question, index) => (
|
|
1640
|
+
<Button
|
|
1641
|
+
key={index}
|
|
1642
|
+
variant="outline"
|
|
1643
|
+
size="sm"
|
|
1644
|
+
onClick={() => handleSuggestionClick(question)}
|
|
1645
|
+
className="ai-chat-suggestions__button"
|
|
1646
|
+
>
|
|
1647
|
+
{question}
|
|
1648
|
+
</Button>
|
|
1649
|
+
))}
|
|
1650
|
+
</div>
|
|
1651
|
+
)}
|
|
1652
|
+
|
|
1653
|
+
<div ref={bottomRef} />
|
|
1654
|
+
</ScrollArea>
|
|
1655
|
+
|
|
1656
|
+
{/* New Conversation Button */}
|
|
1657
|
+
{showNewConversationButton && (
|
|
1658
|
+
<div className="ai-chat-panel__new-conversation">
|
|
1659
|
+
<Button
|
|
1660
|
+
variant={newConversationConfirm ? 'destructive' : 'outline'}
|
|
1661
|
+
size="sm"
|
|
1662
|
+
onClick={handleNewConversation}
|
|
1663
|
+
className="ai-chat-new-conversation"
|
|
1664
|
+
>
|
|
1665
|
+
{newConversationConfirm ? 'Click to Confirm' : 'New Conversation'}
|
|
1666
|
+
</Button>
|
|
1667
|
+
</div>
|
|
1668
|
+
)}
|
|
1669
|
+
|
|
1670
|
+
{/* Input Area - Isolated component for performance */}
|
|
1671
|
+
<ChatInput
|
|
1672
|
+
placeholder={placeholder}
|
|
1673
|
+
idle={idle}
|
|
1674
|
+
onSubmit={continueChat}
|
|
1675
|
+
onStop={handleStop}
|
|
1676
|
+
agentOptions={agentOptions}
|
|
1677
|
+
currentAgentId={currentAgentId}
|
|
1678
|
+
onAgentChange={onAgentChange}
|
|
1679
|
+
agentsLoading={agentsLoading}
|
|
1680
|
+
currentAgentLabel={currentAgentLabel || undefined}
|
|
1681
|
+
currentAgentAvatarUrl={currentAgentAvatarUrl || undefined}
|
|
1682
|
+
contextSections={contextSections}
|
|
1683
|
+
totalContextTokens={totalContextTokens}
|
|
1684
|
+
maxContextTokens={maxContextTokens}
|
|
1685
|
+
enableContextDetailView={enableContextDetailView}
|
|
1686
|
+
/>
|
|
1687
|
+
|
|
1688
|
+
{/* Footer */}
|
|
1689
|
+
{showPoweredBy && (
|
|
1690
|
+
<div className="ai-chat-panel__footer">
|
|
1691
|
+
{mcpServers && mcpServers.length > 0 && (
|
|
1692
|
+
<div className="ai-chat-tools-status">
|
|
1693
|
+
<span
|
|
1694
|
+
className={`ai-chat-tools-status__dot ${
|
|
1695
|
+
toolsLoading ? 'loading' : toolsFetchError ? 'error' : 'ready'
|
|
1696
|
+
}`}
|
|
1697
|
+
/>
|
|
1698
|
+
<span className="ai-chat-tools-status__text">
|
|
1699
|
+
{toolsLoading
|
|
1700
|
+
? 'tools loading...'
|
|
1701
|
+
: toolsFetchError
|
|
1702
|
+
? 'tool fetch failed'
|
|
1703
|
+
: toolList.length > 0
|
|
1704
|
+
? `${toolList.length} tools ready`
|
|
1705
|
+
: 'no tools found'}
|
|
1706
|
+
</span>
|
|
1707
|
+
</div>
|
|
1708
|
+
)}
|
|
1709
|
+
|
|
1710
|
+
<div className="ai-chat-powered-by">
|
|
1711
|
+
<span>brought to you by</span>
|
|
1712
|
+
<a href="https://llmasaservice.io" target="_blank" rel="noopener noreferrer">
|
|
1713
|
+
<LLMAsAServiceLogo />
|
|
1714
|
+
<span>llmasaservice.io</span>
|
|
1715
|
+
</a>
|
|
1716
|
+
</div>
|
|
1717
|
+
</div>
|
|
1718
|
+
)}
|
|
1719
|
+
</div>
|
|
1720
|
+
);
|
|
1721
|
+
};
|
|
1722
|
+
|
|
1723
|
+
// Memoize to prevent re-renders when parent state changes but our props don't
|
|
1724
|
+
export default React.memo(AIChatPanel);
|
|
1725
|
+
|