ChatInterface.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import React, { useState, useRef, useEffect } from 'react';
  2. import { Send, Loader2, Paperclip, X, Search, Database } from 'lucide-react';
  3. import { useLanguage } from '../contexts/LanguageContext';
  4. import {
  5. AppSettings,
  6. KnowledgeFile,
  7. ModelConfig,
  8. Message,
  9. Role,
  10. KnowledgeGroup
  11. } from '../types';
  12. import ChatMessage from './ChatMessage'; // Assuming ChatMessage is a default export based on original code
  13. import SearchResultsPanel from './SearchResultsPanel';
  14. import { chatService, ChatMessage as ChatMsg, ChatSource } from '../services/chatService';
  15. import { generateUUID } from '../utils/uuid';
  16. interface ChatInterfaceProps {
  17. files: KnowledgeFile[];
  18. settings: AppSettings;
  19. models: ModelConfig[];
  20. groups: KnowledgeGroup[];
  21. selectedGroups: string[];
  22. onGroupSelectionChange?: (groupIds: string[]) => void;
  23. onOpenGroupSelection?: () => void; // New prop
  24. selectedFiles?: string[];
  25. onClearFileSelection?: () => void;
  26. onMobileUploadClick: () => void;
  27. currentHistoryId?: string;
  28. historyMessages?: any[] | null;
  29. onHistoryMessagesLoaded?: () => void;
  30. onPreviewSource?: (source: ChatSource) => void;
  31. onOpenFile?: (source: ChatSource) => void;
  32. onHistoryIdCreated?: (historyId: string) => void;
  33. }
  34. const ChatInterface: React.FC<ChatInterfaceProps> = ({
  35. files,
  36. settings,
  37. models,
  38. groups,
  39. selectedGroups,
  40. onGroupSelectionChange,
  41. onOpenGroupSelection,
  42. selectedFiles,
  43. onClearFileSelection,
  44. onMobileUploadClick,
  45. currentHistoryId,
  46. historyMessages,
  47. onHistoryMessagesLoaded,
  48. onPreviewSource,
  49. onOpenFile,
  50. onHistoryIdCreated
  51. }) => {
  52. const { t, language } = useLanguage();
  53. const [messages, setMessages] = useState<Message[]>([]);
  54. const [input, setInput] = useState('');
  55. const [isLoading, setIsLoading] = useState(false);
  56. const [sources, setSources] = useState<ChatSource[]>([]);
  57. const [showSources, setShowSources] = useState(false);
  58. const messagesEndRef = useRef<HTMLDivElement>(null);
  59. const inputRef = useRef<HTMLTextAreaElement>(null);
  60. const lastSubmitTime = useRef<number>(0);
  61. // Debug logging
  62. // console.log('ChatInterface Render:', {
  63. // selectedFilesCount: selectedFiles?.length,
  64. // totalFilesCount: files.length,
  65. // selectedFiles: selectedFiles,
  66. // matchedFiles: files.filter(f => selectedFiles?.includes(f.id)).map(f => f.name)
  67. // });
  68. // Handle loading of history messages
  69. // Handle loading of history messages
  70. useEffect(() => {
  71. if (historyMessages && historyMessages.length > 0) {
  72. const convertedMessages: Message[] = historyMessages.map(msg => ({
  73. id: msg.id,
  74. role: msg.role === 'user' ? Role.USER : Role.MODEL,
  75. text: msg.content,
  76. timestamp: new Date(msg.createdAt).getTime(),
  77. sources: msg.sources // Attach sources to message
  78. }));
  79. setMessages(convertedMessages);
  80. // Notify parent component that history messages have been loaded
  81. onHistoryMessagesLoaded?.();
  82. }
  83. }, [historyMessages, onHistoryMessagesLoaded]);
  84. useEffect(() => {
  85. const welcomeText = t('welcomeMessage');
  86. setMessages((prevMessages) => {
  87. if (prevMessages.length === 0) {
  88. return [
  89. {
  90. id: 'welcome',
  91. role: Role.MODEL,
  92. text: welcomeText,
  93. timestamp: Date.now(),
  94. },
  95. ];
  96. }
  97. const hasWelcome = prevMessages.some(m => m.id === 'welcome');
  98. if (hasWelcome) {
  99. return prevMessages.map(m =>
  100. m.id === 'welcome'
  101. ? { ...m, text: welcomeText }
  102. : m
  103. );
  104. }
  105. return prevMessages;
  106. });
  107. }, [t]);
  108. const scrollToBottom = () => {
  109. messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  110. };
  111. useEffect(() => {
  112. scrollToBottom();
  113. }, [messages]);
  114. const handleSend = async () => {
  115. if (!input.trim() || isLoading) return;
  116. // Debounce mechanism: prevent duplicate submissions within 500ms
  117. const now = Date.now();
  118. if (now - lastSubmitTime.current < 500) {
  119. console.log('Preventing duplicate submission');
  120. return;
  121. }
  122. lastSubmitTime.current = now;
  123. const userText = input.trim();
  124. // Instantly clear input field and reset height to prevent duplicate submission
  125. setInput('');
  126. if (inputRef.current) {
  127. inputRef.current.style.height = 'auto';
  128. inputRef.current.blur(); // Remove focus
  129. }
  130. // Resolve Model Config
  131. const selectedModel = models.find(m => m.id === settings.selectedLLMId && m.type === 'llm');
  132. if (!selectedModel) {
  133. const errorMsg: Message = {
  134. id: generateUUID(),
  135. role: Role.MODEL,
  136. text: `${t('errorNoModel')} - LLM ID: ${settings.selectedLLMId}, Models: ${models.length} `,
  137. timestamp: Date.now(),
  138. isError: true,
  139. };
  140. setMessages(prev => [...prev, errorMsg]);
  141. return;
  142. }
  143. const newMessage: Message = {
  144. id: generateUUID(),
  145. role: Role.USER,
  146. text: userText,
  147. timestamp: Date.now(),
  148. };
  149. setIsLoading(true);
  150. setMessages((prev) => [...prev, newMessage]);
  151. const authToken = localStorage.getItem('authToken');
  152. if (!authToken) {
  153. const errorMsg: Message = {
  154. id: generateUUID(),
  155. role: Role.MODEL,
  156. text: t('needLogin'),
  157. timestamp: Date.now(),
  158. isError: true,
  159. };
  160. setMessages(prev => [...prev, errorMsg]);
  161. setIsLoading(false);
  162. return;
  163. }
  164. try {
  165. const history: ChatMsg[] = messages
  166. .filter(m => m.id !== 'welcome')
  167. .map(m => ({
  168. role: m.role === Role.USER ? 'user' : 'assistant',
  169. content: m.text,
  170. }));
  171. const botMessageId = generateUUID();
  172. let botContent = '';
  173. // Add initial bot message
  174. const botMessage: Message = {
  175. id: botMessageId,
  176. role: Role.MODEL,
  177. text: '',
  178. timestamp: Date.now(),
  179. };
  180. setMessages(prev => [...prev, botMessage]);
  181. const stream = chatService.streamChat(
  182. userText,
  183. history,
  184. authToken,
  185. language,
  186. settings.selectedEmbeddingId,
  187. settings.selectedLLMId, // Pass selected LLM ID
  188. selectedGroups.length > 0 ? selectedGroups : undefined, // Pass group filter
  189. selectedFiles?.length > 0 ? selectedFiles : undefined, // Pass file filter
  190. currentHistoryId, // Pass history ID
  191. settings.enableRerank, // Pass Rerank switch
  192. settings.selectedRerankId, // Pass Rerank model ID
  193. settings.temperature, // Pass temperature parameter
  194. settings.maxTokens, // Pass max tokens
  195. settings.topK, // Pass Top-K parameter
  196. settings.similarityThreshold, // Pass similarity threshold
  197. settings.rerankSimilarityThreshold, // Pass Rerank threshold
  198. settings.enableQueryExpansion, // Pass query expansion
  199. settings.enableHyDE // Pass HyDE
  200. );
  201. for await (const chunk of stream) {
  202. if (chunk.type === 'content') {
  203. botContent += chunk.data;
  204. setMessages(prev =>
  205. prev.map(msg =>
  206. msg.id === botMessageId
  207. ? { ...msg, text: botContent }
  208. : msg
  209. )
  210. );
  211. } else if (chunk.type === 'sources') {
  212. // Attach sources to the current bot message
  213. setMessages(prev =>
  214. prev.map(msg =>
  215. msg.id === botMessageId
  216. ? { ...msg, sources: chunk.data }
  217. : msg
  218. )
  219. );
  220. } else if (chunk.type === 'historyId') {
  221. onHistoryIdCreated?.(chunk.data);
  222. } else if (chunk.type === 'error') {
  223. setMessages(prev =>
  224. prev.map(msg =>
  225. msg.id === botMessageId
  226. ? { ...msg, text: t('errorMessage').replace('$1', chunk.data), isError: true }
  227. : msg
  228. )
  229. );
  230. break;
  231. }
  232. }
  233. } catch (error: any) {
  234. console.error('Chat error:', error);
  235. let errorText = t('errorGeneric');
  236. if (error.message === "API_KEY_MISSING") errorText = t('apiError');
  237. else if (error.message.includes("OpenAI API Error")) errorText = `OpenAI Error: ${error.message} `;
  238. else if (error.message === "GEMINI_API_ERROR") errorText = t('geminiError');
  239. else if (error.message) errorText = `Error: ${error.message} `;
  240. const errorMessage: Message = {
  241. id: generateUUID(),
  242. role: Role.MODEL,
  243. text: errorText,
  244. timestamp: Date.now(),
  245. isError: true,
  246. };
  247. setMessages((prev) => [...prev, errorMessage]);
  248. } finally {
  249. setIsLoading(false);
  250. lastSubmitTime.current = 0;
  251. }
  252. };
  253. const handleKeyDown = (e: React.KeyboardEvent) => {
  254. if (e.key === 'Enter' && !e.shiftKey) {
  255. e.preventDefault();
  256. if (!isLoading && input.trim()) {
  257. handleSend();
  258. }
  259. }
  260. };
  261. const handleInputResize = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  262. setInput(e.target.value);
  263. e.target.style.height = 'auto';
  264. e.target.style.height = `${Math.min(e.target.scrollHeight, 200)} px`;
  265. };
  266. return (
  267. <div className="flex flex-col h-full bg-transparent relative overflow-hidden">
  268. <div className="flex-1 overflow-y-auto px-4 md:px-8 pt-6 pb-32 space-y-8 scrollbar-hide">
  269. {messages.map((msg) => (
  270. <ChatMessage
  271. key={msg.id}
  272. message={msg}
  273. onPreviewSource={onPreviewSource}
  274. onOpenFile={onOpenFile}
  275. />
  276. ))}
  277. {isLoading && (
  278. <div className="flex justify-start animate-in fade-in slide-in-from-left-2 duration-300">
  279. <div className="flex flex-row gap-4 items-start translate-x-1">
  280. <div className="w-9 h-9 rounded-xl bg-white/80 backdrop-blur-sm border border-slate-200/50 flex items-center justify-center shadow-sm">
  281. <Loader2 className="w-4 h-4 text-indigo-600 animate-spin" />
  282. </div>
  283. <div className="bg-white/80 backdrop-blur-md border border-white/40 px-5 py-3.5 rounded-2xl rounded-tl-none shadow-sm flex items-center">
  284. <div className="flex items-center gap-2">
  285. <div className="flex gap-1">
  286. <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]"></span>
  287. <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]"></span>
  288. <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce"></span>
  289. </div>
  290. <span className="text-sm font-medium text-slate-500 ml-2 tracking-wide uppercase text-[10px]">{t('analyzing')}</span>
  291. </div>
  292. </div>
  293. </div>
  294. </div>
  295. )}
  296. <div ref={messagesEndRef} />
  297. </div>
  298. <div className="absolute bottom-6 left-0 right-0 px-4 md:px-8 pointer-events-none">
  299. <div className="max-w-4xl mx-auto pointer-events-auto">
  300. {((selectedFiles && selectedFiles.length > 0) || true) && (
  301. <div className="mb-3 flex flex-wrap gap-2 animate-in slide-in-from-bottom-2 duration-300">
  302. {/* Group Selection Button */}
  303. <button
  304. type="button"
  305. onClick={onOpenGroupSelection}
  306. className={`flex items-center gap-2 px-3.5 py-1.5 rounded-full text-xs font-semibold transition-all border shadow-sm ${selectedGroups.length > 0
  307. ? 'bg-blue-600 text-white border-blue-500 hover:bg-blue-700'
  308. : 'bg-white/90 backdrop-blur-md text-slate-600 border-slate-200/60 hover:bg-white'
  309. }`}
  310. title={t('selectKnowledgeGroup')}
  311. >
  312. <Database size={13} className={selectedGroups.length > 0 ? "text-blue-100" : "text-blue-500"} />
  313. <span className="truncate max-w-[150px]">
  314. {selectedGroups.length === 0
  315. ? t('allKnowledgeGroups')
  316. : selectedGroups.length <= 1
  317. ? (groups.find(g => g.id === selectedGroups[0])?.name || t('unknownGroup'))
  318. : t('selectedGroupsCount').replace('$1', selectedGroups.length.toString())}
  319. </span>
  320. </button>
  321. {files.filter(f => selectedFiles?.includes(f.id)).map(file => (
  322. <div key={file.id} className="flex items-center gap-1.5 bg-indigo-50 text-indigo-700 px-3 py-1.5 rounded-full text-xs font-semibold border border-indigo-100 shadow-sm animate-in zoom-in-95">
  323. <span className="truncate max-w-[150px]">{file.title || file.name}</span>
  324. <button
  325. onClick={onClearFileSelection}
  326. className="hover:bg-indigo-200/50 rounded-full p-0.5 transition-colors"
  327. >
  328. <X size={12} />
  329. </button>
  330. </div>
  331. ))}
  332. </div>
  333. )}
  334. <div className="bg-white rounded-2xl border border-slate-200 shadow-sm flex items-end p-2.5 transition-all duration-300 focus-within:ring-2 focus-within:ring-blue-500/20 focus-within:border-blue-400 group/input">
  335. <button
  336. onClick={onMobileUploadClick}
  337. className="md:hidden p-3 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-colors"
  338. >
  339. <Paperclip className="w-5 h-5" />
  340. </button>
  341. <textarea
  342. ref={inputRef}
  343. value={input}
  344. onChange={handleInputResize}
  345. onKeyDown={handleKeyDown}
  346. placeholder={files.length > 0 ? t('placeholderWithFiles') : t('placeholderEmpty')}
  347. className="flex-1 max-h-[250px] min-h-[48px] bg-transparent border-none focus:ring-0 text-slate-800 placeholder:text-slate-400/80 resize-none py-3 px-4 text-[15px] leading-relaxed"
  348. rows={1}
  349. disabled={files.length === 0 && messages.length < 2 && false}
  350. />
  351. <button
  352. onClick={handleSend}
  353. disabled={!input.trim() || isLoading}
  354. className={`p-3 rounded-xl mb-0.5 ml-2 transition-all duration-300 ${input.trim() && !isLoading
  355. ? 'bg-gradient-to-br from-blue-600 to-indigo-600 text-white hover:shadow-lg hover:shadow-blue-500/30 transform hover:-translate-y-0.5 active:translate-y-0 active:scale-95'
  356. : 'bg-slate-100 text-slate-300 cursor-not-allowed'
  357. } `}
  358. type="button"
  359. >
  360. {isLoading ? (
  361. <Loader2 className="w-5 h-5 animate-spin" />
  362. ) : (
  363. <Send className="w-5 h-5" />
  364. )}
  365. </button>
  366. </div>
  367. <p className="text-center text-[10px] text-slate-400/80 mt-3 font-medium tracking-tight uppercase">
  368. {t('aiDisclaimer')}
  369. </p>
  370. </div>
  371. </div>
  372. </div>
  373. );
  374. };
  375. export default ChatInterface;