@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.
@@ -0,0 +1,3583 @@
1
+ import { LLMAsAServiceCustomer, useLLM } from "llmasaservice-client";
2
+ import React, {
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import ReactMarkdown from "react-markdown";
10
+
11
+ import ReactDOMServer from "react-dom/server";
12
+ import "./ChatPanel.css";
13
+ import remarkGfm from "remark-gfm";
14
+ import rehypeRaw from "rehype-raw";
15
+ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
16
+ import PrismStyle from "react-syntax-highlighter";
17
+ import materialDark from "react-syntax-highlighter/dist/esm/styles/prism/material-dark.js";
18
+ import materialLight from "react-syntax-highlighter/dist/esm/styles/prism/material-light.js";
19
+ import EmailModal from "./EmailModal";
20
+ import ToolInfoModal from "./ToolInfoModal";
21
+
22
+ export interface ChatPanelProps {
23
+ project_id: string;
24
+ initialPrompt?: string;
25
+ initialMessage?: string;
26
+ title?: string;
27
+ placeholder?: string;
28
+ hideInitialPrompt?: boolean;
29
+ customer?: LLMAsAServiceCustomer;
30
+ messages?: { role: "user" | "assistant"; content: string }[];
31
+ data?: { key: string; data: string }[];
32
+ thumbsUpClick?: (callId: string) => void;
33
+ thumbsDownClick?: (callId: string) => void;
34
+ theme?: "light" | "dark";
35
+ cssUrl?: string;
36
+ markdownClass?: string;
37
+ width?: string;
38
+ height?: string;
39
+ url?: string | null;
40
+ scrollToEnd?: boolean;
41
+ prismStyle?: PrismStyle;
42
+ service?: string | null;
43
+ historyChangedCallback?: (history: {
44
+ [key: string]: { content: string; callId: string };
45
+ }) => void;
46
+ responseCompleteCallback?: (
47
+ callId: string,
48
+ prompt: string,
49
+ response: string
50
+ ) => void;
51
+ promptTemplate?: string;
52
+ actions?: {
53
+ pattern: string;
54
+ type?: string;
55
+ markdown?: string;
56
+ callback?: (match: string, groups: any[]) => void;
57
+ clickCode?: string;
58
+ style?: string;
59
+ }[];
60
+ showSaveButton?: boolean;
61
+ showEmailButton?: boolean;
62
+ showNewConversationButton?: boolean;
63
+ followOnQuestions?: string[];
64
+ clearFollowOnQuestionsNextPrompt?: boolean;
65
+ followOnPrompt?: string;
66
+ showPoweredBy?: boolean;
67
+ agent?: string | null;
68
+ conversation?: string | null;
69
+ showCallToAction?: boolean;
70
+ callToActionButtonText?: string;
71
+ callToActionEmailAddress?: string;
72
+ callToActionEmailSubject?: string;
73
+ initialHistory?: {
74
+ [prompt: string]: { content: string; callId: string };
75
+ };
76
+ hideRagContextInPrompt?: boolean;
77
+ createConversationOnFirstChat?: boolean;
78
+ customerEmailCaptureMode?: "HIDE" | "OPTIONAL" | "REQUIRED";
79
+ customerEmailCapturePlaceholder?: string;
80
+ mcpServers?: [];
81
+ progressiveActions?: boolean;
82
+ }
83
+
84
+ interface HistoryEntry {
85
+ content: string;
86
+ callId: string;
87
+ toolCalls?: [];
88
+ toolResponses?: [];
89
+ }
90
+
91
+ interface ExtraProps extends React.HTMLAttributes<HTMLElement> {
92
+ inline?: boolean;
93
+ }
94
+
95
+ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
96
+ project_id,
97
+ initialPrompt = "",
98
+ title = "",
99
+ placeholder = "Type a message",
100
+ hideInitialPrompt = true,
101
+ customer = {},
102
+ messages = [],
103
+ data = [],
104
+ thumbsUpClick,
105
+ thumbsDownClick,
106
+ theme = "light",
107
+ cssUrl = "",
108
+ markdownClass = null,
109
+ width = "300px",
110
+ height = "100vh",
111
+ url = null,
112
+ scrollToEnd = false,
113
+ initialMessage = "",
114
+ prismStyle = theme === "light" ? materialLight : materialDark,
115
+ service = null,
116
+ historyChangedCallback = null,
117
+ responseCompleteCallback = null,
118
+ promptTemplate = "",
119
+ actions,
120
+ showSaveButton = true,
121
+ showEmailButton = true,
122
+ showNewConversationButton = true,
123
+ followOnQuestions = [],
124
+ clearFollowOnQuestionsNextPrompt = false,
125
+ followOnPrompt = "",
126
+ showPoweredBy = true,
127
+ agent = null,
128
+ conversation = null,
129
+ showCallToAction = false,
130
+ callToActionButtonText = "Submit",
131
+ callToActionEmailAddress = "",
132
+ callToActionEmailSubject = "Agent CTA submitted",
133
+ initialHistory = {},
134
+ hideRagContextInPrompt = true,
135
+ createConversationOnFirstChat = true,
136
+ customerEmailCaptureMode = "HIDE",
137
+ customerEmailCapturePlaceholder = "Please enter your email...",
138
+ mcpServers,
139
+ progressiveActions = true,
140
+ }) => {
141
+ const isEmailAddress = (email: string): boolean => {
142
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
143
+ return emailRegex.test(email);
144
+ };
145
+
146
+ const [nextPrompt, setNextPrompt] = useState("");
147
+ const [lastController, setLastController] = useState(new AbortController());
148
+ const [lastMessages, setLastMessages] = useState<any[]>([]);
149
+ const [history, setHistory] = useState<{ [prompt: string]: HistoryEntry }>(
150
+ initialHistory
151
+ );
152
+ const [isLoading, setIsLoading] = useState(false);
153
+ const [lastPrompt, setLastPrompt] = useState<string | null>(null);
154
+ const [lastKey, setLastKey] = useState<string | null>(null);
155
+ const [hasScroll, setHasScroll] = useState(false);
156
+ const bottomRef = useRef<HTMLDivElement | null>(null);
157
+ const bottomPanelRef = useRef<HTMLDivElement | null>(null);
158
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
159
+ const historyCallbackRef = useRef(historyChangedCallback);
160
+ const [isAtBottom, setIsAtBottom] = useState(true);
161
+ const [isEmailModalOpen, setIsEmailModalOpen] = useState(false);
162
+ const [isToolInfoModalOpen, setIsToolInfoModalOpen] = useState(false);
163
+ const [toolInfoData, setToolInfoData] = useState<{
164
+ calls: any[];
165
+ responses: any[];
166
+ } | null>(null);
167
+
168
+ const [currentConversation, setCurrentConversation] = useState<string | null>(
169
+ conversation
170
+ );
171
+ const [emailInput, setEmailInput] = useState(
172
+ (customer as LLMAsAServiceCustomer)?.customer_user_email ?? ""
173
+ );
174
+ const [emailInputSet, setEmailInputSet] = useState(
175
+ isEmailAddress(emailInput)
176
+ );
177
+ const [emailValid, setEmailValid] = useState(true);
178
+ const [showEmailPanel, setShowEmailPanel] = useState(
179
+ customerEmailCaptureMode !== "HIDE"
180
+ );
181
+ const [callToActionSent, setCallToActionSent] = useState(false);
182
+ const [CTAClickedButNoEmail, setCTAClickedButNoEmail] = useState(false);
183
+ const [emailSent, setEmailSent] = useState(false);
184
+ const [emailClickedButNoEmail, setEmailClickedButNoEmail] = useState(false);
185
+ const [currentCustomer, setCurrentCustomer] = useState<LLMAsAServiceCustomer>(
186
+ customer as LLMAsAServiceCustomer
187
+ );
188
+ const [justReset, setJustReset] = useState(false);
189
+ const [newConversationConfirm, setNewConversationConfirm] = useState(false);
190
+ const [allActions, setAllActions] = useState<
191
+ {
192
+ pattern: string;
193
+ type?: string;
194
+ markdown?: string;
195
+ callback?: (match: string, groups: any[]) => void;
196
+ clickCode?: string;
197
+ style?: string;
198
+ actionType?: string;
199
+ }[]
200
+ >([]);
201
+
202
+ const [pendingToolRequests, setPendingToolRequests] = useState<
203
+ {
204
+ match: string;
205
+ groups: any[];
206
+ toolName: string;
207
+ }[]
208
+ >([]);
209
+
210
+ const [followOnQuestionsState, setFollowOnQuestionsState] =
211
+ useState(followOnQuestions);
212
+
213
+ // new per‐tool approval state
214
+ const [sessionApprovedTools, setSessionApprovedTools] = useState<string[]>(
215
+ []
216
+ );
217
+ const [alwaysApprovedTools, setAlwaysApprovedTools] = useState<string[]>([]);
218
+
219
+ // State for tracking thinking content and navigation
220
+ const [thinkingBlocks, setThinkingBlocks] = useState<
221
+ Array<{ type: "reasoning" | "searching"; content: string; index: number }>
222
+ >([]);
223
+ const [currentThinkingIndex, setCurrentThinkingIndex] = useState(0);
224
+
225
+ // State for tracking user-resized textarea height
226
+ const [userResizedHeight, setUserResizedHeight] = useState<number | null>(null);
227
+
228
+ // State for pending button attachments
229
+ const [pendingButtonAttachments, setPendingButtonAttachments] = useState<
230
+ Array<{
231
+ buttonId: string;
232
+ action: any;
233
+ match: string;
234
+ groups: any[];
235
+ }>
236
+ >([]);
237
+
238
+ // Progressive actions support
239
+ const actionMatchRegistry = useRef<Map<string, string>>(new Map()); // key => stable buttonId
240
+ const deferredActionsRef = useRef<
241
+ Map<string, { action: any; match: string; groups: any[] }>
242
+ >(new Map()); // buttonId => metadata (filled during streaming)
243
+ const finalizedButtonsRef = useRef<Set<string>>(new Set()); // track activated buttonIds
244
+ const actionSequenceRef = useRef<number>(0); // incremental id source
245
+ const lastProcessedResponseRef = useRef<string>(""); // detect unchanged streaming chunks
246
+ const finalizedForCallRef = useRef<string | null>(null); // lastCallId for which finalization happened
247
+
248
+ // Keep historyChangedCallback ref in sync (avoids infinite loops from unstable references)
249
+ historyCallbackRef.current = historyChangedCallback;
250
+
251
+ // Persistent button action registry for event delegation fallback
252
+ const buttonActionRegistry = useRef<
253
+ Map<string, { action: any; match: string; groups: any[] }>
254
+ >(new Map());
255
+
256
+ // load “always” approvals
257
+ useEffect(() => {
258
+ const stored = localStorage.getItem("alwaysApprovedTools");
259
+ if (stored) setAlwaysApprovedTools(JSON.parse(stored));
260
+ }, []);
261
+
262
+ // persist “always” approvals
263
+ useEffect(() => {
264
+ localStorage.setItem(
265
+ "alwaysApprovedTools",
266
+ JSON.stringify(alwaysApprovedTools)
267
+ );
268
+ }, [alwaysApprovedTools]);
269
+
270
+ useEffect(() => {
271
+ if (followOnQuestions !== followOnQuestionsState) {
272
+ setFollowOnQuestionsState(followOnQuestions);
273
+ }
274
+ }, [followOnQuestions]);
275
+
276
+ // Cleanup button registry on unmount
277
+ useEffect(() => {
278
+ return () => {
279
+ buttonActionRegistry.current.clear();
280
+ };
281
+ }, []);
282
+
283
+ // Periodic cleanup of orphaned registry entries
284
+ useEffect(() => {
285
+ const cleanupInterval = setInterval(() => {
286
+ const registryKeys = Array.from(buttonActionRegistry.current.keys());
287
+ const orphanedKeys: string[] = [];
288
+
289
+ registryKeys.forEach((buttonId) => {
290
+ const button = document.getElementById(buttonId);
291
+ if (!button) {
292
+ orphanedKeys.push(buttonId);
293
+ buttonActionRegistry.current.delete(buttonId);
294
+ }
295
+ });
296
+
297
+ if (orphanedKeys.length > 0) {
298
+ }
299
+ }, 10000); // Cleanup every 10 seconds
300
+
301
+ return () => clearInterval(cleanupInterval);
302
+ }, []);
303
+
304
+ const [iframeUrl, setIframeUrl] = useState<string | null>(null);
305
+ const responseAreaRef = useRef(null);
306
+
307
+ // Memoized regex patterns to avoid recreation on every render
308
+ const THINKING_PATTERNS = useMemo(
309
+ () => ({
310
+ reasoning: /<reasoning>([\s\S]*?)<\/reasoning>/gi,
311
+ searching: /<searching>([\s\S]*?)<\/searching>/gi,
312
+ }),
313
+ []
314
+ );
315
+
316
+ // Memoized regex instances for better performance
317
+ const reasoningRegex = useMemo(
318
+ () => new RegExp(THINKING_PATTERNS.reasoning.source, "gi"),
319
+ [THINKING_PATTERNS.reasoning.source]
320
+ );
321
+ const searchingRegex = useMemo(
322
+ () => new RegExp(THINKING_PATTERNS.searching.source, "gi"),
323
+ [THINKING_PATTERNS.searching.source]
324
+ );
325
+
326
+ // Memoized content cleaning function
327
+ const cleanContentForDisplay = useCallback((content: string): string => {
328
+ let cleaned = content
329
+ .replace(/\*\*(.*?)\*\*/g, "$1") // Remove bold
330
+ .replace(/\*(.*?)\*/g, "$1") // Remove italics
331
+ .replace(/\n+/g, " ") // Replace newlines with spaces
332
+ .replace(/\s+/g, " ") // Normalize whitespace
333
+ .trim();
334
+
335
+ // Limit length to keep UI clean
336
+ if (cleaned.length > 100) {
337
+ cleaned = cleaned.substring(0, 100) + "...";
338
+ }
339
+
340
+ return cleaned || "Thinking";
341
+ }, []);
342
+
343
+ // Optimized function to extract thinking blocks in order
344
+ const processThinkingTags = useCallback(
345
+ (
346
+ text: string
347
+ ): {
348
+ cleanedText: string;
349
+ thinkingBlocks: Array<{
350
+ type: "reasoning" | "searching";
351
+ content: string;
352
+ index: number;
353
+ }>;
354
+ lastThinkingContent: string;
355
+ } => {
356
+ if (!text) {
357
+ return {
358
+ cleanedText: "",
359
+ thinkingBlocks: [],
360
+ lastThinkingContent: "Thinking",
361
+ };
362
+ }
363
+
364
+ // Remove zero-width space characters from keepalive before processing
365
+ // This prevents them from interfering with thinking block extraction
366
+ const processedText = text.replace(/\u200B/g, "");
367
+
368
+ const allMatches: Array<{
369
+ content: string;
370
+ index: number;
371
+ type: "reasoning" | "searching";
372
+ }> = [];
373
+
374
+ // Reset regex state for fresh matching
375
+ reasoningRegex.lastIndex = 0;
376
+ searchingRegex.lastIndex = 0;
377
+
378
+ // Process reasoning blocks
379
+ let reasoningMatch;
380
+ while ((reasoningMatch = reasoningRegex.exec(processedText)) !== null) {
381
+ const content = reasoningMatch[1]?.trim();
382
+ if (content) {
383
+ allMatches.push({
384
+ content,
385
+ index: reasoningMatch.index,
386
+ type: "reasoning",
387
+ });
388
+ }
389
+ }
390
+
391
+ // Process searching blocks
392
+ let searchingMatch;
393
+ while ((searchingMatch = searchingRegex.exec(processedText)) !== null) {
394
+ const content = searchingMatch[1]?.trim();
395
+ if (content) {
396
+ allMatches.push({
397
+ content,
398
+ index: searchingMatch.index,
399
+ type: "searching",
400
+ });
401
+ }
402
+ }
403
+
404
+ // Sort by index to preserve original order
405
+ const thinkingBlocks = allMatches.sort((a, b) => a.index - b.index);
406
+
407
+ // Clean the text by removing thinking tags
408
+ let cleanedText = processedText
409
+ .replace(THINKING_PATTERNS.reasoning, "")
410
+ .replace(THINKING_PATTERNS.searching, "")
411
+ .trim();
412
+
413
+ // Get last thinking content
414
+ let lastThinkingContent = "Thinking";
415
+ if (thinkingBlocks.length > 0) {
416
+ const lastBlock = thinkingBlocks[thinkingBlocks.length - 1];
417
+ if (lastBlock?.content) {
418
+ lastThinkingContent = cleanContentForDisplay(lastBlock.content);
419
+ }
420
+ }
421
+
422
+ return {
423
+ cleanedText,
424
+ thinkingBlocks,
425
+ lastThinkingContent,
426
+ };
427
+ },
428
+ [
429
+ THINKING_PATTERNS.reasoning,
430
+ THINKING_PATTERNS.searching,
431
+ reasoningRegex,
432
+ searchingRegex,
433
+ cleanContentForDisplay,
434
+ ]
435
+ );
436
+
437
+ // Memoized render function for thinking blocks with navigation
438
+ const renderThinkingBlocks = useCallback((): React.ReactElement | null => {
439
+ if (thinkingBlocks.length === 0) return null;
440
+
441
+ const currentBlock = thinkingBlocks[currentThinkingIndex];
442
+ if (!currentBlock) return null;
443
+
444
+ const icon = currentBlock.type === "reasoning" ? "🤔" : "🔍";
445
+ const baseTitle =
446
+ currentBlock.type === "reasoning" ? "Reasoning" : "Searching";
447
+
448
+ // Extract title from **[title]** at the beginning of content and strip formatting
449
+ const extractTitleAndContent = (
450
+ text: string
451
+ ): { displayTitle: string; content: string } => {
452
+ // Handle potential whitespace at the beginning and be more flexible with the pattern
453
+ const trimmedText = text.trim();
454
+ const titleMatch = trimmedText.match(/^\*\*\[(.*?)\]\*\*/);
455
+ if (titleMatch) {
456
+ const extractedTitle = titleMatch[1];
457
+ // Remove the title pattern and any following whitespace/newlines
458
+ const remainingContent = trimmedText
459
+ .replace(/^\*\*\[.*?\]\*\*\s*\n?/, "")
460
+ .replace(/\*\*(.*?)\*\*/g, "$1")
461
+ .trim();
462
+ return {
463
+ displayTitle: `${baseTitle}: ${extractedTitle}`,
464
+ content: remainingContent,
465
+ };
466
+ }
467
+ // If no title found, just strip bold formatting
468
+ return {
469
+ displayTitle: baseTitle,
470
+ content: trimmedText.replace(/\*\*(.*?)\*\*/g, "$1"),
471
+ };
472
+ };
473
+
474
+ const { displayTitle, content } = extractTitleAndContent(
475
+ currentBlock.content
476
+ );
477
+
478
+ return (
479
+ <div className="thinking-block-container">
480
+ <div className={`thinking-section ${currentBlock.type}-section`}>
481
+ <div className="thinking-header">
482
+ {icon} {displayTitle}
483
+ {thinkingBlocks.length > 1 && (
484
+ <div className="thinking-navigation">
485
+ <button
486
+ onClick={() =>
487
+ setCurrentThinkingIndex(
488
+ Math.max(0, currentThinkingIndex - 1)
489
+ )
490
+ }
491
+ disabled={currentThinkingIndex === 0}
492
+ className="thinking-nav-btn"
493
+ >
494
+
495
+ </button>
496
+ <span className="thinking-counter">
497
+ {currentThinkingIndex + 1} / {thinkingBlocks.length}
498
+ </span>
499
+ <button
500
+ onClick={() =>
501
+ setCurrentThinkingIndex(
502
+ Math.min(
503
+ thinkingBlocks.length - 1,
504
+ currentThinkingIndex + 1
505
+ )
506
+ )
507
+ }
508
+ disabled={currentThinkingIndex === thinkingBlocks.length - 1}
509
+ className="thinking-nav-btn"
510
+ >
511
+
512
+ </button>
513
+ </div>
514
+ )}
515
+ </div>
516
+ <div className="thinking-content">{content}</div>
517
+ </div>
518
+ </div>
519
+ );
520
+ }, [thinkingBlocks, currentThinkingIndex]);
521
+
522
+ const getBrowserInfo = () => {
523
+ try {
524
+ return {
525
+ currentTimeUTC: new Date().toISOString(),
526
+ userTimezone:
527
+ (typeof Intl !== "undefined" &&
528
+ Intl.DateTimeFormat().resolvedOptions().timeZone) ||
529
+ "unknown",
530
+ userLanguage:
531
+ (typeof navigator !== "undefined" &&
532
+ (navigator.language || navigator.language)) ||
533
+ "unknown",
534
+ };
535
+ } catch (e) {
536
+ console.warn("Error getting browser info:", e);
537
+ return {
538
+ currentTimeUTC: new Date().toISOString(),
539
+ userTimezone: "unknown",
540
+ userLanguage: "unknown",
541
+ };
542
+ }
543
+ };
544
+
545
+ const browserInfo = useMemo(() => getBrowserInfo(), []);
546
+
547
+ const dataWithExtras = () => {
548
+ return [
549
+ ...data,
550
+ { key: "--customer_id", data: currentCustomer?.customer_id ?? "" },
551
+ {
552
+ key: "--customer_name",
553
+ data: currentCustomer?.customer_name ?? "",
554
+ },
555
+ {
556
+ key: "--customer_user_id",
557
+ data: currentCustomer?.customer_user_id ?? "",
558
+ },
559
+ {
560
+ key: "--customer_user_name",
561
+ data: currentCustomer?.customer_user_name ?? "",
562
+ },
563
+ {
564
+ key: "--customer_user_email",
565
+ data: currentCustomer?.customer_user_email ?? "",
566
+ },
567
+ { key: "--email", data: emailInput ?? "" },
568
+ { key: "--emailValid", data: emailValid ? "true" : "false" },
569
+ {
570
+ key: "--emailInputSet",
571
+ data: emailInputSet ? "true" : "false",
572
+ },
573
+ {
574
+ key: "--emailPanelShowing",
575
+ data: showEmailPanel ? "true" : "false",
576
+ },
577
+ {
578
+ key: "--callToActionSent",
579
+ data: callToActionSent ? "true" : "false",
580
+ },
581
+ {
582
+ key: "--CTAClickedButNoEmail",
583
+ data: CTAClickedButNoEmail ? "true" : "false",
584
+ },
585
+ { key: "--emailSent", data: emailSent ? "true" : "false" },
586
+ {
587
+ key: "--emailClickedButNoEmail",
588
+ data: emailClickedButNoEmail ? "true" : "false",
589
+ },
590
+ {
591
+ key: "--currentTimeUTC",
592
+ data: browserInfo?.currentTimeUTC,
593
+ },
594
+ { key: "--userTimezone", data: browserInfo?.userTimezone },
595
+ { key: "--userLanguage", data: browserInfo?.userLanguage },
596
+ ];
597
+ };
598
+
599
+ // public api url for dev and production
600
+ let publicAPIUrl = "https://api.llmasaservice.io";
601
+ if (
602
+ window.location.hostname === "localhost" ||
603
+ window.location.hostname === "dev.llmasaservice.io"
604
+ ) {
605
+ publicAPIUrl = "https://8ftw8droff.execute-api.us-east-1.amazonaws.com/dev";
606
+ }
607
+
608
+ const [toolList, setToolList] = useState<any[]>([]);
609
+ const [toolsLoading, setToolsLoading] = useState(false);
610
+ const [toolsFetchError, setToolsFetchError] = useState(false);
611
+
612
+ // mcp servers are passed in in the mcpServers prop. Fetch tools for each one.
613
+ useEffect(() => {
614
+ //console.log("MCP servers", mcpServers);
615
+
616
+ const fetchAndSetTools = async () => {
617
+ if (!mcpServers || mcpServers.length === 0) {
618
+ setToolList([]);
619
+ setToolsLoading(false);
620
+ setToolsFetchError(false);
621
+ return;
622
+ }
623
+
624
+ setToolsLoading(true);
625
+ setToolsFetchError(false);
626
+
627
+ try {
628
+ // Create an array of promises, one for each fetch call
629
+ const fetchPromises = (mcpServers ?? []).map(async (m: any) => {
630
+ const urlToFetch = `${publicAPIUrl}/tools/${encodeURIComponent(
631
+ m.url
632
+ )}`;
633
+
634
+ try {
635
+ const response = await fetch(urlToFetch);
636
+ if (!response.ok) {
637
+ console.error(
638
+ `Error fetching tools from ${m.url}: ${response.status} ${response.statusText}`
639
+ );
640
+ const errorBody = await response.text();
641
+ console.error(`Error body: ${errorBody}`);
642
+ throw new Error(
643
+ `HTTP ${response.status}: ${response.statusText}`
644
+ );
645
+ }
646
+ const toolsFromServer = await response.json();
647
+ if (Array.isArray(toolsFromServer)) {
648
+ return toolsFromServer.map((tool) => ({
649
+ ...tool,
650
+ url: m.url,
651
+ accessToken: m.accessToken || "",
652
+ headers: {},
653
+ }));
654
+ } else {
655
+ return [];
656
+ }
657
+ } catch (fetchError) {
658
+ console.error(
659
+ `Network or parsing error fetching tools from ${m.url}:`,
660
+ fetchError
661
+ );
662
+ throw fetchError; // Re-throw to be caught by outer try-catch
663
+ }
664
+ });
665
+
666
+ // Wait for all fetch calls to complete
667
+ const results = await Promise.all(fetchPromises);
668
+
669
+ const allTools = results.flat();
670
+
671
+ //console.log("Merged tools from all servers:", allTools);
672
+ setToolList(allTools);
673
+ setToolsFetchError(false);
674
+ } catch (error) {
675
+ console.error(
676
+ "An error occurred while processing tool fetches:",
677
+ error
678
+ );
679
+ setToolList([]); // Clear tools on overall error
680
+ setToolsFetchError(true);
681
+ } finally {
682
+ setToolsLoading(false);
683
+ }
684
+ };
685
+
686
+ fetchAndSetTools();
687
+ }, [mcpServers, publicAPIUrl]);
688
+
689
+ const { send, response, idle, stop, lastCallId, setResponse } = useLLM({
690
+ project_id: project_id,
691
+ customer: currentCustomer,
692
+ url: url,
693
+ agent: agent,
694
+ tools: toolList.map((item) => {
695
+ // remove the url from the tool list
696
+ return {
697
+ name: item.name,
698
+ description: item.description,
699
+ parameters: item.parameters,
700
+ };
701
+ }) as [],
702
+ });
703
+
704
+ // Centralized action processing function to eliminate duplication
705
+ const processActionsOnContent = useCallback((
706
+ content: string,
707
+ context: {
708
+ type: 'history' | 'streaming';
709
+ historyIndex?: number;
710
+ actionIndex?: number;
711
+ matchOffset?: number;
712
+ isProgressive?: boolean;
713
+ isIdle?: boolean;
714
+ }
715
+ ): {
716
+ processedContent: string;
717
+ buttonAttachments: Array<{
718
+ buttonId: string;
719
+ action: any;
720
+ match: string;
721
+ groups: any[];
722
+ }>;
723
+ } => {
724
+ let workingContent = content;
725
+ const buttonAttachments: Array<{
726
+ buttonId: string;
727
+ action: any;
728
+ match: string;
729
+ groups: any[];
730
+ }> = [];
731
+
732
+ // 1. Remove tool JSON patterns (display only)
733
+ allActions
734
+ .filter((a) => a.actionType === "tool")
735
+ .forEach((action) => {
736
+ try {
737
+ const regex = new RegExp(action.pattern, "gmi");
738
+ workingContent = workingContent.replace(regex, "");
739
+ } catch (e) {
740
+ console.warn("Invalid tool action regex", action.pattern, e);
741
+ }
742
+ });
743
+
744
+ // 2. Remove thinking tags (for history processing)
745
+ if (context.type === 'history') {
746
+ const { cleanedText } = processThinkingTags(workingContent);
747
+ workingContent = cleanedText;
748
+ }
749
+
750
+ // 3. Apply non-tool, non-response actions (markdown/html/button transformations)
751
+ const filteredActions = allActions.filter((a) => a.type !== "response" && a.actionType !== "tool");
752
+ console.log(`DEBUG: ${context.type} processing - filtered actions:`, filteredActions.length, 'of', allActions.length);
753
+
754
+ filteredActions.forEach((action, actionIndex) => {
755
+ try {
756
+ const regex = new RegExp(action.pattern, "gmi");
757
+ const matches = workingContent.match(regex);
758
+
759
+ if (matches) {
760
+ console.log(`${context.type === 'history' ? 'History' : 'Streaming'} processing: Found matches for pattern "${action.pattern}":`, matches, 'in content:', workingContent.substring(0, 100));
761
+ }
762
+
763
+ workingContent = workingContent.replace(
764
+ regex,
765
+ (match: string, ...groups: any[]) => {
766
+ // The match parameter is the full match, groups are the capture groups
767
+ const actualMatch = match;
768
+ // Remove the last two elements (offset and full string) to get just capture groups
769
+ const restGroups = groups.slice(0, -2);
770
+
771
+ // Generate buttonId based on context
772
+ let buttonId: string;
773
+ if (context.type === 'history') {
774
+ const matchIndex = restGroups[restGroups.length - 2];
775
+ buttonId = `button-init-${context.historyIndex}-${actionIndex}-${matchIndex}`;
776
+ } else {
777
+ // Streaming context
778
+ const offset = groups[groups.length - 2];
779
+ const matchOffset = typeof offset === "number" ? offset : 0;
780
+ const key = `${actionIndex}:${matchOffset}`;
781
+
782
+ // Derive or allocate stable buttonId
783
+ let existingButtonId = actionMatchRegistry.current.get(key);
784
+ if (!existingButtonId) {
785
+ existingButtonId = `button-stable-${actionSequenceRef.current++}`;
786
+ actionMatchRegistry.current.set(key, existingButtonId);
787
+ }
788
+ buttonId = existingButtonId;
789
+
790
+ // If already finalized and progressive mode active, skip reinsertion
791
+ if (context.isProgressive && finalizedButtonsRef.current.has(buttonId)) {
792
+ return match;
793
+ }
794
+ }
795
+
796
+ // Replace tokens in template
797
+ const substituteTemplate = (template: string) => {
798
+ let html = template.replace(/\$match/gim, actualMatch);
799
+ restGroups.forEach((g, gi) => {
800
+ html = html.replace(new RegExp(`\\$${gi + 1}`, "gmi"), g || '');
801
+ });
802
+ return html;
803
+ };
804
+
805
+ // Build HTML output
806
+ let html = actualMatch;
807
+ if (action.type === "button" || action.type === "callback") {
808
+ // For history: always active buttons
809
+ // For streaming: placeholder (disabled) during streaming, active when idle
810
+ if (context.type === 'history') {
811
+ html = `<br /><button id="${buttonId}" ${
812
+ action.style ? 'class="' + action.style + '"' : ""
813
+ }>${action.markdown ?? actualMatch}</button>`;
814
+ } else {
815
+ // Streaming context with progressive actions
816
+ if (context.isProgressive && !context.isIdle) {
817
+ html = `<br /><button id="${buttonId}" data-pending="true" ${
818
+ action.style ? 'class="' + action.style + '"' : ""
819
+ }>${substituteTemplate(action.markdown ?? actualMatch)}</button>`;
820
+ } else {
821
+ html = `<br /><button id="${buttonId}" ${
822
+ action.style ? 'class="' + action.style + '"' : ""
823
+ }>${substituteTemplate(action.markdown ?? actualMatch)}</button>`;
824
+ }
825
+ }
826
+ } else if (action.type === "markdown" || action.type === "html") {
827
+ html = context.type === 'history'
828
+ ? (action.markdown ?? "")
829
+ : substituteTemplate(action.markdown ?? "");
830
+ }
831
+
832
+ // Apply token substitution for history context
833
+ if (context.type === 'history') {
834
+ html = html.replace(new RegExp("\\$match", "gmi"), actualMatch);
835
+ restGroups.forEach((group: string, gi: number) => {
836
+ html = html.replace(
837
+ new RegExp(`\\$${gi + 1}`, "gmi"),
838
+ group || ''
839
+ );
840
+ });
841
+ }
842
+
843
+ // Queue button attachment context if needed
844
+ if (action.type === "button" || action.type === "callback") {
845
+ if (context.type === 'history') {
846
+ buttonAttachments.push({
847
+ buttonId,
848
+ action,
849
+ match: actualMatch,
850
+ groups: restGroups,
851
+ });
852
+ // Also add to registry for fallback event delegation
853
+ buttonActionRegistry.current.set(buttonId, {
854
+ action,
855
+ match: actualMatch,
856
+ groups: restGroups,
857
+ });
858
+ } else {
859
+ // Streaming context
860
+ if (!finalizedButtonsRef.current.has(buttonId)) {
861
+ deferredActionsRef.current.set(buttonId, {
862
+ action,
863
+ match: actualMatch,
864
+ groups: restGroups,
865
+ });
866
+ // If NOT progressive (legacy behavior) attach immediately
867
+ if (!context.isProgressive) {
868
+ buttonAttachments.push({
869
+ buttonId,
870
+ action,
871
+ match: actualMatch,
872
+ groups: restGroups,
873
+ });
874
+ }
875
+ }
876
+ }
877
+ }
878
+
879
+ return html;
880
+ }
881
+ );
882
+ } catch (e) {
883
+ console.warn("Invalid action regex", action.pattern, e);
884
+ }
885
+ });
886
+
887
+ return {
888
+ processedContent: workingContent,
889
+ buttonAttachments,
890
+ };
891
+ }, [allActions, processThinkingTags, progressiveActions, idle]);
892
+
893
+ useEffect(() => {
894
+ setShowEmailPanel(customerEmailCaptureMode !== "HIDE");
895
+
896
+ if (customerEmailCaptureMode === "REQUIRED") {
897
+ if (!isEmailAddress(emailInput)) {
898
+ setEmailValid(false);
899
+ }
900
+ }
901
+ }, [customerEmailCaptureMode]);
902
+
903
+ useEffect(() => {
904
+ // do any response actions
905
+ if (lastCallId && lastCallId !== "" && idle && response) {
906
+ allActions
907
+ .filter((a) => a.type === "response")
908
+ .forEach((action) => {
909
+ if (action.type === "response" && action.pattern) {
910
+ const regex = new RegExp(action.pattern, "gi");
911
+ const matches = regex.exec(response);
912
+ /*
913
+ console.log(
914
+ "action match",
915
+ matches,
916
+ action.pattern,
917
+ action.callback
918
+ );
919
+ */
920
+ if (matches && action.callback) {
921
+ action.callback(matches[0], matches.slice(1));
922
+ }
923
+ }
924
+ });
925
+ }
926
+
927
+ // call the connected responseCompleteCallback
928
+ if (responseCompleteCallback) {
929
+ if (lastCallId && lastCallId !== "" && idle)
930
+ responseCompleteCallback(lastCallId, lastPrompt ?? "", response);
931
+ }
932
+ }, [idle]);
933
+
934
+ useEffect(() => {
935
+ if (Object.keys(initialHistory).length === 0) return;
936
+ setHistory(initialHistory);
937
+ }, [initialHistory]);
938
+
939
+ useEffect(() => {
940
+ if (!conversation || conversation === "") return;
941
+ setCurrentConversation(conversation);
942
+ setHistory(initialHistory);
943
+ }, [conversation]);
944
+
945
+ useEffect(() => {
946
+ // Clean up any previously added CSS from this component
947
+ const existingLinks = document.querySelectorAll(
948
+ 'link[data-source="llmasaservice-ui"]'
949
+ );
950
+ existingLinks.forEach((link) => link.parentNode?.removeChild(link));
951
+
952
+ const existingStyles = document.querySelectorAll(
953
+ 'style[data-source="llmasaservice-ui"]'
954
+ );
955
+ existingStyles.forEach((style) => style.parentNode?.removeChild(style));
956
+
957
+ if (cssUrl) {
958
+ if (cssUrl.startsWith("http://") || cssUrl.startsWith("https://")) {
959
+ // If it's a URL, create a link element
960
+ const link = document.createElement("link");
961
+ link.href = cssUrl;
962
+ link.rel = "stylesheet";
963
+ // Add a data attribute to identify and remove this link later if needed
964
+ link.setAttribute("data-source", "llmasaservice-ui");
965
+ document.head.appendChild(link);
966
+ //console.log("Added CSS link", link);
967
+ } else {
968
+ // If it's a CSS string, create a style element
969
+ const style = document.createElement("style");
970
+ style.textContent = cssUrl;
971
+ // Add a data attribute to identify and remove this style later if needed
972
+ style.setAttribute("data-source", "llmasaservice-ui");
973
+ document.head.appendChild(style);
974
+ //console.log("Added inline CSS");
975
+ }
976
+ }
977
+
978
+ // Clean up when component unmounts
979
+ return () => {
980
+ const links = document.querySelectorAll(
981
+ 'link[data-source="llmasaservice-ui"]'
982
+ );
983
+ links.forEach((link) => link.parentNode?.removeChild(link));
984
+
985
+ const styles = document.querySelectorAll(
986
+ 'style[data-source="llmasaservice-ui"]'
987
+ );
988
+ styles.forEach((style) => style.parentNode?.removeChild(style));
989
+ };
990
+ }, [cssUrl]);
991
+
992
+ const extractValue = (
993
+ match: string,
994
+ groups: any[] = [],
995
+ extraArgs: string[] = []
996
+ ): string => {
997
+ // Rule 1: If there are no extraArgs and no groups, use match.
998
+ if (
999
+ (!extraArgs || extraArgs.length === 0) &&
1000
+ (!groups || groups.length === 0)
1001
+ ) {
1002
+ return match;
1003
+ }
1004
+ // Rule 2: If there are no extraArgs but groups exist, use groups[0].
1005
+ if ((!extraArgs || extraArgs.length === 0) && groups && groups.length > 0) {
1006
+ return groups[0];
1007
+ }
1008
+ // Rule 3: If there are extraArgs, use the first one as a template.
1009
+ if (extraArgs && extraArgs.length > 0) {
1010
+ const template = extraArgs[0] ?? "";
1011
+ return template.replace(/\$(\d+)/g, (_, index) => {
1012
+ const i = parseInt(index, 10);
1013
+ return groups[i] !== undefined ? groups[i] : "";
1014
+ });
1015
+ }
1016
+ return "";
1017
+ };
1018
+
1019
+ const openUrlActionCallback = useCallback(
1020
+ (match: string, groups: any[], ...extraArgs: string[]) => {
1021
+ const url = extractValue(match, groups, extraArgs);
1022
+ if (url?.startsWith("http") || url?.startsWith("mailto")) {
1023
+ window.open(url, "_blank");
1024
+ }
1025
+ },
1026
+ []
1027
+ );
1028
+
1029
+ const copyToClipboardCallback = useCallback(
1030
+ (match: string, groups: any[], ...extraArgs: string[]) => {
1031
+ const val = extractValue(match, groups, extraArgs);
1032
+ navigator.clipboard.writeText(val);
1033
+ },
1034
+ []
1035
+ );
1036
+
1037
+ const showAlertCallback = useCallback(
1038
+ (match: string, groups: any[], ...extraArgs: string[]) => {
1039
+ alert(extractValue(match, groups, extraArgs));
1040
+ },
1041
+ []
1042
+ );
1043
+
1044
+ const sendFollowOnPromptCallback = useCallback(
1045
+ (match: string, groups: any[], ...extraArgs: string[]) => {
1046
+ const val = extractValue(match, groups, extraArgs);
1047
+ if (val && val !== followOnPrompt) {
1048
+ continueChat(val);
1049
+ }
1050
+ },
1051
+ [followOnPrompt]
1052
+ );
1053
+
1054
+ const setFollowUpQuestionsCallback = useCallback(
1055
+ (match: string, groups: any[], ...extraArgs: string[]) => {
1056
+ const val = extractValue(match, groups, extraArgs).split("|");
1057
+ setFollowOnQuestionsState(val);
1058
+ },
1059
+ [followOnQuestions]
1060
+ );
1061
+
1062
+ const openIframeCallback = useCallback(
1063
+ (match: string, groups: any[], ...extraArgs: string[]) => {
1064
+ const url = extractValue(match, groups, extraArgs);
1065
+ if (url?.startsWith("http")) {
1066
+ setIframeUrl(url);
1067
+ }
1068
+ },
1069
+ []
1070
+ );
1071
+
1072
+ const anthropic_toolAction = {
1073
+ pattern:
1074
+ '\\{"type":"tool_use","id":"([^"]+)","name":"([^"]+)","input":(\\{[\\s\\S]+?\\}),"service":"([^"]+)"\\}',
1075
+ type: "markdown",
1076
+ markdown: "<br />*Tool use requested: $2*",
1077
+ actionType: "tool",
1078
+ };
1079
+
1080
+ const openAI_toolAction = {
1081
+ pattern:
1082
+ '\\{"id":"([^"]+)","type":"function","function":\\{"name":"([^"]+)","arguments":"((?:\\\\.|[^"\\\\])*)"\\},"service":"([^"]+)"\\}',
1083
+ type: "markdown",
1084
+ markdown: "<br />*Tool use requested: $2*",
1085
+ actionType: "tool",
1086
+ };
1087
+
1088
+ // google doesn't return an id, so we just grab functioCall
1089
+ const google_toolAction = {
1090
+ pattern:
1091
+ '^\\{\\s*"(functionCall)"\\s*:\\s*\\{\\s*"name"\\s*:\\s*"([^"]+)"\\s*,\\s*"args"\\s*:\\s*(\\{[\\s\\S]+?\\})\\s*\\}(?:\\s*,\\s*"thoughtSignature"\\s*:\\s*"[^"]*")?\\s*,\\s*"service"\\s*:\\s*"([^"]+)"\\s*\\}$',
1092
+ type: "markdown",
1093
+ markdown: "<br />*Tool use requested: $2*",
1094
+ actionType: "tool",
1095
+ };
1096
+
1097
+ type ActionCallback = (
1098
+ match: string,
1099
+ groups: any[],
1100
+ ...extraArgs: string[]
1101
+ ) => void;
1102
+
1103
+ const callbackMapping: Record<string, ActionCallback> = useMemo(
1104
+ () => ({
1105
+ openUrlActionCallback,
1106
+ copyToClipboardCallback,
1107
+ showAlertCallback,
1108
+ sendFollowOnPromptCallback,
1109
+ setFollowUpQuestionsCallback,
1110
+ openIframeCallback,
1111
+ }),
1112
+ [
1113
+ openUrlActionCallback,
1114
+ copyToClipboardCallback,
1115
+ showAlertCallback,
1116
+ sendFollowOnPromptCallback,
1117
+ setFollowUpQuestionsCallback,
1118
+ openIframeCallback,
1119
+ ]
1120
+ );
1121
+ const parseCallbackString = (callbackStr: string) => {
1122
+ const regex = /^(\w+)(?:\((.+)\))?$/;
1123
+ const match = callbackStr.match(regex);
1124
+ if (match) {
1125
+ const name = match[1];
1126
+ // If there are args, split by comma and trim whitespace.
1127
+ const args = match[2] ? match[2].split(",").map((arg) => arg.trim()) : [];
1128
+ return { name, args };
1129
+ }
1130
+ return null;
1131
+ };
1132
+
1133
+ const getActionsArraySafely = (actionsString: string) => {
1134
+ let actions: any[] = [];
1135
+ if (actionsString && actionsString !== "") {
1136
+ try {
1137
+ actions = JSON.parse(actionsString);
1138
+ if (!Array.isArray(actions)) {
1139
+ throw new Error("Parsed actions is not an array");
1140
+ }
1141
+ // Map string callbacks to actual functions using callbackMapping and parsing args if needed
1142
+ actions = actions
1143
+ .map((action) => {
1144
+ if (typeof action.callback === "string") {
1145
+ const parsed = parseCallbackString(action.callback);
1146
+ if (parsed && parsed.name && callbackMapping[parsed.name]) {
1147
+ // Wrap the callback so that it receives the original match & groups plus extra args
1148
+ const mappedCallback = callbackMapping[parsed.name];
1149
+ if (mappedCallback) {
1150
+ return {
1151
+ ...action,
1152
+ callback: (match: string, groups: any[]) =>
1153
+ mappedCallback(match, groups, ...parsed.args),
1154
+ };
1155
+ }
1156
+ } else {
1157
+ // Optionally provide a no-op fallback or skip the action:
1158
+ return null;
1159
+ }
1160
+ } else {
1161
+ return action;
1162
+ }
1163
+ })
1164
+ .filter(Boolean); // removes null entries
1165
+ } catch (error) {
1166
+ console.error("Error parsing actions string:", error);
1167
+ actions = [];
1168
+ }
1169
+ }
1170
+ return actions;
1171
+ };
1172
+
1173
+ useEffect(() => {
1174
+ // DEBUG: Log when actions prop changes
1175
+ console.log("ChatPanel received actions prop change:", {
1176
+ actions,
1177
+ actionsType: typeof actions,
1178
+ actionsLength: Array.isArray(actions) ? actions.length : "not array"
1179
+ });
1180
+
1181
+ const actionsString =
1182
+ typeof actions === "string" ? actions : JSON.stringify(actions);
1183
+ const processedActions = getActionsArraySafely(actionsString);
1184
+ console.log("DEBUG: Setting allActions:", {
1185
+ actionsString,
1186
+ processedActions,
1187
+ totalActions: [...processedActions, anthropic_toolAction, openAI_toolAction, google_toolAction].length
1188
+ });
1189
+ setAllActions([
1190
+ ...processedActions,
1191
+ anthropic_toolAction,
1192
+ openAI_toolAction,
1193
+ google_toolAction,
1194
+ ]);
1195
+ }, [actions]);
1196
+
1197
+ // Process existing (initial) history entries so they receive the same formatting
1198
+ // as streamed responses (tool JSON removal, thinking tag removal, action markdown/button injection)
1199
+ useEffect(() => {
1200
+ if (!allActions || allActions.length === 0) return; // wait for actions
1201
+ setHistory((prevHistory) => {
1202
+ if (!prevHistory || Object.keys(prevHistory).length === 0)
1203
+ return prevHistory;
1204
+
1205
+ let changed = false;
1206
+ const updated: typeof prevHistory = { ...prevHistory };
1207
+ const newButtonAttachments: Array<{
1208
+ buttonId: string;
1209
+ action: any;
1210
+ match: string;
1211
+ groups: any[];
1212
+ }> = [];
1213
+
1214
+ Object.entries(prevHistory).forEach(([prompt, entry], historyIndex) => {
1215
+ if (!entry || !entry.content) {
1216
+ return;
1217
+ }
1218
+
1219
+ // Use centralized action processing function
1220
+ const { processedContent, buttonAttachments } = processActionsOnContent(
1221
+ entry.content,
1222
+ {
1223
+ type: 'history',
1224
+ historyIndex,
1225
+ }
1226
+ );
1227
+
1228
+ if (processedContent !== entry.content) {
1229
+ updated[prompt] = { ...entry, content: processedContent } as any;
1230
+ changed = true;
1231
+ }
1232
+
1233
+ // Add button attachments to the queue
1234
+ newButtonAttachments.push(...buttonAttachments);
1235
+
1236
+ });
1237
+
1238
+ if (newButtonAttachments.length > 0) {
1239
+ // Defer adding button attachments until after history state applied
1240
+ setPendingButtonAttachments((prev) => [
1241
+ ...prev,
1242
+ ...newButtonAttachments,
1243
+ ]);
1244
+ }
1245
+
1246
+ return changed ? updated : prevHistory;
1247
+ });
1248
+ }, [allActions, processThinkingTags, initialHistory]);
1249
+
1250
+ const pendingToolRequestsRef = useRef(pendingToolRequests);
1251
+
1252
+ useEffect(() => {
1253
+ pendingToolRequestsRef.current = pendingToolRequests;
1254
+ }, [pendingToolRequests]);
1255
+
1256
+ const processGivenToolRequests = async (
1257
+ requests: typeof pendingToolRequests
1258
+ ) => {
1259
+ if (!requests || requests.length === 0)
1260
+ requests = pendingToolRequestsRef.current;
1261
+
1262
+ if (requests.length === 0) return;
1263
+
1264
+ //console.log("processGivenToolRequests", requests);
1265
+ setIsLoading(true);
1266
+
1267
+ const toolsToProcess = [...requests];
1268
+ setPendingToolRequests([]);
1269
+ try {
1270
+ // Start with base messages including the user's original question
1271
+ const newMessages = [
1272
+ {
1273
+ role: "user",
1274
+ content: [
1275
+ {
1276
+ type: "text",
1277
+ text: lastKey,
1278
+ },
1279
+ ],
1280
+ },
1281
+ ];
1282
+
1283
+ // Add a single assistant message with ALL tool calls
1284
+ const toolCallsMessage = {
1285
+ role: "assistant",
1286
+ content: [],
1287
+ tool_calls: [],
1288
+ };
1289
+
1290
+ // Parse all tool calls first
1291
+ const toolCallsPromises = toolsToProcess.map(async (req) => {
1292
+ if (!req) return null;
1293
+
1294
+ try {
1295
+ return {
1296
+ req,
1297
+ parsedToolCall: JSON.parse(req.match),
1298
+ };
1299
+ } catch (e) {
1300
+ console.error("Failed to parse tool call:", e);
1301
+ return null;
1302
+ }
1303
+ });
1304
+
1305
+ // Wait for all tool calls to be parsed
1306
+ const parsedToolCalls = await Promise.all(toolCallsPromises);
1307
+
1308
+ // Add all tool calls to the assistant message
1309
+ parsedToolCalls.forEach((item) => {
1310
+ if (item && item.parsedToolCall) {
1311
+ (toolCallsMessage.tool_calls as any[]).push(item.parsedToolCall);
1312
+ }
1313
+ });
1314
+
1315
+ // Add the assistant message with all tool calls
1316
+ newMessages.push(toolCallsMessage);
1317
+
1318
+ const finalToolCalls = toolCallsMessage.tool_calls;
1319
+
1320
+ const toolResponsePromises = parsedToolCalls.map(async (item) => {
1321
+ if (!item || !item.req) return null;
1322
+
1323
+ const req = item.req;
1324
+ //console.log(`Processing tool ${req.toolName}`);
1325
+
1326
+ const mcpTool = toolList.find((tool) => tool.name === req.toolName);
1327
+
1328
+ if (!mcpTool) {
1329
+ console.error(`Tool ${req.toolName} not found in tool list`);
1330
+ return null;
1331
+ }
1332
+
1333
+ try {
1334
+ let args;
1335
+ try {
1336
+ args = JSON.parse(req.groups[2]);
1337
+ } catch (e) {
1338
+ try {
1339
+ args = JSON.parse(req.groups[2].replace(/\\"/g, '"'));
1340
+ } catch (err) {
1341
+ console.error("Failed to parse tool arguments:", err);
1342
+ return null;
1343
+ }
1344
+ }
1345
+
1346
+ const body = {
1347
+ tool: req.groups[1],
1348
+ args: args,
1349
+ };
1350
+
1351
+ const result = await fetch(
1352
+ `${publicAPIUrl}/tools/${encodeURIComponent(mcpTool.url)}`,
1353
+ {
1354
+ method: "POST",
1355
+ headers: {
1356
+ "Content-Type": "application/json",
1357
+ "x-mcp-access-token":
1358
+ mcpTool.accessToken && mcpTool.accessToken !== ""
1359
+ ? mcpTool.accessToken
1360
+ : "",
1361
+ "x-project-id": project_id,
1362
+ },
1363
+ body: JSON.stringify(body),
1364
+ }
1365
+ );
1366
+
1367
+ if (!result.ok) {
1368
+ console.error(
1369
+ `Error calling tool ${req.toolName}: ${result.status} ${result.statusText}`
1370
+ );
1371
+ const errorBody = await result.text();
1372
+ console.error(`Error body: ${errorBody}`);
1373
+ return null;
1374
+ }
1375
+
1376
+ let resultData;
1377
+ try {
1378
+ resultData = await result.json();
1379
+ } catch (jsonError) {
1380
+ console.error(
1381
+ `Error parsing JSON response for tool ${req.toolName}:`,
1382
+ jsonError
1383
+ );
1384
+ // Attempt to read as text for debugging if JSON fails
1385
+ try {
1386
+ const textBody = await result.text(); // Note: This consumes the body if json() failed early
1387
+ console.error("Response body (text):", textBody);
1388
+ } catch (textError) {
1389
+ console.error(
1390
+ "Failed to read response body as text either:",
1391
+ textError
1392
+ );
1393
+ }
1394
+ return null; // Exit if JSON parsing failed
1395
+ }
1396
+
1397
+ if (
1398
+ resultData &&
1399
+ resultData.content &&
1400
+ resultData.content.length > 0
1401
+ ) {
1402
+ const textResult = resultData.content[0]?.text;
1403
+ return {
1404
+ role: "tool",
1405
+ content: [
1406
+ {
1407
+ type: "text",
1408
+ text: textResult,
1409
+ },
1410
+ ],
1411
+ tool_call_id: req.groups[0],
1412
+ };
1413
+ } else {
1414
+ console.error(`No content returned from tool ${req.toolName}`);
1415
+ return null;
1416
+ }
1417
+ } catch (error) {
1418
+ console.error(`Error processing tool ${req.toolName}:`, error);
1419
+ return null;
1420
+ }
1421
+ });
1422
+
1423
+ // Wait for all tool responses
1424
+ const toolResponses = await Promise.all(toolResponsePromises);
1425
+ const finalToolResponses = toolResponses.filter(Boolean); // Filter out null
1426
+
1427
+ if (lastKey) {
1428
+ setHistory((prev) => {
1429
+ const existingEntry = prev[lastKey] || {};
1430
+
1431
+ return {
1432
+ ...prev,
1433
+ [lastKey]: {
1434
+ ...existingEntry,
1435
+ toolCalls: [
1436
+ ...((existingEntry as any).toolCalls || []),
1437
+ ...finalToolCalls,
1438
+ ],
1439
+ toolResponses: [
1440
+ ...((existingEntry as any).toolResponses || []),
1441
+ ...finalToolResponses,
1442
+ ],
1443
+ },
1444
+ } as any;
1445
+ });
1446
+ }
1447
+
1448
+ finalToolResponses.forEach((response) => {
1449
+ if (response) {
1450
+ newMessages.push(response);
1451
+ }
1452
+ });
1453
+
1454
+ send(
1455
+ "",
1456
+ newMessages as any,
1457
+ [
1458
+ ...dataWithExtras(),
1459
+ {
1460
+ key: "--messages",
1461
+ data: newMessages.length.toString(),
1462
+ },
1463
+ ],
1464
+ true,
1465
+ true,
1466
+ service,
1467
+ currentConversation,
1468
+ lastController
1469
+ );
1470
+ } catch (error) {
1471
+ console.error("Error in processing all tools:", error);
1472
+ setIsLoading(false);
1473
+ }
1474
+ };
1475
+
1476
+ useEffect(() => {
1477
+ if (response && response.length > 0) {
1478
+ setIsLoading(false);
1479
+
1480
+ // Step 1: Detect tool requests from the original response BEFORE any cleaning
1481
+ const toolRequests: { match: string; groups: any[]; toolName: string }[] =
1482
+ [];
1483
+
1484
+ if (allActions && allActions.length > 0) {
1485
+ allActions
1486
+ .filter((a) => a.actionType === "tool")
1487
+ .forEach((action) => {
1488
+ const regex = new RegExp(action.pattern, "gmi");
1489
+ let match;
1490
+ // Use original response for tool detection
1491
+ while ((match = regex.exec(response)) !== null) {
1492
+ toolRequests.push({
1493
+ match: match[0],
1494
+ groups: Array.from(match).slice(1),
1495
+ toolName: match[2] ?? "tool", // Tool name should always in the 2nd capture group
1496
+ });
1497
+ }
1498
+ });
1499
+ }
1500
+
1501
+ // Set tool requests immediately after detection
1502
+ if (toolRequests.length > 0) {
1503
+ //console.log("toolRequests", toolRequests);
1504
+ setPendingToolRequests(toolRequests);
1505
+ } else {
1506
+ setPendingToolRequests([]);
1507
+ }
1508
+
1509
+ // Step 2: Remove tool JSON patterns from response for display
1510
+ let responseWithoutTools = response;
1511
+ if (allActions && allActions.length > 0) {
1512
+ allActions
1513
+ .filter((a) => a.actionType === "tool")
1514
+ .forEach((action) => {
1515
+ const regex = new RegExp(action.pattern, "gmi");
1516
+ responseWithoutTools = responseWithoutTools.replace(regex, "");
1517
+ });
1518
+ }
1519
+
1520
+ // Step 3: Process thinking tags on the response without tool JSON
1521
+ const { cleanedText, thinkingBlocks: newThinkingBlocks } =
1522
+ processThinkingTags(responseWithoutTools);
1523
+
1524
+ // Replace the blocks entirely (don't append) to avoid duplicates during streaming
1525
+ setThinkingBlocks(newThinkingBlocks);
1526
+ // Always show the latest (last) thinking block
1527
+ setCurrentThinkingIndex(Math.max(0, newThinkingBlocks.length - 1));
1528
+
1529
+ // Step 4: Process other non-tool actions on the cleaned response with two-phase option
1530
+ const { processedContent: newResponse, buttonAttachments } = processActionsOnContent(
1531
+ cleanedText,
1532
+ {
1533
+ type: 'streaming',
1534
+ isProgressive: progressiveActions,
1535
+ isIdle: idle,
1536
+ }
1537
+ );
1538
+
1539
+ // Handle button attachments for non-progressive mode
1540
+ if (!progressiveActions && buttonAttachments.length > 0) {
1541
+ setPendingButtonAttachments((prev) => [...prev, ...buttonAttachments]);
1542
+ }
1543
+
1544
+ // Store the cleaned response (without reasoning/searching tags and without tool JSON)
1545
+ setHistory((prevHistory) => {
1546
+ // Get any existing tool data from the previous state
1547
+ const existingEntry = prevHistory[lastKey ?? ""] || {
1548
+ content: "",
1549
+ callId: "",
1550
+ };
1551
+
1552
+ const updatedHistory = {
1553
+ ...prevHistory,
1554
+ [lastKey ?? ""]: {
1555
+ ...existingEntry, // This preserves toolCalls and toolResponses
1556
+ content: newResponse, // Store cleaned response without thinking tags or tool JSON
1557
+ callId: lastCallId,
1558
+ },
1559
+ };
1560
+
1561
+ return updatedHistory;
1562
+ });
1563
+ }
1564
+ }, [
1565
+ response,
1566
+ allActions,
1567
+ lastKey,
1568
+ lastCallId,
1569
+ messages.length,
1570
+ lastPrompt,
1571
+ lastMessages,
1572
+ initialPrompt,
1573
+ processThinkingTags,
1574
+ progressiveActions,
1575
+ idle,
1576
+ ]);
1577
+
1578
+ // Finalization effect: when idle transitions true, convert placeholders to active buttons and queue attachments once
1579
+ useEffect(() => {
1580
+ if (!progressiveActions) return; // legacy path unchanged
1581
+ if (!idle) return; // only finalize at end
1582
+ if (!lastKey) return;
1583
+ if (finalizedForCallRef.current === lastCallId) return; // already finalized
1584
+
1585
+ // Replace data-pending buttons in the stored history content for the lastKey
1586
+ setHistory((prev) => {
1587
+ const entry = prev[lastKey];
1588
+ if (!entry) return prev;
1589
+ let content = entry.content;
1590
+ // Simple replace: remove data-pending attribute
1591
+ content = content.replace(/data-pending="true"/g, "");
1592
+
1593
+ const updated = {
1594
+ ...prev,
1595
+ [lastKey]: { ...entry, content },
1596
+ };
1597
+ return updated;
1598
+ });
1599
+
1600
+ // Queue attachments for all deferred actions not yet finalized
1601
+ const attachments: Array<{
1602
+ buttonId: string;
1603
+ action: any;
1604
+ match: string;
1605
+ groups: any[];
1606
+ }> = [];
1607
+ deferredActionsRef.current.forEach((meta, buttonId) => {
1608
+ if (!finalizedButtonsRef.current.has(buttonId)) {
1609
+ attachments.push({ buttonId, ...meta });
1610
+ finalizedButtonsRef.current.add(buttonId);
1611
+ }
1612
+ });
1613
+
1614
+ if (attachments.length > 0) {
1615
+ setPendingButtonAttachments((prev) => [...prev, ...attachments]);
1616
+ }
1617
+
1618
+ // Move deferred into active registry for delegation
1619
+ attachments.forEach(({ buttonId, action, match, groups }) => {
1620
+ buttonActionRegistry.current.set(buttonId, { action, match, groups });
1621
+ });
1622
+
1623
+ finalizedForCallRef.current = lastCallId;
1624
+ }, [idle, progressiveActions, lastCallId, lastKey]);
1625
+
1626
+ // More reliable button attachment with retry mechanism and MutationObserver
1627
+ const attachButtonHandlers = useCallback(
1628
+ (
1629
+ attachments: Array<{
1630
+ buttonId: string;
1631
+ action: any;
1632
+ match: string;
1633
+ groups: any[];
1634
+ }>,
1635
+ retryCount = 0
1636
+ ) => {
1637
+ if (attachments.length === 0) return;
1638
+ let attachedCount = 0;
1639
+ let notFoundCount = 0;
1640
+ let alreadyAttachedCount = 0;
1641
+ const stillPending: typeof attachments = [];
1642
+
1643
+ // First, let's see what buttons actually exist in the DOM
1644
+ const allButtonsInDOM = document.querySelectorAll("button");
1645
+ const buttonIdsInDOM = Array.from(allButtonsInDOM)
1646
+ .map((btn) => btn.id)
1647
+ .filter((id) => id);
1648
+
1649
+ attachments.forEach(({ buttonId, action, match, groups }) => {
1650
+ const button = document.getElementById(buttonId) as HTMLButtonElement;
1651
+
1652
+ if (button) {
1653
+ if (!button.onclick) {
1654
+ button.onclick = () => {
1655
+ if (action.callback) {
1656
+ action.callback(match, groups);
1657
+ }
1658
+
1659
+ if (action.clickCode) {
1660
+ try {
1661
+ const func = new Function("match", action.clickCode);
1662
+ func(match);
1663
+ // Note: interactionClicked will be available when this closure executes
1664
+ if (typeof interactionClicked === "function") {
1665
+ interactionClicked(lastCallId, "action");
1666
+ }
1667
+ } catch (error) {
1668
+ console.error("Error executing clickCode:", error);
1669
+ }
1670
+ }
1671
+ };
1672
+ attachedCount++;
1673
+ } else {
1674
+ alreadyAttachedCount++;
1675
+ }
1676
+ } else {
1677
+ notFoundCount++;
1678
+ stillPending.push({ buttonId, action, match, groups });
1679
+ // Only register in fallback for buttons that failed direct attachment
1680
+ buttonActionRegistry.current.set(buttonId, { action, match, groups });
1681
+ }
1682
+ });
1683
+
1684
+ // If there are still pending buttons and we haven't exceeded retry limit, try again
1685
+ if (stillPending.length > 0 && retryCount < 5) {
1686
+ setTimeout(() => {
1687
+ attachButtonHandlers(stillPending, retryCount + 1);
1688
+ }, 200);
1689
+ } else if (stillPending.length > 0) {
1690
+ console.warn(
1691
+ "Failed to attach all buttons after max retries:",
1692
+ stillPending.map((p) => p.buttonId)
1693
+ );
1694
+ }
1695
+ },
1696
+ [lastCallId]
1697
+ );
1698
+
1699
+ // Handle button attachments after history updates
1700
+ useEffect(() => {
1701
+ if (pendingButtonAttachments.length > 0) {
1702
+ // Use requestAnimationFrame to ensure DOM is ready, then add a small delay
1703
+ requestAnimationFrame(() => {
1704
+ setTimeout(() => {
1705
+ attachButtonHandlers([...pendingButtonAttachments]);
1706
+ setPendingButtonAttachments([]);
1707
+ }, 100);
1708
+ });
1709
+ }
1710
+ }, [pendingButtonAttachments, attachButtonHandlers]);
1711
+
1712
+ // Additional effect to catch buttons that might be added through other means
1713
+ useEffect(() => {
1714
+ if (typeof window === "undefined") return;
1715
+
1716
+ const observer = new MutationObserver((mutations) => {
1717
+ let newButtonsFound = false;
1718
+
1719
+ mutations.forEach((mutation) => {
1720
+ if (mutation.type === "childList") {
1721
+ mutation.addedNodes.forEach((node) => {
1722
+ if (node.nodeType === Node.ELEMENT_NODE) {
1723
+ const element = node as Element;
1724
+
1725
+ // Check if the added node is a button with an action ID or contains such buttons
1726
+ const actionButtons = element.id?.startsWith("button-")
1727
+ ? [element as HTMLButtonElement]
1728
+ : Array.from(element.querySelectorAll('button[id^="button-"]'));
1729
+
1730
+ if (actionButtons.length > 0) {
1731
+ newButtonsFound = true;
1732
+
1733
+ // Check if any of these buttons don't have click handlers
1734
+ actionButtons.forEach((button) => {
1735
+ if (!(button as HTMLButtonElement).onclick) {
1736
+ console.log(
1737
+ "Found unattached button via MutationObserver:",
1738
+ button.id
1739
+ );
1740
+ // Note: We can't directly attach here because we don't have the action context
1741
+ // This is mainly for debugging to see if buttons are being added after our attachment
1742
+ }
1743
+ });
1744
+ }
1745
+ }
1746
+ });
1747
+ }
1748
+ });
1749
+ });
1750
+
1751
+ // Start observing only the response area, not the entire document
1752
+ const responseArea = responseAreaRef.current;
1753
+ if (responseArea) {
1754
+ observer.observe(responseArea, {
1755
+ childList: true,
1756
+ subtree: true,
1757
+ });
1758
+ }
1759
+
1760
+ return () => observer.disconnect();
1761
+ }, []);
1762
+
1763
+ // Fallback event delegation system for button clicks
1764
+ useEffect(() => {
1765
+ if (typeof window === "undefined") return;
1766
+
1767
+ const handleDelegatedClick = (event: Event) => {
1768
+ const target = event.target as HTMLElement;
1769
+
1770
+ // Check if the clicked element is a button with an action ID
1771
+ if (
1772
+ target.tagName === "BUTTON" &&
1773
+ target.id &&
1774
+ target.id.startsWith("button-")
1775
+ ) {
1776
+ const buttonData = buttonActionRegistry.current.get(target.id);
1777
+
1778
+ if (buttonData) {
1779
+ const { action, match, groups } = buttonData;
1780
+
1781
+ // Only handle if the button doesn't already have an onclick handler
1782
+ const button = target as HTMLButtonElement;
1783
+ if (!button.onclick) {
1784
+ // Prevent default and stop propagation to avoid conflicts
1785
+ event.preventDefault();
1786
+ event.stopPropagation();
1787
+
1788
+ if (action.callback) {
1789
+ action.callback(match, groups);
1790
+ }
1791
+
1792
+ if (action.clickCode) {
1793
+ try {
1794
+ const func = new Function("match", action.clickCode);
1795
+ func(match);
1796
+ if (typeof interactionClicked === "function") {
1797
+ interactionClicked(lastCallId, "action");
1798
+ }
1799
+ } catch (error) {
1800
+ console.error(
1801
+ "Error executing clickCode via delegation:",
1802
+ error
1803
+ );
1804
+ }
1805
+ }
1806
+
1807
+ // Remove from registry after successful execution to prevent memory leaks
1808
+ buttonActionRegistry.current.delete(target.id);
1809
+ }
1810
+ }
1811
+ }
1812
+ };
1813
+
1814
+ // Add the delegated event listener to the document (using bubble phase instead of capture)
1815
+ document.addEventListener("click", handleDelegatedClick, false);
1816
+
1817
+ return () => {
1818
+ document.removeEventListener("click", handleDelegatedClick, false);
1819
+ };
1820
+ }, [lastCallId]);
1821
+
1822
+ // Debug function to check DOM state - you can call this from browser console
1823
+ useEffect(() => {
1824
+ if (typeof window !== "undefined") {
1825
+ (window as any).debugChatPanelButtons = () => {
1826
+ const allButtons = document.querySelectorAll('button[id^="button-"]');
1827
+ const allButtonsAny = document.querySelectorAll("button");
1828
+ const buttonInfo = Array.from(allButtons).map((button) => ({
1829
+ id: button.id,
1830
+ hasOnclick: !!(button as HTMLButtonElement).onclick,
1831
+ textContent: button.textContent?.substring(0, 50),
1832
+ visible: (button as HTMLElement).offsetParent !== null,
1833
+ inDOM: document.contains(button),
1834
+ parentElement: button.parentElement?.tagName,
1835
+ outerHTML: button.outerHTML.substring(0, 200),
1836
+ inRegistry: buttonActionRegistry.current.has(button.id),
1837
+ }));
1838
+
1839
+ return buttonInfo;
1840
+ };
1841
+ }
1842
+ }, [pendingButtonAttachments, history]);
1843
+
1844
+ function hasVerticalScrollbar(element: any) {
1845
+ return element.scrollHeight > element.clientHeight;
1846
+ }
1847
+
1848
+ // Auto-resize textarea based on content
1849
+ const autoResizeTextarea = useCallback(() => {
1850
+ if (textareaRef.current) {
1851
+ const textarea = textareaRef.current;
1852
+ // Reset height to auto to get the actual scrollHeight
1853
+ textarea.style.height = "auto";
1854
+ // Set height to scrollHeight, but with min and max constraints
1855
+ const minHeight = 40; // Minimum height in pixels
1856
+ const maxHeight = 120; // Maximum height in pixels (about 4 lines)
1857
+
1858
+ // Use user's resized height as minimum if they've manually resized
1859
+ const effectiveMinHeight = userResizedHeight ? Math.max(userResizedHeight, minHeight) : minHeight;
1860
+
1861
+ // If user has manually resized, don't enforce max height constraint
1862
+ const effectiveMaxHeight = userResizedHeight ? Number.MAX_SAFE_INTEGER : maxHeight;
1863
+
1864
+ const newHeight = Math.min(
1865
+ Math.max(textarea.scrollHeight, effectiveMinHeight),
1866
+ effectiveMaxHeight
1867
+ );
1868
+ textarea.style.height = `${newHeight}px`;
1869
+ }
1870
+ }, [userResizedHeight]);
1871
+
1872
+ // Auto-resize textarea when nextPrompt changes or component mounts
1873
+ useEffect(() => {
1874
+ autoResizeTextarea();
1875
+ }, [nextPrompt, autoResizeTextarea]);
1876
+
1877
+ // Detect manual textarea resizing by user
1878
+ useEffect(() => {
1879
+ if (!textareaRef.current) return;
1880
+
1881
+ const textarea = textareaRef.current;
1882
+
1883
+ // Store the height before any potential user interaction
1884
+ let heightBeforeInteraction = textarea.clientHeight;
1885
+ let userIsInteracting = false;
1886
+
1887
+ const handleMouseDown = () => {
1888
+ heightBeforeInteraction = textarea.clientHeight;
1889
+ userIsInteracting = true;
1890
+ };
1891
+
1892
+ const handleMouseUp = () => {
1893
+ if (userIsInteracting) {
1894
+ const currentHeight = textarea.clientHeight;
1895
+ // If the height changed significantly during user interaction, consider it a manual resize
1896
+ if (Math.abs(currentHeight - heightBeforeInteraction) > 5) {
1897
+ setUserResizedHeight(currentHeight);
1898
+ }
1899
+ userIsInteracting = false;
1900
+ }
1901
+ };
1902
+
1903
+ // Also handle the case where user drags outside the textarea
1904
+ const handleGlobalMouseUp = () => {
1905
+ if (userIsInteracting) {
1906
+ handleMouseUp();
1907
+ }
1908
+ };
1909
+
1910
+ // Add event listeners
1911
+ textarea.addEventListener('mousedown', handleMouseDown);
1912
+ textarea.addEventListener('mouseup', handleMouseUp);
1913
+ document.addEventListener('mouseup', handleGlobalMouseUp);
1914
+
1915
+ // Cleanup
1916
+ return () => {
1917
+ textarea.removeEventListener('mousedown', handleMouseDown);
1918
+ textarea.removeEventListener('mouseup', handleMouseUp);
1919
+ document.removeEventListener('mouseup', handleGlobalMouseUp);
1920
+ };
1921
+ }, []);
1922
+
1923
+ // initial prompt change. Reset the chat history and get this response
1924
+ useEffect(() => {
1925
+ if (initialPrompt && initialPrompt !== "") {
1926
+ if (initialPrompt !== lastPrompt) {
1927
+ setIsLoading(true);
1928
+
1929
+ // Clear thinking blocks for new response
1930
+ setThinkingBlocks([]);
1931
+ setCurrentThinkingIndex(0);
1932
+
1933
+ ensureConversation().then((convId) => {
1934
+ if (lastController) stop(lastController);
1935
+ const controller = new AbortController();
1936
+ send(
1937
+ initialPrompt,
1938
+ messages,
1939
+ [
1940
+ ...dataWithExtras(),
1941
+ {
1942
+ key: "--messages",
1943
+ data: messages.length.toString(),
1944
+ },
1945
+ ],
1946
+ true,
1947
+ true,
1948
+ service,
1949
+ convId,
1950
+ controller
1951
+ );
1952
+
1953
+ // Store the context in component state
1954
+ setLastPrompt(initialPrompt);
1955
+ setLastMessages(messages);
1956
+ setLastKey(initialPrompt);
1957
+ setLastController(controller);
1958
+ setHistory({});
1959
+
1960
+ // Clear button registry for new conversation
1961
+ buttonActionRegistry.current.clear();
1962
+ });
1963
+ }
1964
+ }
1965
+ }, [initialPrompt]);
1966
+
1967
+ useEffect(() => {
1968
+ if (scrollToEnd) {
1969
+ if (window.top !== window.self) {
1970
+ const responseArea = responseAreaRef.current as any;
1971
+ responseArea.scrollTo({
1972
+ top: responseArea.scrollHeight,
1973
+ behavior: "smooth",
1974
+ });
1975
+ } else {
1976
+ // If the ChatPanel is not within an iframe
1977
+ bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
1978
+ }
1979
+ } else {
1980
+ const responseArea = responseAreaRef.current as any;
1981
+ if (responseArea) {
1982
+ setHasScroll(hasVerticalScrollbar(responseArea));
1983
+ const handleScroll = () => {
1984
+ const isScrolledToBottom =
1985
+ responseArea.scrollHeight - responseArea.scrollTop ===
1986
+ responseArea.clientHeight;
1987
+ setIsAtBottom(isScrolledToBottom);
1988
+ };
1989
+ handleScroll();
1990
+ responseArea.addEventListener("scroll", handleScroll);
1991
+ return () => responseArea.removeEventListener("scroll", handleScroll);
1992
+ }
1993
+ }
1994
+ }, [response, history]);
1995
+
1996
+ // Use ref to avoid infinite loops from unstable callback references
1997
+ useEffect(() => {
1998
+ if (historyCallbackRef.current) {
1999
+ historyCallbackRef.current(history);
2000
+ }
2001
+ }, [history]);
2002
+
2003
+ useEffect(() => {
2004
+ //console.log("followOnPromptChanged detected");
2005
+ if (followOnPrompt && followOnPrompt !== "") {
2006
+ continueChat(followOnPrompt);
2007
+ }
2008
+ }, [followOnPrompt]);
2009
+
2010
+ const setCookie = (name: string, value: string, days: number = 30) => {
2011
+ const date = new Date();
2012
+ date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
2013
+ const expires = `; expires=${date.toUTCString()}`;
2014
+ const sameSite = "; SameSite=Lax"; // Add SameSite attribute for security
2015
+ const secure = window.location.protocol === "https:" ? "; Secure" : "";
2016
+ document.cookie = `${name}=${value}${expires}; path=/${sameSite}${secure}`;
2017
+ };
2018
+
2019
+ const getCookie = (name: string): string | undefined => {
2020
+ const value = `; ${document.cookie}`;
2021
+ const parts = value.split(`; ${name}=`);
2022
+ if (parts.length === 2) {
2023
+ return parts.pop()?.split(";").shift();
2024
+ }
2025
+ return undefined;
2026
+ };
2027
+
2028
+ const ensureConversation = () => {
2029
+ if (
2030
+ (!currentConversation || currentConversation === "") &&
2031
+ createConversationOnFirstChat
2032
+ ) {
2033
+ const browserInfo = getBrowserInfo();
2034
+
2035
+ return fetch(`${publicAPIUrl}/conversations`, {
2036
+ method: "POST",
2037
+ headers: {
2038
+ "Content-Type": "application/json",
2039
+ },
2040
+ body: JSON.stringify({
2041
+ project_id: project_id ?? "",
2042
+ agentId: agent,
2043
+ customerId: currentCustomer?.customer_id ?? null,
2044
+ customerEmail: currentCustomer?.customer_user_email ?? null,
2045
+ timezone: browserInfo?.userTimezone,
2046
+ language: browserInfo?.userLanguage,
2047
+ }),
2048
+ })
2049
+ .then(async (res) => {
2050
+ if (!res.ok) {
2051
+ const errorText = await res.text();
2052
+ throw new Error(
2053
+ `HTTP error! status: ${res.status}, message: ${errorText}`
2054
+ );
2055
+ }
2056
+ return res.json();
2057
+ })
2058
+ .then((newConvo) => {
2059
+ if (newConvo?.id) {
2060
+ //console.log("new conversation created", newConvo.id);
2061
+ setCurrentConversation(newConvo.id);
2062
+ return newConvo.id;
2063
+ }
2064
+ return "";
2065
+ })
2066
+ .catch((error) => {
2067
+ console.error("Error creating new conversation", error);
2068
+ return "";
2069
+ });
2070
+ }
2071
+ // If a currentConversation exists, return it in a resolved Promise.
2072
+ return Promise.resolve(currentConversation);
2073
+ };
2074
+
2075
+ useEffect(() => {
2076
+ const isEmpty = (value: string | undefined | null): boolean => {
2077
+ return !value || value.trim() === "" || value.trim() === "undefined";
2078
+ };
2079
+
2080
+ // First, try to save any new values to cookies
2081
+ let updatedValues = { ...currentCustomer };
2082
+ let needsUpdate = false;
2083
+
2084
+ if (
2085
+ !isEmpty(currentCustomer?.customer_user_email) &&
2086
+ isEmailAddress(currentCustomer.customer_user_email ?? "")
2087
+ ) {
2088
+ setCookie(
2089
+ "llmasaservice-panel-customer-user-email",
2090
+ currentCustomer?.customer_user_email ?? ""
2091
+ );
2092
+ // Only update email state if it's different from the cookie-derived value
2093
+ if (emailInput !== currentCustomer.customer_user_email) {
2094
+ setEmailInput(currentCustomer.customer_user_email ?? "");
2095
+ setEmailInputSet(true);
2096
+ setEmailValid(true);
2097
+ }
2098
+ }
2099
+
2100
+ if (isEmpty(currentCustomer?.customer_user_email)) {
2101
+ const cookieEmail = getCookie("llmasaservice-panel-customer-user-email");
2102
+ if (!isEmpty(cookieEmail) && isEmailAddress(cookieEmail ?? "")) {
2103
+ updatedValues.customer_user_email = cookieEmail;
2104
+ needsUpdate = true;
2105
+ }
2106
+ }
2107
+
2108
+ // if the customer_id is not set, but the email is set, use the email as the customer_id
2109
+ if (
2110
+ isEmpty(currentCustomer?.customer_id) &&
2111
+ !isEmpty(updatedValues.customer_user_email) &&
2112
+ isEmailAddress(updatedValues.customer_user_email ?? "")
2113
+ ) {
2114
+ updatedValues.customer_id = updatedValues.customer_user_email ?? "";
2115
+ needsUpdate = true;
2116
+ }
2117
+
2118
+ // Only update state if the derived values are actually different
2119
+ if (
2120
+ needsUpdate &&
2121
+ (updatedValues.customer_id !== currentCustomer.customer_id ||
2122
+ updatedValues.customer_user_email !==
2123
+ currentCustomer.customer_user_email)
2124
+ ) {
2125
+ // update the customer id and email in the conversation
2126
+ ensureConversation().then((convId) => {
2127
+ if (convId && convId !== "") {
2128
+ /*
2129
+ console.log(
2130
+ "updating conversation with customer id and email",
2131
+ convId,
2132
+ updatedValues
2133
+ );
2134
+ */
2135
+
2136
+ fetch(`${publicAPIUrl}/conversations/${convId}`, {
2137
+ method: "POST",
2138
+ headers: {
2139
+ "Content-Type": "application/json",
2140
+ },
2141
+ body: JSON.stringify({
2142
+ project_id: project_id ?? "",
2143
+ customerId: updatedValues.customer_id,
2144
+ customerEmail: updatedValues.customer_user_email,
2145
+ }),
2146
+ }).catch((error) =>
2147
+ console.error("Failed to update conversation:", error)
2148
+ );
2149
+ }
2150
+ });
2151
+
2152
+ setCurrentCustomer(updatedValues);
2153
+ }
2154
+ }, [currentCustomer, project_id, agent, publicAPIUrl, emailInput]);
2155
+
2156
+ const continueChat = (suggestion?: string) => {
2157
+ //console.log("continueChat called");
2158
+
2159
+ // Clear thinking blocks for new response
2160
+ setThinkingBlocks([]);
2161
+ setCurrentThinkingIndex(0);
2162
+
2163
+ // Auto-set email if valid before proceeding
2164
+ if (emailInput && isEmailAddress(emailInput) && !emailInputSet) {
2165
+ const newId =
2166
+ currentCustomer?.customer_id &&
2167
+ currentCustomer.customer_id !== "" &&
2168
+ currentCustomer.customer_id !== currentCustomer?.customer_user_email
2169
+ ? currentCustomer.customer_id
2170
+ : emailInput;
2171
+
2172
+ setEmailInputSet(true);
2173
+ setEmailValid(true);
2174
+ setCurrentCustomer({
2175
+ customer_id: newId,
2176
+ customer_user_email: emailInput,
2177
+ });
2178
+ }
2179
+
2180
+ // wait till new conversation created....
2181
+ ensureConversation().then((convId) => {
2182
+ //console.log("current customer", currentCustomer);
2183
+
2184
+ if (!idle) {
2185
+ stop(lastController);
2186
+
2187
+ setHistory((prevHistory) => {
2188
+ return {
2189
+ ...prevHistory,
2190
+ [lastKey ?? ""]: {
2191
+ content:
2192
+ processThinkingTags(response).cleanedText +
2193
+ "\n\n(response cancelled)",
2194
+ callId: lastCallId,
2195
+ },
2196
+ };
2197
+ });
2198
+
2199
+ return;
2200
+ }
2201
+
2202
+ if (clearFollowOnQuestionsNextPrompt) {
2203
+ setFollowOnQuestionsState([]);
2204
+ }
2205
+
2206
+ if (
2207
+ (suggestion && suggestion !== "") ||
2208
+ (nextPrompt && nextPrompt !== "")
2209
+ ) {
2210
+ setIsLoading(true);
2211
+
2212
+ // build the chat input from history
2213
+ // For new conversations (when history is empty), start with an empty array
2214
+ // to prevent carrying over previous conversation context
2215
+ const messagesAndHistory =
2216
+ Object.keys(history).length === 0 ? [] : messages;
2217
+ Object.entries(history).forEach(([prompt, response]) => {
2218
+ // Strip timestamp prefix from prompt before using it
2219
+ let promptToSend = prompt;
2220
+ if (prompt.includes(":")) {
2221
+ // Check if it starts with an ISO timestamp pattern
2222
+ const isoTimestampRegex =
2223
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
2224
+ if (isoTimestampRegex.test(prompt)) {
2225
+ const colonIndex = prompt.indexOf(":", 19); // Skip the colons in the timestamp part
2226
+ promptToSend = prompt.substring(colonIndex + 1);
2227
+ }
2228
+ // Also handle legacy numeric timestamps for backward compatibility
2229
+ else if (/^\d+:/.test(prompt)) {
2230
+ const colonIndex = prompt.indexOf(":");
2231
+ promptToSend = prompt.substring(colonIndex + 1);
2232
+ }
2233
+ }
2234
+
2235
+ if (promptTemplate && promptTemplate !== "") {
2236
+ promptToSend = promptTemplate.replace("{{prompt}}", promptToSend);
2237
+ for (let i = 0; i < data.length; i++) {
2238
+ promptToSend = promptToSend.replace(
2239
+ "{{" + data[i]?.key + "}}",
2240
+ data[i]?.data ?? ""
2241
+ );
2242
+ }
2243
+ }
2244
+
2245
+ messagesAndHistory.push({ role: "user", content: promptToSend });
2246
+ messagesAndHistory.push({
2247
+ role: "assistant",
2248
+ content: response.content,
2249
+ });
2250
+ });
2251
+
2252
+ let nextPromptToSend = suggestion ?? nextPrompt;
2253
+
2254
+ // Generate a unique key using ISO timestamp prefix + prompt content
2255
+ // This ensures chronological order and virtually guarantees uniqueness
2256
+ const timestamp = new Date().toISOString();
2257
+ let promptKey = `${timestamp}:${nextPromptToSend ?? ""}`;
2258
+
2259
+ // set the history prompt with the about to be sent prompt
2260
+ setHistory((prevHistory) => {
2261
+ return {
2262
+ ...prevHistory,
2263
+ [promptKey ?? ""]: { content: "", callId: "" },
2264
+ };
2265
+ });
2266
+
2267
+ // if this is the first user message, use the template. otherwise it is a follow-on question(s)
2268
+ if (
2269
+ (initialPrompt &&
2270
+ initialPrompt !== "" &&
2271
+ Object.keys(history).length === 1) ||
2272
+ ((!initialPrompt || initialPrompt === "") &&
2273
+ Object.keys(history).length === 0)
2274
+ ) {
2275
+ if (promptTemplate && promptTemplate !== "") {
2276
+ nextPromptToSend = promptTemplate.replace(
2277
+ "{{prompt}}",
2278
+ nextPromptToSend
2279
+ );
2280
+ for (let i = 0; i < data.length; i++) {
2281
+ nextPromptToSend = nextPromptToSend.replace(
2282
+ "{{" + data[i]?.key + "}}",
2283
+ data[i]?.data ?? ""
2284
+ );
2285
+ }
2286
+ }
2287
+ }
2288
+
2289
+ //console.log("Sending for conversation", convId);
2290
+
2291
+ const controller = new AbortController();
2292
+ send(
2293
+ nextPromptToSend,
2294
+ messagesAndHistory,
2295
+ [
2296
+ ...dataWithExtras(),
2297
+ {
2298
+ key: "--messages",
2299
+ data: messagesAndHistory.length.toString(),
2300
+ },
2301
+ ],
2302
+ true,
2303
+ true,
2304
+ service,
2305
+ convId,
2306
+ controller
2307
+ );
2308
+
2309
+ setLastPrompt(nextPromptToSend);
2310
+ setLastMessages(messagesAndHistory);
2311
+ setLastKey(promptKey);
2312
+ setLastController(controller);
2313
+ setNextPrompt("");
2314
+ }
2315
+ });
2316
+ };
2317
+
2318
+ const replaceHistory = (newHistory: {
2319
+ [prompt: string]: { content: string; callId: string };
2320
+ }) => {
2321
+ setHistory(newHistory);
2322
+ };
2323
+
2324
+ function copyToClipboard(text: string) {
2325
+ navigator.clipboard.writeText(text);
2326
+ interactionClicked(lastCallId, "copy");
2327
+ }
2328
+
2329
+ const scrollToBottom = () => {
2330
+ if (window.top !== window.self) {
2331
+ const responseArea = responseAreaRef.current as any;
2332
+ responseArea.scrollTo({
2333
+ top: responseArea.scrollHeight,
2334
+ behavior: "smooth",
2335
+ });
2336
+ } else {
2337
+ // If the ChatPanel is not within an iframe
2338
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
2339
+ }
2340
+ };
2341
+
2342
+ const CodeBlock = ({ node, className, children, style, ...props }: any) => {
2343
+ const match = /language-(\w+)/.exec(className || "");
2344
+
2345
+ return match ? (
2346
+ <>
2347
+ <div
2348
+ style={{
2349
+ border: 0,
2350
+ padding: 0,
2351
+ height: "16px",
2352
+ display: "flex",
2353
+ justifyContent: "space-between",
2354
+ alignItems: "center",
2355
+ }}
2356
+ >
2357
+ <span>{match ? match[1] : "Unknown"}</span>
2358
+ <button
2359
+ onClick={() => copyToClipboard(children)}
2360
+ className="copy-button"
2361
+ >
2362
+ <svg
2363
+ xmlns="http://www.w3.org/2000/svg"
2364
+ viewBox="0 0 320 320"
2365
+ fill="currentColor"
2366
+ className="icon-svg"
2367
+ >
2368
+ <path
2369
+ d="M35,270h45v45c0,8.284,6.716,15,15,15h200c8.284,0,15-6.716,15-15V75c0-8.284-6.716-15-15-15h-45V15
2370
+ c0-8.284-6.716-15-15-15H35c-8.284,0-15,6.716-15,15v240C20,263.284,26.716,270,35,270z M280,300H110V90h170V300z M50,30h170v30H95
2371
+ c-8.284,0-15,6.716-15,15v165H50V30z"
2372
+ />
2373
+ <path d="M155,120c-8.284,0-15,6.716-15,15s6.716,15,15,15h80c8.284,0,15-6.716,15-15s-6.716-15-15-15H155z" />
2374
+ <path d="M235,180h-80c-8.284,0-15,6.716-15,15s6.716,15,15,15h80c8.284,0,15-6.716,15-15S243.284,180,235,180z" />
2375
+ <path
2376
+ d="M235,240h-80c-8.284,0-15,6.716-15,15c0,8.284,6.716,15,15,15h80c8.284,0,15-6.716,15-15C250,246.716,243.284,240,235,240z
2377
+ "
2378
+ />
2379
+ </svg>
2380
+ </button>
2381
+ </div>
2382
+ <SyntaxHighlighter
2383
+ style={prismStyle}
2384
+ PreTag="div"
2385
+ language={match[1]}
2386
+ {...props}
2387
+ >
2388
+ {String(children).replace(/\n$/, "")}
2389
+ </SyntaxHighlighter>
2390
+ </>
2391
+ ) : (
2392
+ <code className={className ? className : ""} {...props}>
2393
+ {children}
2394
+ </code>
2395
+ );
2396
+ };
2397
+
2398
+ // links should always open in a new tab
2399
+ const CustomLink = ({ href, children, ...props }: any) => {
2400
+ return (
2401
+ <a href={href} target="_blank" rel="noopener noreferrer" {...props}>
2402
+ {children}
2403
+ </a>
2404
+ );
2405
+ };
2406
+
2407
+ const convertMarkdownToHTML = (markdown: string): string => {
2408
+ const html = ReactDOMServer.renderToStaticMarkup(
2409
+ <ReactMarkdown
2410
+ className={markdownClass}
2411
+ remarkPlugins={[remarkGfm]}
2412
+ rehypePlugins={[rehypeRaw]}
2413
+ >
2414
+ {markdown}
2415
+ </ReactMarkdown>
2416
+ );
2417
+ return html;
2418
+ };
2419
+
2420
+ const convertHistoryToHTML = (history: {
2421
+ [prompt: string]: { callId: string; content: string };
2422
+ }): string => {
2423
+ const stylesheet = `
2424
+ <style>
2425
+ .conversation-history {
2426
+ font-family: Arial, sans-serif;
2427
+ line-height: 1.5; /* Slightly increase line height for readability */
2428
+ }
2429
+ .history-entry {
2430
+ margin-bottom: 15px;
2431
+ }
2432
+ .prompt-container, .response-container {
2433
+ margin-bottom: 10px; /* Adjusted spacing */
2434
+ }
2435
+ .prompt, .response {
2436
+ display: block; /* Ensure they take up the full row */
2437
+ margin: 5px 0; /* Add vertical spacing */
2438
+ padding: 10px; /* Increase padding for better spacing */
2439
+ border-radius: 5px;
2440
+ max-width: 80%; /* Keep width constrained */
2441
+ }
2442
+ .prompt {
2443
+ background-color: #efefef;
2444
+ margin-left: 0; /* Align to the left */
2445
+ }
2446
+ .response {
2447
+ background-color: #f0fcfd;
2448
+ margin-left: 25px; /* Indent slightly for visual differentiation */
2449
+ }
2450
+ </style>
2451
+ `;
2452
+
2453
+ let html = `
2454
+ <html>
2455
+ <head>
2456
+ ${stylesheet}
2457
+ </head>
2458
+ <body>
2459
+ <h1>Conversation History (${new Date().toLocaleString()})</h1>
2460
+ <div class="conversation-history">
2461
+ `;
2462
+
2463
+ Object.entries(history).forEach(([prompt, response], index) => {
2464
+ if (hideInitialPrompt && index === 0) {
2465
+ html += `
2466
+ <div class="history-entry">
2467
+ <div class="response-container">
2468
+ <div class="response">${convertMarkdownToHTML(
2469
+ response.content
2470
+ )}</div>
2471
+ </div>
2472
+ </div>
2473
+ `;
2474
+ } else {
2475
+ html += `
2476
+ <div class="history-entry">
2477
+ <div class="prompt-container">
2478
+ <div class="prompt">${convertMarkdownToHTML(
2479
+ formatPromptForDisplay(prompt)
2480
+ )}</div>
2481
+ </div>
2482
+ <div class="response-container">
2483
+ <div class="response">${convertMarkdownToHTML(response.content)}</div>
2484
+ </div>
2485
+ </div>
2486
+ `;
2487
+ }
2488
+ });
2489
+
2490
+ html += `
2491
+ </div>
2492
+ </body>
2493
+ </html>
2494
+ `;
2495
+
2496
+ return html;
2497
+ };
2498
+
2499
+ const saveHTMLToFile = (html: string, filename: string) => {
2500
+ const blob = new Blob([html], { type: "text/html" });
2501
+ const link = document.createElement("a");
2502
+ link.href = URL.createObjectURL(blob);
2503
+ link.download = filename;
2504
+ document.body.appendChild(link);
2505
+ link.click();
2506
+ document.body.removeChild(link);
2507
+ };
2508
+
2509
+ const handleSendEmail = (to: string, from: string) => {
2510
+ sendConversationsViaEmail(to, `Conversation History from ${title}`, from);
2511
+ interactionClicked(lastCallId, "email", to);
2512
+ setEmailSent(true);
2513
+ };
2514
+
2515
+ const handleSuggestionClick = async (suggestion: string) => {
2516
+ continueChat(suggestion);
2517
+ await interactionClicked(lastCallId, "suggestion");
2518
+ };
2519
+
2520
+ const sendConversationsViaEmail = async (
2521
+ to: string,
2522
+ subject: string = `Conversation History from ${title}`,
2523
+ from: string = ""
2524
+ ) => {
2525
+ fetch(`${publicAPIUrl}/share/email`, {
2526
+ method: "POST",
2527
+ headers: {
2528
+ "Content-Type": "application/json",
2529
+ },
2530
+ body: JSON.stringify({
2531
+ to: to,
2532
+ from: from,
2533
+ subject: subject,
2534
+ html: convertHistoryToHTML(history),
2535
+ project_id: project_id ?? "",
2536
+ customer: currentCustomer,
2537
+ history: history,
2538
+ title: title,
2539
+ }),
2540
+ });
2541
+
2542
+ await interactionClicked(lastCallId, "email", from);
2543
+ };
2544
+
2545
+ const sendCallToActionEmail = async (from: string) => {
2546
+ const r = await fetch(`${publicAPIUrl}/share/email`, {
2547
+ method: "POST",
2548
+ headers: {
2549
+ "Content-Type": "application/json",
2550
+ },
2551
+ body: JSON.stringify({
2552
+ to: callToActionEmailAddress,
2553
+ from: from,
2554
+ subject: `${callToActionEmailSubject} from ${from}`,
2555
+ html: convertHistoryToHTML(history),
2556
+ project_id: project_id ?? "",
2557
+ customer: currentCustomer,
2558
+ history: history,
2559
+ title: title,
2560
+ }),
2561
+ });
2562
+
2563
+ await interactionClicked(lastCallId, "cta", from);
2564
+
2565
+ setCallToActionSent(true);
2566
+ };
2567
+
2568
+ const interactionClicked = async (
2569
+ callId: string,
2570
+ action: string,
2571
+ emailaddress: string = "",
2572
+ comment: string = ""
2573
+ ) => {
2574
+ console.log(`Interaction clicked: ${action} for callId: ${callId}`);
2575
+
2576
+ ensureConversation().then((convId) => {
2577
+ // special case where no call made yet, and they click on Suggestion/CTA/Email/Save
2578
+ if (!callId || callId === "") callId = convId;
2579
+
2580
+ const email =
2581
+ emailaddress && emailaddress !== ""
2582
+ ? emailaddress
2583
+ : isEmailAddress(currentCustomer?.customer_user_email ?? "")
2584
+ ? currentCustomer?.customer_user_email
2585
+ : isEmailAddress(currentCustomer?.customer_id ?? "")
2586
+ ? currentCustomer?.customer_id
2587
+ : "";
2588
+
2589
+ fetch(`${publicAPIUrl}/feedback/${callId}/${action}`, {
2590
+ method: "POST",
2591
+ headers: {
2592
+ "Content-Type": "application/json",
2593
+ },
2594
+ body: JSON.stringify({
2595
+ project_id: project_id ?? "",
2596
+ conversation_id: convId ?? "",
2597
+ email: email,
2598
+ comment: comment,
2599
+ }),
2600
+ });
2601
+ });
2602
+ };
2603
+
2604
+ const formatPromptForDisplay = (prompt: string): string => {
2605
+ if (!prompt) {
2606
+ return "";
2607
+ }
2608
+
2609
+ // Strip timestamp prefix if present (format: "ISO_timestamp:prompt")
2610
+ let cleanedPrompt = prompt;
2611
+ if (prompt.includes(":")) {
2612
+ // Check if it starts with an ISO timestamp pattern (YYYY-MM-DDTHH:mm:ss.sssZ)
2613
+ const isoTimestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z:/;
2614
+ if (isoTimestampRegex.test(prompt)) {
2615
+ const colonIndex = prompt.indexOf(":", 19); // Skip the colons in the timestamp part
2616
+ cleanedPrompt = prompt.substring(colonIndex + 1);
2617
+ }
2618
+ // Also handle legacy numeric timestamps for backward compatibility
2619
+ else if (/^\d+:/.test(prompt)) {
2620
+ const colonIndex = prompt.indexOf(":");
2621
+ cleanedPrompt = prompt.substring(colonIndex + 1);
2622
+ }
2623
+ }
2624
+
2625
+ if (hideRagContextInPrompt && cleanedPrompt.includes("CONTEXT:")) {
2626
+ const parts = cleanedPrompt.split("CONTEXT:");
2627
+ const withoutContext = parts.length > 0 ? (parts[0] as string) : "";
2628
+
2629
+ // Remove the optional chaining since withoutContext is always a string
2630
+ if (withoutContext.includes("PROMPT:")) {
2631
+ const promptParts = withoutContext.split("PROMPT:");
2632
+ return promptParts.length > 1
2633
+ ? (promptParts[1] || "").trim()
2634
+ : withoutContext.trim();
2635
+ }
2636
+
2637
+ return withoutContext.trim();
2638
+ }
2639
+
2640
+ return cleanedPrompt;
2641
+ };
2642
+
2643
+ const isDisabledDueToNoEmail = () => {
2644
+ const valid = isEmailAddress(emailInput);
2645
+ if (valid) return false;
2646
+ if (customerEmailCaptureMode === "REQUIRED") return true;
2647
+ return false;
2648
+ };
2649
+
2650
+ // helper to dedupe tool names
2651
+ const getUniqueToolNames = (reqs: typeof pendingToolRequests) =>
2652
+ Array.from(new Set(reqs.map((r) => r.toolName)));
2653
+
2654
+ // called by each button
2655
+ const handleToolApproval = (
2656
+ toolName: string,
2657
+ scope: "once" | "session" | "always"
2658
+ ) => {
2659
+ if (scope === "session" || scope === "always") {
2660
+ setSessionApprovedTools((p) => Array.from(new Set([...p, toolName])));
2661
+ }
2662
+ if (scope === "always") {
2663
+ setAlwaysApprovedTools((p) => Array.from(new Set([...p, toolName])));
2664
+ }
2665
+
2666
+ // process and remove just this tool’s calls
2667
+ const requestsToRun = pendingToolRequests.filter(
2668
+ (r) => r.toolName === toolName
2669
+ );
2670
+ processGivenToolRequests(requestsToRun);
2671
+ setPendingToolRequests((p) => p.filter((r) => r.toolName !== toolName));
2672
+ };
2673
+
2674
+ // auto‐process pending tools that were previously approved (session or always)
2675
+ useEffect(() => {
2676
+ if (pendingToolRequests.length === 0) return;
2677
+ const toAuto = pendingToolRequests.filter(
2678
+ (r) =>
2679
+ sessionApprovedTools.includes(r.toolName) ||
2680
+ alwaysApprovedTools.includes(r.toolName)
2681
+ );
2682
+ if (toAuto.length > 0) {
2683
+ processGivenToolRequests(toAuto);
2684
+ setPendingToolRequests((prev) =>
2685
+ prev.filter(
2686
+ (r) =>
2687
+ !sessionApprovedTools.includes(r.toolName) &&
2688
+ !alwaysApprovedTools.includes(r.toolName)
2689
+ )
2690
+ );
2691
+ }
2692
+ }, [pendingToolRequests, sessionApprovedTools, alwaysApprovedTools]);
2693
+
2694
+ return (
2695
+ <>
2696
+ <div
2697
+ style={{ width: width, height: height }}
2698
+ className={"llm-panel" + (theme === "light" ? "" : " dark-theme")}
2699
+ >
2700
+ {title && title !== "" ? <div className="title">{title}</div> : null}
2701
+ <div className="responseArea" ref={responseAreaRef}>
2702
+ {initialMessage && initialMessage !== "" ? (
2703
+ <div className="history-entry">
2704
+ <div className="response">
2705
+ <ReactMarkdown
2706
+ className={markdownClass}
2707
+ remarkPlugins={[remarkGfm]}
2708
+ rehypePlugins={[rehypeRaw]}
2709
+ >
2710
+ {initialMessage}
2711
+ </ReactMarkdown>
2712
+ </div>
2713
+ </div>
2714
+ ) : null}
2715
+
2716
+ {Object.entries(history).map(([prompt, historyEntry], index) => {
2717
+ const isLastEntry = index === Object.keys(history).length - 1;
2718
+ const hasToolData = !!(
2719
+ (historyEntry?.toolCalls?.length || 0) > 0 ||
2720
+ (historyEntry?.toolResponses?.length || 0) > 0
2721
+ );
2722
+
2723
+ return (
2724
+ <div className="history-entry" key={index}>
2725
+ {hideInitialPrompt && index === 0 ? null : (
2726
+ <div className="prompt">{formatPromptForDisplay(prompt)}</div>
2727
+ )}
2728
+
2729
+ <div className="response">
2730
+ {/* Show streaming response with thinking blocks displayed separately */}
2731
+ {index === Object.keys(history).length - 1 &&
2732
+ (isLoading || !idle) &&
2733
+ !justReset ? (
2734
+ <div className="streaming-response">
2735
+ {/* Display current thinking block or thinking message */}
2736
+ {(() => {
2737
+ const { cleanedText } = processThinkingTags(
2738
+ response || ""
2739
+ );
2740
+
2741
+ // If we have thinking blocks, show the current one
2742
+ if (thinkingBlocks.length > 0) {
2743
+ const isOnLastBlock =
2744
+ currentThinkingIndex === thinkingBlocks.length - 1;
2745
+ const hasMainContent =
2746
+ cleanedText && cleanedText.trim().length > 0;
2747
+ const shouldShowLoading =
2748
+ isOnLastBlock && !hasMainContent;
2749
+
2750
+ return (
2751
+ <div>
2752
+ {renderThinkingBlocks()}
2753
+ {/* Show animated thinking if we're showing the last block and no main content yet */}
2754
+ {shouldShowLoading && (
2755
+ <div className="loading-text">
2756
+ Thinking...&nbsp;
2757
+ <div className="dot"></div>
2758
+ <div className="dot"></div>
2759
+ <div className="dot"></div>
2760
+ </div>
2761
+ )}
2762
+ </div>
2763
+ );
2764
+ }
2765
+
2766
+ // If no thinking blocks yet but no main content, show generic thinking
2767
+ if (!cleanedText || cleanedText.length === 0) {
2768
+ return (
2769
+ <div className="loading-text">
2770
+ Thinking...&nbsp;
2771
+ <div className="dot"></div>
2772
+ <div className="dot"></div>
2773
+ <div className="dot"></div>
2774
+ </div>
2775
+ );
2776
+ }
2777
+
2778
+ return null;
2779
+ })()}
2780
+
2781
+ {/* Display the main content (processed with actions) */}
2782
+ {(() => {
2783
+ // Get the processed content that includes action buttons from history
2784
+ // During streaming, use the most recent history entry if it exists
2785
+ if (lastKey && history[lastKey] && history[lastKey].content) {
2786
+ return (
2787
+ <ReactMarkdown
2788
+ className={markdownClass}
2789
+ remarkPlugins={[remarkGfm]}
2790
+ rehypePlugins={[rehypeRaw]}
2791
+ components={{ /*a: CustomLink,*/ code: CodeBlock }}
2792
+ >
2793
+ {history[lastKey].content}
2794
+ </ReactMarkdown>
2795
+ );
2796
+ }
2797
+
2798
+ // Fallback to cleaned text if no processed history exists yet
2799
+ const { cleanedText } = processThinkingTags(
2800
+ response || ""
2801
+ );
2802
+ return cleanedText && cleanedText.length > 0 ? (
2803
+ <ReactMarkdown
2804
+ className={markdownClass}
2805
+ remarkPlugins={[remarkGfm]}
2806
+ rehypePlugins={[rehypeRaw]}
2807
+ components={{ /*a: CustomLink,*/ code: CodeBlock }}
2808
+ >
2809
+ {cleanedText}
2810
+ </ReactMarkdown>
2811
+ ) : null;
2812
+ })()}
2813
+ </div>
2814
+ ) : (
2815
+ <div>
2816
+ {/* For completed responses, show stored thinking blocks if this is the last entry */}
2817
+ {isLastEntry &&
2818
+ thinkingBlocks.length > 0 &&
2819
+ renderThinkingBlocks()}
2820
+
2821
+ {/* Show the main content (cleaned of thinking tags) */}
2822
+ <ReactMarkdown
2823
+ className={markdownClass}
2824
+ remarkPlugins={[remarkGfm]}
2825
+ rehypePlugins={[rehypeRaw]}
2826
+ components={{ /*a: CustomLink,*/ code: CodeBlock }}
2827
+ >
2828
+ {processThinkingTags(historyEntry.content).cleanedText}
2829
+ </ReactMarkdown>
2830
+ </div>
2831
+ )}
2832
+
2833
+ {isLastEntry && pendingToolRequests.length > 0 && (
2834
+ <div className="approve-tools-panel">
2835
+ {getUniqueToolNames(pendingToolRequests).map(
2836
+ (toolName) => {
2837
+ const tool = toolList.find(
2838
+ (t) => t.name === toolName
2839
+ );
2840
+ return (
2841
+ <div key={toolName} className="approve-tool-item">
2842
+ <div className="approve-tools-header">
2843
+ Tool “{toolName}” requires approval <br />
2844
+ </div>
2845
+
2846
+ <div className="approve-tools-buttons">
2847
+ <button
2848
+ className="approve-tools-button"
2849
+ onClick={() =>
2850
+ handleToolApproval(toolName, "once")
2851
+ }
2852
+ disabled={isLoading}
2853
+ >
2854
+ Approve Once
2855
+ </button>
2856
+ <button
2857
+ className="approve-tools-button"
2858
+ onClick={() =>
2859
+ handleToolApproval(toolName, "session")
2860
+ }
2861
+ disabled={isLoading}
2862
+ >
2863
+ Approve This Chat
2864
+ </button>
2865
+ <button
2866
+ className="approve-tools-button"
2867
+ onClick={() =>
2868
+ handleToolApproval(toolName, "always")
2869
+ }
2870
+ disabled={isLoading}
2871
+ >
2872
+ Approve Always
2873
+ </button>
2874
+ <br />
2875
+ </div>
2876
+
2877
+ {tool?.description && (
2878
+ <div className="approve-tools-description">
2879
+ {tool.description}
2880
+ </div>
2881
+ )}
2882
+ </div>
2883
+ );
2884
+ }
2885
+ )}
2886
+ </div>
2887
+ )}
2888
+
2889
+ {idle && !isLoading && pendingToolRequests.length === 0 && (
2890
+ <div className="button-container">
2891
+ <button
2892
+ className="copy-button"
2893
+ onClick={() => {
2894
+ copyToClipboard(historyEntry.content);
2895
+ }}
2896
+ disabled={isDisabledDueToNoEmail()}
2897
+ >
2898
+ <svg
2899
+ xmlns="http://www.w3.org/2000/svg"
2900
+ viewBox="0 0 320 320"
2901
+ fill="currentColor"
2902
+ className="icon-svg"
2903
+ >
2904
+ <path
2905
+ d="M35,270h45v45c0,8.284,6.716,15,15,15h200c8.284,0,15-6.716,15-15V75c0-8.284-6.716-15-15-15h-45V15
2906
+ c0-8.284-6.716-15-15-15H35c-8.284,0-15,6.716-15,15v240C20,263.284,26.716,270,35,270z M280,300H110V90h170V300z M50,30h170v30H95
2907
+ c-8.284,0-15,6.716-15,15v165H50V30z"
2908
+ />
2909
+ <path d="M155,120c-8.284,0-15,6.716-15,15s6.716,15,15,15h80c8.284,0,15-6.716,15-15s-6.716-15-15-15H155z" />
2910
+ <path d="M235,180h-80c-8.284,0-15,6.716-15,15s6.716,15,15,15h80c8.284,0,15-6.716,15-15S243.284,180,235,180z" />
2911
+ <path
2912
+ d="M235,240h-80c-8.284,0-15,6.716-15,15c0,8.284,6.716,15,15,15h80c8.284,0,15-6.716,15-15C250,246.716,243.284,240,235,240z
2913
+ "
2914
+ />
2915
+ </svg>
2916
+ </button>
2917
+
2918
+ <button
2919
+ className="thumbs-button"
2920
+ onClick={() => {
2921
+ if (thumbsUpClick) thumbsUpClick(historyEntry.callId);
2922
+ interactionClicked(historyEntry.callId, "thumbsup");
2923
+ }}
2924
+ disabled={isDisabledDueToNoEmail()}
2925
+ >
2926
+ <svg
2927
+ xmlns="http://www.w3.org/2000/svg"
2928
+ viewBox="0 0 20 20"
2929
+ fill="currentColor"
2930
+ className="icon-svg"
2931
+ >
2932
+ <path d="M20.22 9.55C19.79 9.04 19.17 8.75 18.5 8.75H14.47V6C14.47 4.48 13.24 3.25 11.64 3.25C10.94 3.25 10.31 3.67 10.03 4.32L7.49 10.25H5.62C4.31 10.25 3.25 11.31 3.25 12.62V18.39C3.25 19.69 4.32 20.75 5.62 20.75H17.18C18.27 20.75 19.2 19.97 19.39 18.89L20.71 11.39C20.82 10.73 20.64 10.06 20.21 9.55H20.22ZM5.62 19.25C5.14 19.25 4.75 18.86 4.75 18.39V12.62C4.75 12.14 5.14 11.75 5.62 11.75H7.23V19.25H5.62ZM17.92 18.63C17.86 18.99 17.55 19.25 17.18 19.25H8.74V11.15L11.41 4.9C11.45 4.81 11.54 4.74 11.73 4.74C12.42 4.74 12.97 5.3 12.97 5.99V10.24H18.5C18.73 10.24 18.93 10.33 19.07 10.5C19.21 10.67 19.27 10.89 19.23 11.12L17.91 18.62L17.92 18.63Z" />
2933
+ </svg>
2934
+ </button>
2935
+
2936
+ <button
2937
+ className="thumbs-button"
2938
+ onClick={() => {
2939
+ if (thumbsDownClick)
2940
+ thumbsDownClick(historyEntry.callId);
2941
+ interactionClicked(historyEntry.callId, "thumbsdown");
2942
+ }}
2943
+ disabled={isDisabledDueToNoEmail()}
2944
+ >
2945
+ <svg
2946
+ xmlns="http://www.w3.org/2000/svg"
2947
+ viewBox="0 0 20 20"
2948
+ fill="currentColor"
2949
+ className="icon-svg"
2950
+ >
2951
+ <path d="M18.38 3.25H6.81C5.72 3.25 4.79 4.03 4.6 5.11L3.29 12.61C3.18 13.27 3.36 13.94 3.78 14.45C4.21 14.96 4.83 15.25 5.5 15.25H9.53V18C9.53 19.52 10.76 20.75 12.36 20.75C13.06 20.75 13.69 20.33 13.97 19.68L16.51 13.75H18.39C19.7 13.75 20.76 12.69 20.76 11.38V5.61C20.76 4.31 19.7 3.25 18.39 3.25H18.38ZM15.26 12.85L12.59 19.1C12.55 19.19 12.46 19.26 12.27 19.26C11.58 19.26 11.03 18.7 11.03 18.01V13.76H5.5C5.27 13.76 5.07 13.67 4.93 13.5C4.78 13.33 4.73 13.11 4.77 12.88L6.08 5.38C6.14 5.02 6.45001 4.76 6.82 4.76H15.26V12.85ZM19.25 11.38C19.25 11.86 18.86 12.25 18.38 12.25H16.77V4.75H18.38C18.86 4.75 19.25 5.14 19.25 5.61V11.38Z" />
2952
+ </svg>
2953
+ </button>
2954
+
2955
+ {(idle || hasToolData) && (
2956
+ <button
2957
+ className="copy-button"
2958
+ title="Show Tool Call/Response JSON"
2959
+ onClick={() => {
2960
+ const historyEntry = history[prompt];
2961
+ setToolInfoData({
2962
+ calls: historyEntry?.toolCalls ?? [],
2963
+ responses: historyEntry?.toolResponses ?? [],
2964
+ });
2965
+ setIsToolInfoModalOpen(true);
2966
+ }}
2967
+ >
2968
+ <svg
2969
+ xmlns="http://www.w3.org/2000/svg"
2970
+ viewBox="0 0 24 24"
2971
+ fill="currentColor"
2972
+ className="icon-svg"
2973
+ >
2974
+ <path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" />
2975
+ </svg>
2976
+ </button>
2977
+ )}
2978
+ </div>
2979
+ )}
2980
+ </div>
2981
+ </div>
2982
+ );
2983
+ })}
2984
+ <ToolInfoModal
2985
+ isOpen={isToolInfoModalOpen}
2986
+ onClose={() => setIsToolInfoModalOpen(false)}
2987
+ data={toolInfoData}
2988
+ />
2989
+
2990
+ {followOnQuestionsState &&
2991
+ followOnQuestionsState.length > 0 &&
2992
+ idle &&
2993
+ !isLoading &&
2994
+ pendingToolRequests.length === 0 && (
2995
+ <div className="suggestions-container">
2996
+ {followOnQuestionsState.map((question, index) => (
2997
+ <button
2998
+ key={index}
2999
+ className="suggestion-button"
3000
+ onClick={() => handleSuggestionClick(question)}
3001
+ disabled={!idle || isDisabledDueToNoEmail()}
3002
+ >
3003
+ {question}
3004
+ </button>
3005
+ ))}
3006
+ </div>
3007
+ )}
3008
+
3009
+ <div ref={bottomRef} />
3010
+
3011
+ {hasScroll && !isAtBottom && (
3012
+ <button className="scroll-button" onClick={scrollToBottom}>
3013
+
3014
+ </button>
3015
+ )}
3016
+ </div>
3017
+ {showEmailPanel && (
3018
+ <>
3019
+ {!emailValid && (
3020
+ <div className="email-input-message">
3021
+ {isDisabledDueToNoEmail()
3022
+ ? "Let's get started - please enter your email"
3023
+ : CTAClickedButNoEmail || emailClickedButNoEmail
3024
+ ? "Sure, we just need an email address to contact you"
3025
+ : "Email address is invalid"}
3026
+ </div>
3027
+ )}
3028
+ <div className="email-input-container">
3029
+ <input
3030
+ type="email"
3031
+ name="email"
3032
+ id="email"
3033
+ className={
3034
+ emailValid
3035
+ ? emailInputSet
3036
+ ? "email-input-set"
3037
+ : "email-input"
3038
+ : "email-input-invalid"
3039
+ }
3040
+ placeholder={customerEmailCapturePlaceholder}
3041
+ value={emailInput}
3042
+ onChange={(e) => {
3043
+ const newEmail = e.target.value;
3044
+ setEmailInput(newEmail);
3045
+
3046
+ // Reset validation state while typing
3047
+ if (!emailInputSet) {
3048
+ if (
3049
+ customerEmailCaptureMode === "REQUIRED" &&
3050
+ newEmail !== ""
3051
+ ) {
3052
+ setEmailValid(isEmailAddress(newEmail));
3053
+ } else {
3054
+ setEmailValid(true);
3055
+ }
3056
+ }
3057
+ }}
3058
+ onBlur={() => {
3059
+ // Auto-validate and set email when field loses focus
3060
+ if (
3061
+ emailInput &&
3062
+ isEmailAddress(emailInput) &&
3063
+ !emailInputSet
3064
+ ) {
3065
+ const newId =
3066
+ currentCustomer?.customer_id &&
3067
+ currentCustomer.customer_id !== "" &&
3068
+ currentCustomer.customer_id !==
3069
+ currentCustomer?.customer_user_email
3070
+ ? currentCustomer.customer_id
3071
+ : emailInput;
3072
+
3073
+ setEmailInputSet(true);
3074
+ setEmailValid(true);
3075
+ setCurrentCustomer({
3076
+ customer_id: newId,
3077
+ customer_user_email: emailInput,
3078
+ });
3079
+
3080
+ // Handle pending actions
3081
+ if (CTAClickedButNoEmail) {
3082
+ sendCallToActionEmail(emailInput);
3083
+ setCTAClickedButNoEmail(false);
3084
+ }
3085
+ if (emailClickedButNoEmail) {
3086
+ handleSendEmail(emailInput, emailInput);
3087
+ setEmailClickedButNoEmail(false);
3088
+ }
3089
+ } else if (
3090
+ customerEmailCaptureMode === "REQUIRED" &&
3091
+ emailInput !== ""
3092
+ ) {
3093
+ setEmailValid(isEmailAddress(emailInput));
3094
+ }
3095
+ }}
3096
+ disabled={false}
3097
+ />
3098
+ {emailInputSet && (
3099
+ <button
3100
+ className="email-input-button"
3101
+ onClick={() => {
3102
+ setEmailInputSet(false);
3103
+ setEmailValid(true);
3104
+ }}
3105
+ title="Edit email"
3106
+ >
3107
+
3108
+ </button>
3109
+ )}
3110
+ </div>
3111
+ </>
3112
+ )}
3113
+ <div className="button-container-actions">
3114
+ {showNewConversationButton && (
3115
+ <button
3116
+ className={`save-button new-conversation-button ${
3117
+ newConversationConfirm ? "confirm-state" : ""
3118
+ }`}
3119
+ onClick={() => {
3120
+ if (!newConversationConfirm) {
3121
+ // First click - show confirmation state
3122
+ setNewConversationConfirm(true);
3123
+
3124
+ // Auto-reset confirmation after 3 seconds
3125
+ setTimeout(() => {
3126
+ setNewConversationConfirm(false);
3127
+ }, 3000);
3128
+ } else {
3129
+ // Second click - proceed with reset
3130
+ setNewConversationConfirm(false);
3131
+
3132
+ // Stop any current request first
3133
+ if (!idle) {
3134
+ stop(lastController);
3135
+ }
3136
+
3137
+ // Clear the current response from useLLM hook
3138
+ setResponse("");
3139
+
3140
+ // Reset conversation state while preserving customer info
3141
+ setHistory({});
3142
+ setLastMessages([]);
3143
+ setNextPrompt("");
3144
+ setLastPrompt(null);
3145
+ setLastKey(null);
3146
+ setIsLoading(false);
3147
+ setCurrentConversation(null);
3148
+ setCallToActionSent(false);
3149
+ setEmailSent(false);
3150
+ setCTAClickedButNoEmail(false);
3151
+ setEmailClickedButNoEmail(false);
3152
+ setFollowOnQuestionsState(followOnQuestions);
3153
+ setSessionApprovedTools([]);
3154
+ setThinkingBlocks([]);
3155
+ setCurrentThinkingIndex(0);
3156
+ setPendingButtonAttachments([]);
3157
+ setPendingToolRequests([]);
3158
+ setIsEmailModalOpen(false);
3159
+ setIsToolInfoModalOpen(false);
3160
+ setToolInfoData(null);
3161
+ setJustReset(true);
3162
+
3163
+ // Create a new AbortController for future requests
3164
+ setLastController(new AbortController());
3165
+
3166
+ // Clear button registry for new conversation
3167
+ buttonActionRegistry.current.clear();
3168
+
3169
+ // Force a small delay to ensure all state updates are processed
3170
+ setTimeout(() => {
3171
+ setJustReset(false);
3172
+ // Scroll to top after state updates
3173
+ const responseArea = responseAreaRef.current as any;
3174
+ if (responseArea) {
3175
+ responseArea.scrollTo({
3176
+ top: 0,
3177
+ behavior: "smooth",
3178
+ });
3179
+ }
3180
+ }, 100);
3181
+ }
3182
+ }}
3183
+ title={
3184
+ newConversationConfirm
3185
+ ? "Click again to confirm reset"
3186
+ : "Start a new conversation"
3187
+ }
3188
+ >
3189
+ {newConversationConfirm ? "Click to Confirm" : "New Conversation"}
3190
+ </button>
3191
+ )}
3192
+ {showSaveButton && (
3193
+ <button
3194
+ className="save-button"
3195
+ onClick={() => {
3196
+ saveHTMLToFile(
3197
+ convertHistoryToHTML(history),
3198
+ `conversation-${new Date().toISOString()}.html`
3199
+ );
3200
+ interactionClicked(lastCallId, "save");
3201
+ }}
3202
+ disabled={isDisabledDueToNoEmail()}
3203
+ >
3204
+ Save Conversation
3205
+ </button>
3206
+ )}
3207
+ {showEmailButton && (
3208
+ <button
3209
+ className="save-button"
3210
+ onClick={() => {
3211
+ if (isEmailAddress(emailInput)) {
3212
+ setEmailInputSet(true);
3213
+ setEmailValid(true);
3214
+ handleSendEmail(emailInput, emailInput);
3215
+ setEmailClickedButNoEmail(false);
3216
+ } else {
3217
+ setShowEmailPanel(true);
3218
+ setEmailValid(false);
3219
+ setEmailClickedButNoEmail(true);
3220
+ }
3221
+ }}
3222
+ disabled={isDisabledDueToNoEmail()}
3223
+ >
3224
+ {"Email Conversation" + (emailSent ? " ✓" : "")}
3225
+ </button>
3226
+ )}
3227
+
3228
+ {showCallToAction && (
3229
+ <button
3230
+ className="save-button"
3231
+ onClick={() => {
3232
+ if (isEmailAddress(emailInput)) {
3233
+ setEmailInputSet(true);
3234
+ setEmailValid(true);
3235
+ sendCallToActionEmail(emailInput);
3236
+ setCTAClickedButNoEmail(false);
3237
+ } else {
3238
+ setShowEmailPanel(true);
3239
+ setEmailValid(false);
3240
+ setCTAClickedButNoEmail(true);
3241
+ }
3242
+ }}
3243
+ disabled={isDisabledDueToNoEmail()}
3244
+ >
3245
+ {callToActionButtonText + (callToActionSent ? " ✓" : "")}
3246
+ </button>
3247
+ )}
3248
+ </div>
3249
+
3250
+ <EmailModal
3251
+ isOpen={isEmailModalOpen}
3252
+ defaultEmail={emailInput ?? ""}
3253
+ onClose={() => setIsEmailModalOpen(false)}
3254
+ onSend={handleSendEmail}
3255
+ />
3256
+
3257
+ <div className="input-container">
3258
+ <textarea
3259
+ ref={textareaRef}
3260
+ className={`chat-input${userResizedHeight ? ' user-resized' : ''}`}
3261
+ placeholder={placeholder}
3262
+ value={nextPrompt}
3263
+ onChange={(e) => {
3264
+ setNextPrompt(e.target.value);
3265
+ // Auto-resize on content change
3266
+ setTimeout(autoResizeTextarea, 0);
3267
+ }}
3268
+ onKeyDown={(e) => {
3269
+ if (e.key === "Enter") {
3270
+ if (e.shiftKey) {
3271
+ // Shift+Enter: Allow new line (default behavior)
3272
+ return;
3273
+ } else {
3274
+ // Enter alone: Send message
3275
+ if (nextPrompt.trim() !== "") {
3276
+ e.preventDefault();
3277
+ continueChat();
3278
+ }
3279
+ }
3280
+ }
3281
+ }}
3282
+ onDoubleClick={() => {
3283
+ // Double-click to reset to default auto-resize behavior
3284
+ if (userResizedHeight) {
3285
+ setUserResizedHeight(null);
3286
+ // Trigger auto-resize to return to content-based height
3287
+ setTimeout(autoResizeTextarea, 0);
3288
+ }
3289
+ }}
3290
+ disabled={isDisabledDueToNoEmail()}
3291
+ title={userResizedHeight ? "Double-click to reset to auto-resize" : "Drag bottom-right corner to manually resize"}
3292
+ ></textarea>
3293
+ <button
3294
+ className="send-button"
3295
+ onClick={() => continueChat()}
3296
+ disabled={isDisabledDueToNoEmail()}
3297
+ >
3298
+ {idle ? (
3299
+ <svg
3300
+ xmlns="http://www.w3.org/2000/svg"
3301
+ viewBox="0 0 24 24"
3302
+ strokeWidth="1"
3303
+ stroke="currentColor"
3304
+ fill="none"
3305
+ strokeLinecap="round"
3306
+ strokeLinejoin="round"
3307
+ className="icon-svg-large"
3308
+ >
3309
+ <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
3310
+ <path d="M10 14l11 -11"></path>
3311
+ <path d="M21 3l-6.5 18a.55 .55 0 0 1 -1 0l-3.5 -7l-7 -3.5a.55 .55 0 0 1 0 -1l18 -6.5"></path>
3312
+ </svg>
3313
+ ) : (
3314
+ <svg
3315
+ xmlns="http://www.w3.org/2000/svg"
3316
+ viewBox="0 0 24 24"
3317
+ strokeWidth="1"
3318
+ stroke="currentColor"
3319
+ fill="currentColor"
3320
+ className="icon-svg-large"
3321
+ >
3322
+ <path d="M8 8h16v16H8z" />
3323
+ </svg>
3324
+ )}
3325
+ </button>
3326
+ </div>
3327
+ {showPoweredBy && (
3328
+ <div
3329
+ className={`footer-container ${
3330
+ mcpServers && mcpServers.length > 0 ? "with-tools" : "no-tools"
3331
+ }`}
3332
+ >
3333
+ {/* Tool status indicator - only show when tools are configured */}
3334
+ {mcpServers && mcpServers.length > 0 && (
3335
+ <div className="footer-left">
3336
+ <div className="tool-status">
3337
+ <span
3338
+ className={`tool-status-dot ${
3339
+ toolsLoading
3340
+ ? "loading"
3341
+ : toolsFetchError
3342
+ ? "error"
3343
+ : "ready"
3344
+ }`}
3345
+ title={
3346
+ !toolsLoading && !toolsFetchError && toolList.length > 0
3347
+ ? toolList
3348
+ .map(
3349
+ (tool) =>
3350
+ `${tool.name}: ${
3351
+ tool.description || "No description"
3352
+ }`
3353
+ )
3354
+ .join("\n")
3355
+ : ""
3356
+ }
3357
+ ></span>
3358
+ <span
3359
+ className="tool-status-text"
3360
+ title={
3361
+ !toolsLoading && !toolsFetchError && toolList.length > 0
3362
+ ? toolList
3363
+ .map(
3364
+ (tool) =>
3365
+ `${tool.name}: ${
3366
+ tool.description || "No description"
3367
+ }`
3368
+ )
3369
+ .join("\n")
3370
+ : ""
3371
+ }
3372
+ >
3373
+ {toolsLoading
3374
+ ? "tools loading..."
3375
+ : toolsFetchError
3376
+ ? "tool fetch failed"
3377
+ : toolList.length > 0
3378
+ ? `${toolList.length} tools ready`
3379
+ : "no tools found"}
3380
+ </span>
3381
+ </div>
3382
+ </div>
3383
+ )}
3384
+
3385
+ <div
3386
+ className={`footer-right ${
3387
+ mcpServers && mcpServers.length > 0 ? "" : "footer-center"
3388
+ }`}
3389
+ >
3390
+ <div className="powered-by">
3391
+ <svg
3392
+ width="16"
3393
+ height="16"
3394
+ viewBox="0 0 72 72"
3395
+ fill="none"
3396
+ xmlns="http://www.w3.org/2000/svg"
3397
+ >
3398
+ <ellipse
3399
+ cx="14.0868"
3400
+ cy="59.2146"
3401
+ rx="7.8261"
3402
+ ry="7.7854"
3403
+ fill="#2487D8"
3404
+ />
3405
+ <ellipse
3406
+ cx="24.9013"
3407
+ cy="43.0776"
3408
+ rx="6.11858"
3409
+ ry="6.08676"
3410
+ fill="#2487D8"
3411
+ />
3412
+ <ellipse
3413
+ cx="45.391"
3414
+ cy="43.0776"
3415
+ rx="6.11858"
3416
+ ry="6.08676"
3417
+ fill="#2487D8"
3418
+ />
3419
+ <ellipse
3420
+ cx="65.8813"
3421
+ cy="43.0776"
3422
+ rx="6.11858"
3423
+ ry="6.08676"
3424
+ fill="#2487D8"
3425
+ />
3426
+ <ellipse
3427
+ cx="13.9444"
3428
+ cy="30.4795"
3429
+ rx="4.83795"
3430
+ ry="4.81279"
3431
+ fill="#2487D8"
3432
+ />
3433
+ <ellipse
3434
+ cx="34.7193"
3435
+ cy="30.4795"
3436
+ rx="4.83795"
3437
+ ry="4.81279"
3438
+ fill="#2487D8"
3439
+ />
3440
+ <ellipse
3441
+ cx="55.4942"
3442
+ cy="30.4795"
3443
+ rx="4.83795"
3444
+ ry="4.81279"
3445
+ fill="#2487D8"
3446
+ />
3447
+ <ellipse
3448
+ cx="3.27273"
3449
+ cy="20.4293"
3450
+ rx="3.27273"
3451
+ ry="3.25571"
3452
+ fill="#2487D8"
3453
+ />
3454
+ <ellipse
3455
+ cx="24.9011"
3456
+ cy="20.4293"
3457
+ rx="3.27273"
3458
+ ry="3.25571"
3459
+ fill="#2487D8"
3460
+ />
3461
+ <ellipse
3462
+ cx="45.3914"
3463
+ cy="20.4293"
3464
+ rx="3.27273"
3465
+ ry="3.25571"
3466
+ fill="#2487D8"
3467
+ />
3468
+ <ellipse
3469
+ cx="12.2373"
3470
+ cy="13.4931"
3471
+ rx="1.70751"
3472
+ ry="1.69863"
3473
+ fill="#2487D8"
3474
+ />
3475
+ <ellipse
3476
+ cx="33.0122"
3477
+ cy="13.4931"
3478
+ rx="1.70751"
3479
+ ry="1.69863"
3480
+ fill="#2487D8"
3481
+ />
3482
+ <ellipse
3483
+ cx="53.5019"
3484
+ cy="13.4931"
3485
+ rx="1.70751"
3486
+ ry="1.69863"
3487
+ fill="#2487D8"
3488
+ />
3489
+ <ellipse
3490
+ cx="19.3517"
3491
+ cy="6.13242"
3492
+ rx="1.13834"
3493
+ ry="1.13242"
3494
+ fill="#2487D8"
3495
+ />
3496
+ <ellipse
3497
+ cx="40.1266"
3498
+ cy="6.13242"
3499
+ rx="1.13834"
3500
+ ry="1.13242"
3501
+ fill="#2487D8"
3502
+ />
3503
+ <ellipse
3504
+ cx="60.901"
3505
+ cy="6.13242"
3506
+ rx="1.13834"
3507
+ ry="1.13242"
3508
+ fill="#2487D8"
3509
+ />
3510
+ <ellipse
3511
+ cx="34.8617"
3512
+ cy="59.2146"
3513
+ rx="7.8261"
3514
+ ry="7.7854"
3515
+ fill="#2487D8"
3516
+ />
3517
+ <ellipse
3518
+ cx="55.6366"
3519
+ cy="59.2146"
3520
+ rx="7.8261"
3521
+ ry="7.7854"
3522
+ fill="#ED7D31"
3523
+ />
3524
+ </svg>{" "}
3525
+ &nbsp;&nbsp;brought to you by&nbsp;
3526
+ <a
3527
+ href="https://llmasaservice.io"
3528
+ target="_blank"
3529
+ rel="noopener noreferrer"
3530
+ >
3531
+ llmasaservice.io
3532
+ </a>
3533
+ </div>
3534
+ </div>
3535
+ </div>
3536
+ )}
3537
+ </div>
3538
+ <div ref={bottomPanelRef} />
3539
+ {/* Modal with iframe */}
3540
+ {iframeUrl && (
3541
+ <div
3542
+ style={{
3543
+ position: "fixed",
3544
+ top: 0,
3545
+ left: 0,
3546
+ width: "100vw",
3547
+ height: "100vh",
3548
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
3549
+ display: "flex",
3550
+ justifyContent: "center",
3551
+ alignItems: "center",
3552
+ zIndex: 9999,
3553
+ }}
3554
+ >
3555
+ <div style={{ position: "relative", width: "95vw", height: "95vh" }}>
3556
+ <button
3557
+ onClick={() => setIframeUrl(null)}
3558
+ style={{
3559
+ position: "absolute",
3560
+ top: 10,
3561
+ right: 10,
3562
+ zIndex: 10000,
3563
+ background: "#fff",
3564
+ border: "none",
3565
+ padding: "5px 5px",
3566
+ cursor: "pointer",
3567
+ }}
3568
+ >
3569
+ Close
3570
+ </button>
3571
+ <iframe
3572
+ src={iframeUrl}
3573
+ title="Dynamic Iframe"
3574
+ style={{ width: "100%", height: "100%", border: "none" }}
3575
+ />
3576
+ </div>
3577
+ </div>
3578
+ )}
3579
+ </>
3580
+ );
3581
+ };
3582
+
3583
+ export default ChatPanel;