@hef2024/llmasaservice-ui 0.20.0 → 0.20.1

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.
@@ -11,6 +11,7 @@ import React, {
11
11
  useRef,
12
12
  useState,
13
13
  } from 'react';
14
+ import ReactDOMServer from 'react-dom/server';
14
15
  import { LLMAsAServiceCustomer, useLLM } from 'llmasaservice-client';
15
16
  import ReactMarkdown from 'react-markdown';
16
17
  import remarkGfm from 'remark-gfm';
@@ -19,6 +20,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
19
20
  import materialDark from 'react-syntax-highlighter/dist/esm/styles/prism/material-dark.js';
20
21
  import materialLight from 'react-syntax-highlighter/dist/esm/styles/prism/material-light.js';
21
22
  import { Button, ScrollArea, Tooltip } from './components/ui';
23
+ import ToolInfoModal from './components/ui/ToolInfoModal';
22
24
  import './AIChatPanel.css';
23
25
 
24
26
  // ============================================================================
@@ -89,6 +91,29 @@ export interface AIChatPanelProps {
89
91
 
90
92
  // Callback when a new conversation is created via API
91
93
  onConversationCreated?: (conversationId: string) => void;
94
+
95
+ // UI Customization Props (from ChatPanel)
96
+ cssUrl?: string;
97
+ markdownClass?: string;
98
+ width?: string;
99
+ height?: string;
100
+ scrollToEnd?: boolean;
101
+ prismStyle?: any; // PrismStyle type from react-syntax-highlighter
102
+
103
+ // Email & Save Props
104
+ showSaveButton?: boolean;
105
+ showEmailButton?: boolean;
106
+ messages?: { role: "user" | "assistant"; content: string }[];
107
+
108
+ // Call-to-Action Props
109
+ showCallToAction?: boolean;
110
+ callToActionButtonText?: string;
111
+ callToActionEmailAddress?: string;
112
+ callToActionEmailSubject?: string;
113
+
114
+ // Customer Email Capture Props
115
+ customerEmailCaptureMode?: "HIDE" | "OPTIONAL" | "REQUIRED";
116
+ customerEmailCapturePlaceholder?: string;
92
117
  }
93
118
 
94
119
  /**
@@ -677,6 +702,25 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
677
702
  maxContextTokens = 8000,
678
703
  enableContextDetailView = false,
679
704
  onConversationCreated,
705
+ // UI Customization Props
706
+ cssUrl,
707
+ markdownClass,
708
+ width,
709
+ height,
710
+ scrollToEnd = false,
711
+ prismStyle,
712
+ // Email & Save Props
713
+ showSaveButton = true,
714
+ showEmailButton = true,
715
+ messages = [],
716
+ // Call-to-Action Props
717
+ showCallToAction = false,
718
+ callToActionButtonText = 'Submit',
719
+ callToActionEmailAddress = '',
720
+ callToActionEmailSubject = 'Agent CTA submitted',
721
+ // Customer Email Capture Props
722
+ customerEmailCaptureMode = 'HIDE',
723
+ customerEmailCapturePlaceholder = 'Please enter your email...',
680
724
  }) => {
681
725
  // ============================================================================
682
726
  // API URL
@@ -703,6 +747,40 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
703
747
  const [feedbackCallId, setFeedbackCallId] = useState<{ callId: string; type: 'up' | 'down' } | null>(null);
704
748
  const [error, setError] = useState<{ message: string; code?: string } | null>(null);
705
749
 
750
+ // Email & Save state
751
+ const [emailSent, setEmailSent] = useState(false);
752
+
753
+ // Tool Info Modal state
754
+ const [isToolInfoModalOpen, setIsToolInfoModalOpen] = useState(false);
755
+ const [toolInfoData, setToolInfoData] = useState<{ calls: any[]; responses: any[] } | null>(null);
756
+
757
+ // Call-to-Action state
758
+ const [callToActionSent, setCallToActionSent] = useState(false);
759
+ const [CTAClickedButNoEmail, setCTAClickedButNoEmail] = useState(false);
760
+
761
+ // Customer Email Capture state
762
+ const [emailInput, setEmailInput] = useState(customer?.customer_user_email ?? '');
763
+ const [emailInputSet, setEmailInputSet] = useState(false);
764
+ const [emailValid, setEmailValid] = useState(true);
765
+ const [showEmailPanel, setShowEmailPanel] = useState(customerEmailCaptureMode !== 'HIDE');
766
+ const [emailClickedButNoEmail, setEmailClickedButNoEmail] = useState(false);
767
+
768
+ // Tool Approval state (for MCP tools)
769
+ const [pendingToolRequests, setPendingToolRequests] = useState<any[]>([]);
770
+ const [sessionApprovedTools, setSessionApprovedTools] = useState<string[]>([]);
771
+ const [alwaysApprovedTools, setAlwaysApprovedTools] = useState<string[]>([]);
772
+
773
+ // Email capture mode effect - like ChatPanel
774
+ useEffect(() => {
775
+ setShowEmailPanel(customerEmailCaptureMode !== 'HIDE');
776
+
777
+ if (customerEmailCaptureMode === 'REQUIRED') {
778
+ if (!isEmailAddress(emailInput)) {
779
+ setEmailValid(false);
780
+ }
781
+ }
782
+ }, [customerEmailCaptureMode, emailInput]);
783
+
706
784
  // Refs
707
785
  const bottomRef = useRef<HTMLDivElement | null>(null);
708
786
  const responseAreaRef = useRef<HTMLDivElement | null>(null);
@@ -722,6 +800,8 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
722
800
  const latestHistoryRef = useRef<Record<string, HistoryEntry>>(initialHistory);
723
801
  // Track if we've sent the initial prompt (prevents loops)
724
802
  const initialPromptSentRef = useRef<boolean>(false);
803
+ // Track the last followOnPrompt to detect changes (for auto-submit trigger)
804
+ const lastFollowOnPromptRef = useRef<string>('');
725
805
 
726
806
  // Sync new entries from initialHistory into local history state
727
807
  // This allows parent components to inject messages (e.g., page-based agent suggestions)
@@ -796,9 +876,9 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
796
876
  // ============================================================================
797
877
  // Memoized Values
798
878
  // ============================================================================
799
- const prismStyle = useMemo(
800
- () => (theme === 'light' ? materialLight : materialDark),
801
- [theme]
879
+ const effectivePrismStyle = useMemo(
880
+ () => prismStyle ?? (theme === 'light' ? materialLight : materialDark),
881
+ [prismStyle, theme]
802
882
  );
803
883
 
804
884
  // Browser info for context (matches ChatPanel)
@@ -907,6 +987,215 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
907
987
  const currentAgentLabel = currentAgentInfo.label;
908
988
  const currentAgentAvatarUrl = currentAgentInfo.avatarUrl;
909
989
 
990
+ // ============================================================================
991
+ // Helper Functions
992
+ // ============================================================================
993
+
994
+ // Email validation helper
995
+ const isEmailAddress = (email: string): boolean => {
996
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
997
+ return emailRegex.test(email);
998
+ };
999
+
1000
+ // Convert conversation history to standalone HTML file
1001
+ // Convert markdown to HTML - like ChatPanel
1002
+ const convertMarkdownToHTML = (markdown: string): string => {
1003
+ const html = ReactDOMServer.renderToStaticMarkup(
1004
+ <div className={markdownClass}>
1005
+ <ReactMarkdown
1006
+ remarkPlugins={[remarkGfm]}
1007
+ rehypePlugins={[rehypeRaw]}
1008
+ >
1009
+ {markdown}
1010
+ </ReactMarkdown>
1011
+ </div>
1012
+ );
1013
+ return html;
1014
+ };
1015
+
1016
+ // Convert conversation history to HTML - like ChatPanel
1017
+ const convertHistoryToHTML = (history: Record<string, HistoryEntry>): string => {
1018
+ const stylesheet = `
1019
+ <style>
1020
+ .conversation-history {
1021
+ font-family: Arial, sans-serif;
1022
+ line-height: 1.5;
1023
+ }
1024
+ .history-entry {
1025
+ margin-bottom: 15px;
1026
+ }
1027
+ .prompt-container, .response-container {
1028
+ margin-bottom: 10px;
1029
+ }
1030
+ .prompt, .response {
1031
+ display: block;
1032
+ margin: 5px 0;
1033
+ padding: 10px;
1034
+ border-radius: 5px;
1035
+ max-width: 80%;
1036
+ }
1037
+ .prompt {
1038
+ background-color: #efefef;
1039
+ margin-left: 0;
1040
+ }
1041
+ .response {
1042
+ background-color: #f0fcfd;
1043
+ margin-left: 25px;
1044
+ }
1045
+ </style>
1046
+ `;
1047
+
1048
+ let html = `
1049
+ <html>
1050
+ <head>
1051
+ ${stylesheet}
1052
+ </head>
1053
+ <body>
1054
+ <h1>Conversation History (${new Date().toLocaleString()})</h1>
1055
+ <div class="conversation-history">
1056
+ `;
1057
+
1058
+ Object.entries(history).forEach(([prompt, response], index) => {
1059
+ if (hideInitialPrompt && index === 0) {
1060
+ html += `
1061
+ <div class="history-entry">
1062
+ <div class="response-container">
1063
+ <div class="response">${convertMarkdownToHTML(response.content)}</div>
1064
+ </div>
1065
+ </div>
1066
+ `;
1067
+ } else {
1068
+ html += `
1069
+ <div class="history-entry">
1070
+ <div class="prompt-container">
1071
+ <div class="prompt">${convertMarkdownToHTML(
1072
+ formatPromptForDisplay(prompt)
1073
+ )}</div>
1074
+ </div>
1075
+ <div class="response-container">
1076
+ <div class="response">${convertMarkdownToHTML(response.content)}</div>
1077
+ </div>
1078
+ </div>
1079
+ `;
1080
+ }
1081
+ });
1082
+
1083
+ html += `
1084
+ </div>
1085
+ </body>
1086
+ </html>
1087
+ `;
1088
+
1089
+ return html;
1090
+ };
1091
+
1092
+ // Save HTML to file - like ChatPanel
1093
+ const saveHTMLToFile = (html: string, filename: string) => {
1094
+ const blob = new Blob([html], { type: 'text/html' });
1095
+ const link = document.createElement('a');
1096
+ link.href = URL.createObjectURL(blob);
1097
+ link.download = filename;
1098
+ document.body.appendChild(link);
1099
+ link.click();
1100
+ document.body.removeChild(link);
1101
+ };
1102
+
1103
+ // Download conversation as HTML file
1104
+ const saveAsHTMLFile = useCallback(() => {
1105
+ saveHTMLToFile(
1106
+ convertHistoryToHTML(history),
1107
+ `conversation-${new Date().toISOString()}.html`
1108
+ );
1109
+ interactionClicked(lastCallId || '', 'save');
1110
+ }, [history, lastCallId]);
1111
+
1112
+ const handleSendEmail = (to: string, from: string) => {
1113
+ sendConversationsViaEmail(to, `Conversation History from ${title}`, from);
1114
+ interactionClicked(lastCallId || '', 'email', to);
1115
+ setEmailSent(true);
1116
+ };
1117
+
1118
+ const sendConversationsViaEmail = async (
1119
+ to: string,
1120
+ subject: string = `Conversation History from ${title}`,
1121
+ from: string = ''
1122
+ ) => {
1123
+ fetch(`${publicAPIUrl}/share/email`, {
1124
+ method: 'POST',
1125
+ headers: {
1126
+ 'Content-Type': 'application/json',
1127
+ },
1128
+ body: JSON.stringify({
1129
+ to: to,
1130
+ from: from,
1131
+ subject: subject,
1132
+ html: convertHistoryToHTML(history),
1133
+ project_id: project_id ?? '',
1134
+ customer: customer,
1135
+ history: history,
1136
+ title: title,
1137
+ }),
1138
+ });
1139
+
1140
+ await interactionClicked(lastCallId || '', 'email', from);
1141
+ };
1142
+
1143
+
1144
+ // Send CTA email
1145
+ const sendCallToActionEmail = useCallback(async (from: string) => {
1146
+ try {
1147
+ await fetch(`${publicAPIUrl}/share/email`, {
1148
+ method: 'POST',
1149
+ headers: {
1150
+ 'Content-Type': 'application/json',
1151
+ },
1152
+ body: JSON.stringify({
1153
+ to: callToActionEmailAddress,
1154
+ from: from,
1155
+ subject: `${callToActionEmailSubject} from ${from}`,
1156
+ html: convertHistoryToHTML(history),
1157
+ project_id: project_id ?? '',
1158
+ customer: customer,
1159
+ history: history,
1160
+ title: title,
1161
+ }),
1162
+ });
1163
+
1164
+ await interactionClicked(lastCallId || '', 'cta', from);
1165
+ setCallToActionSent(true);
1166
+ } catch (err) {
1167
+ console.error('[AIChatPanel] Failed to send CTA email:', err);
1168
+ }
1169
+ }, [history, title, project_id, customer, lastCallId, publicAPIUrl, callToActionEmailAddress, callToActionEmailSubject]);
1170
+
1171
+ // Check if button should be disabled due to email capture requirements
1172
+ const isDisabledDueToNoEmail = useCallback(() => {
1173
+ if (customerEmailCaptureMode === 'REQUIRED' && !emailInputSet) {
1174
+ return true;
1175
+ }
1176
+ return false;
1177
+ }, [customerEmailCaptureMode, emailInputSet]);
1178
+
1179
+ // Handle tool approval for MCP tools
1180
+ const handleToolApproval = useCallback((toolName: string, scope: 'once' | 'session' | 'always') => {
1181
+ if (scope === 'session' || scope === 'always') {
1182
+ setSessionApprovedTools((prev) => Array.from(new Set([...prev, toolName])));
1183
+ }
1184
+ if (scope === 'always') {
1185
+ setAlwaysApprovedTools((prev) => Array.from(new Set([...prev, toolName])));
1186
+ }
1187
+
1188
+ // Remove approved tool from pending list
1189
+ setPendingToolRequests((prev) => prev.filter((r) => r.toolName !== toolName));
1190
+
1191
+ console.log(`[AIChatPanel] Tool "${toolName}" approved with scope: ${scope}`);
1192
+ }, []);
1193
+
1194
+ // Get unique tool names from pending requests
1195
+ const getUniqueToolNames = useCallback(() => {
1196
+ return Array.from(new Set(pendingToolRequests.map((r) => r.toolName)));
1197
+ }, [pendingToolRequests]);
1198
+
910
1199
  // ============================================================================
911
1200
  // Callbacks
912
1201
  // ============================================================================
@@ -1226,11 +1515,6 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1226
1515
  fullPromptToSend = promptTemplate.replace('{{prompt}}', fullPromptToSend);
1227
1516
  }
1228
1517
 
1229
- // Add follow-on prompt
1230
- if (followOnPrompt) {
1231
- fullPromptToSend += `\n\n${followOnPrompt}`;
1232
- }
1233
-
1234
1518
  const newController = new AbortController();
1235
1519
  setLastController(newController);
1236
1520
 
@@ -1314,7 +1598,6 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1314
1598
  clearFollowOnQuestionsNextPrompt,
1315
1599
  history,
1316
1600
  promptTemplate,
1317
- followOnPrompt,
1318
1601
  send,
1319
1602
  service,
1320
1603
  ensureConversation,
@@ -1447,12 +1730,13 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1447
1730
  useEffect(() => {
1448
1731
  // Only auto-scroll if:
1449
1732
  // 1. We're actively streaming (!idle)
1450
- // 2. User hasn't manually scrolled up during this response
1733
+ // 2. User hasn't manually scrolled up during this response (or scrollToEnd prop is true)
1451
1734
  // 3. We have content to show (response exists)
1452
- if (!idle && !userHasScrolled && response) {
1735
+ const shouldAutoScroll = scrollToEnd || !userHasScrolled;
1736
+ if (!idle && shouldAutoScroll && response) {
1453
1737
  scrollToBottom();
1454
1738
  }
1455
- }, [response, scrollToBottom, idle, userHasScrolled]); // Removed history dependency
1739
+ }, [response, scrollToBottom, idle, userHasScrolled, scrollToEnd]); // Removed history dependency
1456
1740
 
1457
1741
  // Ref to track idle state for scroll handler (avoids stale closure)
1458
1742
  const idleRef = useRef(idle);
@@ -1548,6 +1832,15 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1548
1832
  }
1549
1833
  }, [initialPrompt, continueChat]);
1550
1834
 
1835
+ // Auto-send followOnPrompt when it changes (like ChatPanel)
1836
+ // This allows parent components to programmatically submit prompts to the current conversation
1837
+ useEffect(() => {
1838
+ if (followOnPrompt && followOnPrompt !== '' && followOnPrompt !== lastFollowOnPromptRef.current) {
1839
+ lastFollowOnPromptRef.current = followOnPrompt;
1840
+ continueChat(followOnPrompt);
1841
+ }
1842
+ }, [followOnPrompt, continueChat]);
1843
+
1551
1844
  // Monitor for errors from useLLM hook
1552
1845
  useEffect(() => {
1553
1846
  if (llmError && llmError.trim()) {
@@ -1594,6 +1887,50 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1594
1887
  }
1595
1888
  }, [llmError, lastKey, lastCallId]);
1596
1889
 
1890
+ // Dynamic CSS Injection
1891
+ useEffect(() => {
1892
+ // Clean up any previously added CSS from this component
1893
+ const existingLinks = document.querySelectorAll(
1894
+ 'link[data-source="ai-chat-panel"]'
1895
+ );
1896
+ existingLinks.forEach((link) => link.parentNode?.removeChild(link));
1897
+
1898
+ const existingStyles = document.querySelectorAll(
1899
+ 'style[data-source="ai-chat-panel"]'
1900
+ );
1901
+ existingStyles.forEach((style) => style.parentNode?.removeChild(style));
1902
+
1903
+ if (cssUrl) {
1904
+ if (cssUrl.startsWith('http://') || cssUrl.startsWith('https://')) {
1905
+ // If it's a URL, create a link element
1906
+ const link = document.createElement('link');
1907
+ link.href = cssUrl;
1908
+ link.rel = 'stylesheet';
1909
+ link.setAttribute('data-source', 'ai-chat-panel');
1910
+ document.head.appendChild(link);
1911
+ } else {
1912
+ // If it's a CSS string, create a style element
1913
+ const style = document.createElement('style');
1914
+ style.textContent = cssUrl;
1915
+ style.setAttribute('data-source', 'ai-chat-panel');
1916
+ document.head.appendChild(style);
1917
+ }
1918
+ }
1919
+
1920
+ // Clean up when component unmounts
1921
+ return () => {
1922
+ const links = document.querySelectorAll(
1923
+ 'link[data-source="ai-chat-panel"]'
1924
+ );
1925
+ links.forEach((link) => link.parentNode?.removeChild(link));
1926
+
1927
+ const styles = document.querySelectorAll(
1928
+ 'style[data-source="ai-chat-panel"]'
1929
+ );
1930
+ styles.forEach((style) => style.parentNode?.removeChild(style));
1931
+ };
1932
+ }, [cssUrl]);
1933
+
1597
1934
  // ============================================================================
1598
1935
  // Render Helpers
1599
1936
  // ============================================================================
@@ -1603,7 +1940,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1603
1940
  const match = /language-(\w+)/.exec(className || '');
1604
1941
  return !inline && match ? (
1605
1942
  <SyntaxHighlighter
1606
- style={prismStyle}
1943
+ style={effectivePrismStyle}
1607
1944
  language={match[1]}
1608
1945
  PreTag="div"
1609
1946
  {...props}
@@ -1615,7 +1952,7 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1615
1952
  {children}
1616
1953
  </code>
1617
1954
  );
1618
- }, [prismStyle]);
1955
+ }, [effectivePrismStyle]);
1619
1956
 
1620
1957
  // Agent suggestion card component for inline agent handoff
1621
1958
  const AgentSuggestionCard = React.memo(({ agentId, agentName, reason }: {
@@ -1824,7 +2161,13 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1824
2161
  const panelClasses = ['ai-chat-panel', theme === 'dark' ? 'dark-theme' : ''].filter(Boolean).join(' ');
1825
2162
 
1826
2163
  return (
1827
- <div className={panelClasses}>
2164
+ <div
2165
+ className={panelClasses}
2166
+ style={{
2167
+ ...(width && { width }),
2168
+ ...(height && { height })
2169
+ }}
2170
+ >
1828
2171
  {/* Title */}
1829
2172
  {title && <div className="ai-chat-panel__title">{title}</div>}
1830
2173
 
@@ -1858,9 +2201,23 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1858
2201
  {initialMessage && (
1859
2202
  <div className="ai-chat-message ai-chat-message--assistant">
1860
2203
  <div className="ai-chat-message__content">
1861
- <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
1862
- {initialMessage}
1863
- </ReactMarkdown>
2204
+ {markdownClass ? (
2205
+ <div className={markdownClass}>
2206
+ <ReactMarkdown
2207
+ remarkPlugins={[remarkGfm]}
2208
+ rehypePlugins={[rehypeRaw]}
2209
+ >
2210
+ {initialMessage}
2211
+ </ReactMarkdown>
2212
+ </div>
2213
+ ) : (
2214
+ <ReactMarkdown
2215
+ remarkPlugins={[remarkGfm]}
2216
+ rehypePlugins={[rehypeRaw]}
2217
+ >
2218
+ {initialMessage}
2219
+ </ReactMarkdown>
2220
+ )}
1864
2221
  </div>
1865
2222
  </div>
1866
2223
  )}
@@ -1895,13 +2252,25 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1895
2252
  {thinkingBlocks.length > 0 && renderThinkingBlocks()}
1896
2253
 
1897
2254
  {processedContent ? (
1898
- <ReactMarkdown
1899
- remarkPlugins={[remarkGfm]}
1900
- rehypePlugins={[rehypeRaw]}
1901
- components={markdownComponents}
1902
- >
1903
- {processedContent}
1904
- </ReactMarkdown>
2255
+ markdownClass ? (
2256
+ <div className={markdownClass}>
2257
+ <ReactMarkdown
2258
+ remarkPlugins={[remarkGfm]}
2259
+ rehypePlugins={[rehypeRaw]}
2260
+ components={markdownComponents}
2261
+ >
2262
+ {processedContent}
2263
+ </ReactMarkdown>
2264
+ </div>
2265
+ ) : (
2266
+ <ReactMarkdown
2267
+ remarkPlugins={[remarkGfm]}
2268
+ rehypePlugins={[rehypeRaw]}
2269
+ components={markdownComponents}
2270
+ >
2271
+ {processedContent}
2272
+ </ReactMarkdown>
2273
+ )
1905
2274
  ) : (
1906
2275
  <div className="ai-chat-loading">
1907
2276
  <span>Thinking</span>
@@ -1916,13 +2285,25 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1916
2285
  ) : (
1917
2286
  <>
1918
2287
  {isLastEntry && thinkingBlocks.length > 0 && renderThinkingBlocks()}
1919
- <ReactMarkdown
1920
- remarkPlugins={[remarkGfm]}
1921
- rehypePlugins={[rehypeRaw]}
1922
- components={markdownComponents}
1923
- >
1924
- {processedContent}
1925
- </ReactMarkdown>
2288
+ {markdownClass ? (
2289
+ <div className={markdownClass}>
2290
+ <ReactMarkdown
2291
+ remarkPlugins={[remarkGfm]}
2292
+ rehypePlugins={[rehypeRaw]}
2293
+ components={markdownComponents}
2294
+ >
2295
+ {processedContent}
2296
+ </ReactMarkdown>
2297
+ </div>
2298
+ ) : (
2299
+ <ReactMarkdown
2300
+ remarkPlugins={[remarkGfm]}
2301
+ rehypePlugins={[rehypeRaw]}
2302
+ components={markdownComponents}
2303
+ >
2304
+ {processedContent}
2305
+ </ReactMarkdown>
2306
+ )}
1926
2307
  </>
1927
2308
  )}
1928
2309
  </div>
@@ -1974,6 +2355,27 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
1974
2355
  </svg>
1975
2356
  )}
1976
2357
  </button>
2358
+
2359
+ {/* Tool Info Button - show if entry has tool data */}
2360
+ {(entry.toolCalls || entry.toolResponses) && (
2361
+ <button
2362
+ className="ai-chat-action-button"
2363
+ onClick={() => {
2364
+ setToolInfoData({
2365
+ calls: entry.toolCalls || [],
2366
+ responses: entry.toolResponses || [],
2367
+ });
2368
+ setIsToolInfoModalOpen(true);
2369
+ }}
2370
+ title="View tool information"
2371
+ >
2372
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="ai-chat-icon-sm">
2373
+ <circle cx="12" cy="12" r="10" />
2374
+ <line x1="12" x2="12" y1="16" y2="12" />
2375
+ <line x1="12" x2="12.01" y1="8" y2="8" />
2376
+ </svg>
2377
+ </button>
2378
+ )}
1977
2379
  </div>
1978
2380
  )}
1979
2381
  </div>
@@ -2001,6 +2403,109 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2001
2403
  <div ref={bottomRef} />
2002
2404
  </ScrollArea>
2003
2405
 
2406
+ {/* Tool Approval Panel */}
2407
+ {pendingToolRequests.length > 0 && (
2408
+ <div className="ai-chat-approve-tools-panel">
2409
+ <div className="ai-chat-approve-tools-header">
2410
+ Tool Approval Required
2411
+ </div>
2412
+ <div className="ai-chat-approve-tools-description">
2413
+ The AI wants to use the following tools:
2414
+ </div>
2415
+ {getUniqueToolNames().map((toolName) => (
2416
+ <div key={toolName} className="ai-chat-approve-tool-item">
2417
+ <div className="ai-chat-approve-tool-name">{toolName}</div>
2418
+ <div className="ai-chat-approve-tools-buttons">
2419
+ <Button
2420
+ size="sm"
2421
+ variant="outline"
2422
+ className="ai-chat-approve-tools-button"
2423
+ onClick={() => handleToolApproval(toolName, 'once')}
2424
+ >
2425
+ Once
2426
+ </Button>
2427
+ <Button
2428
+ size="sm"
2429
+ variant="outline"
2430
+ className="ai-chat-approve-tools-button"
2431
+ onClick={() => handleToolApproval(toolName, 'session')}
2432
+ >
2433
+ This Session
2434
+ </Button>
2435
+ <Button
2436
+ size="sm"
2437
+ variant="default"
2438
+ className="ai-chat-approve-tools-button"
2439
+ onClick={() => handleToolApproval(toolName, 'always')}
2440
+ >
2441
+ Always
2442
+ </Button>
2443
+ </div>
2444
+ </div>
2445
+ ))}
2446
+ </div>
2447
+ )}
2448
+
2449
+ {/* Button Container - Save, Email, CTA */}
2450
+ {(showSaveButton || showEmailButton || showCallToAction) && (
2451
+ <div className="ai-chat-button-container">
2452
+ {showSaveButton && (
2453
+ <Button
2454
+ variant="outline"
2455
+ size="sm"
2456
+ onClick={saveAsHTMLFile}
2457
+ disabled={Object.keys(history).length === 0}
2458
+ className="ai-chat-save-button"
2459
+ >
2460
+ 💾 Save
2461
+ </Button>
2462
+ )}
2463
+
2464
+ {showEmailButton && (
2465
+ <Button
2466
+ variant="outline"
2467
+ size="sm"
2468
+ onClick={() => {
2469
+ if (isEmailAddress(emailInput)) {
2470
+ setEmailInputSet(true);
2471
+ setEmailValid(true);
2472
+ handleSendEmail(emailInput, emailInput);
2473
+ setEmailClickedButNoEmail(false);
2474
+ } else {
2475
+ setShowEmailPanel(true);
2476
+ setEmailValid(false);
2477
+ setEmailClickedButNoEmail(true);
2478
+ }
2479
+ }}
2480
+ disabled={Object.keys(history).length === 0 || isDisabledDueToNoEmail()}
2481
+ className="ai-chat-email-button"
2482
+ >
2483
+ 📧 Email Conversation{emailSent ? ' ✓' : ''}
2484
+ </Button>
2485
+ )}
2486
+
2487
+ {showCallToAction && (
2488
+ <Button
2489
+ variant={callToActionSent ? 'outline' : 'default'}
2490
+ size="sm"
2491
+ onClick={() => {
2492
+ if (customerEmailCaptureMode !== 'HIDE' && !emailInputSet) {
2493
+ setCTAClickedButNoEmail(true);
2494
+ setTimeout(() => setCTAClickedButNoEmail(false), 3000);
2495
+ return;
2496
+ }
2497
+ const fromEmail = emailInput || customer?.customer_user_email || '';
2498
+ sendCallToActionEmail(fromEmail);
2499
+ }}
2500
+ disabled={callToActionSent || Object.keys(history).length === 0}
2501
+ className="ai-chat-cta-button"
2502
+ >
2503
+ {callToActionSent ? '✓ Submitted' : callToActionButtonText}
2504
+ </Button>
2505
+ )}
2506
+ </div>
2507
+ )}
2508
+
2004
2509
  {/* New Conversation Button */}
2005
2510
  {showNewConversationButton && (
2006
2511
  <div className="ai-chat-panel__new-conversation">
@@ -2015,6 +2520,103 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2015
2520
  </div>
2016
2521
  )}
2017
2522
 
2523
+ {/* Customer Email Capture Panel */}
2524
+ {showEmailPanel && (
2525
+ <>
2526
+ {!emailValid && (
2527
+ <div className="ai-chat-email-input-message">
2528
+ {isDisabledDueToNoEmail()
2529
+ ? "Let's get started - please enter your email"
2530
+ : CTAClickedButNoEmail || emailClickedButNoEmail
2531
+ ? 'Sure, we just need an email address to contact you'
2532
+ : 'Email address is invalid'}
2533
+ </div>
2534
+ )}
2535
+ <div className="ai-chat-email-input-container">
2536
+ <input
2537
+ type="email"
2538
+ name="email"
2539
+ id="email"
2540
+ className={
2541
+ emailValid
2542
+ ? emailInputSet
2543
+ ? 'ai-chat-email-input-set'
2544
+ : 'ai-chat-email-input'
2545
+ : 'ai-chat-email-input-invalid'
2546
+ }
2547
+ placeholder={customerEmailCapturePlaceholder}
2548
+ value={emailInput}
2549
+ onChange={(e) => {
2550
+ const newEmail = e.target.value;
2551
+ setEmailInput(newEmail);
2552
+ // Reset validation while typing
2553
+ if (!emailInputSet) {
2554
+ if (customerEmailCaptureMode === 'REQUIRED' && newEmail !== '') {
2555
+ setEmailValid(isEmailAddress(newEmail));
2556
+ } else {
2557
+ setEmailValid(true);
2558
+ }
2559
+ }
2560
+ }}
2561
+ onBlur={() => {
2562
+ // Auto-validate and set email when field loses focus
2563
+ if (emailInput && isEmailAddress(emailInput) && !emailInputSet) {
2564
+ setEmailInputSet(true);
2565
+ setEmailValid(true);
2566
+ interactionClicked('', 'emailcapture', emailInput);
2567
+
2568
+ // Handle pending actions
2569
+ if (CTAClickedButNoEmail) {
2570
+ sendCallToActionEmail(emailInput);
2571
+ setCTAClickedButNoEmail(false);
2572
+ }
2573
+ if (emailClickedButNoEmail) {
2574
+ handleSendEmail(emailInput, emailInput);
2575
+ setEmailClickedButNoEmail(false);
2576
+ }
2577
+ } else if (customerEmailCaptureMode === 'REQUIRED' && emailInput !== '') {
2578
+ setEmailValid(isEmailAddress(emailInput));
2579
+ }
2580
+ }}
2581
+ onKeyDown={(e) => {
2582
+ if (e.key === 'Enter') {
2583
+ if (isEmailAddress(emailInput)) {
2584
+ setEmailInputSet(true);
2585
+ setEmailValid(true);
2586
+ interactionClicked('', 'emailcapture', emailInput);
2587
+
2588
+ // Handle pending actions
2589
+ if (CTAClickedButNoEmail) {
2590
+ sendCallToActionEmail(emailInput);
2591
+ setCTAClickedButNoEmail(false);
2592
+ }
2593
+ if (emailClickedButNoEmail) {
2594
+ handleSendEmail(emailInput, emailInput);
2595
+ setEmailClickedButNoEmail(false);
2596
+ }
2597
+ } else {
2598
+ setEmailValid(false);
2599
+ }
2600
+ }
2601
+ }}
2602
+ disabled={false}
2603
+ />
2604
+ {emailInputSet && (
2605
+ <button
2606
+ className="ai-chat-email-edit-button"
2607
+ onClick={() => {
2608
+ setEmailInputSet(false);
2609
+ setEmailValid(true);
2610
+ }}
2611
+ title="Edit email"
2612
+ >
2613
+
2614
+ </button>
2615
+ )}
2616
+ </div>
2617
+ </>
2618
+ )}
2619
+
2018
2620
  {/* Input Area - Isolated component for performance */}
2019
2621
  <ChatInput
2020
2622
  placeholder={placeholder}
@@ -2064,6 +2666,13 @@ const AIChatPanel: React.FC<AIChatPanelProps> = ({
2064
2666
  </div>
2065
2667
  </div>
2066
2668
  )}
2669
+
2670
+ {/* Modals */}
2671
+ <ToolInfoModal
2672
+ isOpen={isToolInfoModalOpen}
2673
+ onClose={() => setIsToolInfoModalOpen(false)}
2674
+ data={toolInfoData}
2675
+ />
2067
2676
  </div>
2068
2677
  );
2069
2678
  };