ChatInterface.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  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. }
  33. const ChatInterface: React.FC<ChatInterfaceProps> = ({
  34. files,
  35. settings,
  36. models,
  37. groups,
  38. selectedGroups,
  39. onGroupSelectionChange,
  40. onOpenGroupSelection,
  41. selectedFiles,
  42. onClearFileSelection,
  43. onMobileUploadClick,
  44. currentHistoryId,
  45. historyMessages,
  46. onHistoryMessagesLoaded,
  47. onPreviewSource,
  48. onOpenFile
  49. }) => {
  50. const { t, language } = useLanguage();
  51. const [messages, setMessages] = useState<Message[]>([]);
  52. const [input, setInput] = useState('');
  53. const [isLoading, setIsLoading] = useState(false);
  54. const [sources, setSources] = useState<ChatSource[]>([]);
  55. const [showSources, setShowSources] = useState(false);
  56. const messagesEndRef = useRef<HTMLDivElement>(null);
  57. const inputRef = useRef<HTMLTextAreaElement>(null);
  58. const lastSubmitTime = useRef<number>(0);
  59. // Debug logging
  60. // console.log('ChatInterface Render:', {
  61. // selectedFilesCount: selectedFiles?.length,
  62. // totalFilesCount: files.length,
  63. // selectedFiles: selectedFiles,
  64. // matchedFiles: files.filter(f => selectedFiles?.includes(f.id)).map(f => f.name)
  65. // });
  66. // 履歴メッセージの読み込みを処理
  67. // 履歴メッセージの読み込みを処理
  68. useEffect(() => {
  69. if (historyMessages && historyMessages.length > 0) {
  70. const convertedMessages: Message[] = historyMessages.map(msg => ({
  71. id: msg.id,
  72. role: msg.role === 'user' ? Role.USER : Role.MODEL,
  73. text: msg.content,
  74. timestamp: new Date(msg.createdAt).getTime(),
  75. sources: msg.sources // Attach sources to message
  76. }));
  77. setMessages(convertedMessages);
  78. // 履歴メッセージが読み込まれたことを親コンポーネントに通知
  79. onHistoryMessagesLoaded?.();
  80. }
  81. }, [historyMessages, onHistoryMessagesLoaded]);
  82. useEffect(() => {
  83. const welcomeText = t('welcomeMessage');
  84. setMessages((prevMessages) => {
  85. if (prevMessages.length === 0) {
  86. return [
  87. {
  88. id: 'welcome',
  89. role: Role.MODEL,
  90. text: welcomeText,
  91. timestamp: Date.now(),
  92. },
  93. ];
  94. }
  95. const hasWelcome = prevMessages.some(m => m.id === 'welcome');
  96. if (hasWelcome) {
  97. return prevMessages.map(m =>
  98. m.id === 'welcome'
  99. ? { ...m, text: welcomeText }
  100. : m
  101. );
  102. }
  103. return prevMessages;
  104. });
  105. }, [t]);
  106. const scrollToBottom = () => {
  107. messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  108. };
  109. useEffect(() => {
  110. scrollToBottom();
  111. }, [messages]);
  112. const handleSend = async () => {
  113. if (!input.trim() || isLoading) return;
  114. // デバウンス機構:500ms以内の重複送信を防止
  115. const now = Date.now();
  116. if (now - lastSubmitTime.current < 500) {
  117. console.log('Preventing duplicate submission');
  118. return;
  119. }
  120. lastSubmitTime.current = now;
  121. const userText = input.trim();
  122. // 入力欄を即座にクリアして高さをリセットし、重複送信を防止
  123. setInput('');
  124. if (inputRef.current) {
  125. inputRef.current.style.height = 'auto';
  126. inputRef.current.blur(); // 失去焦点
  127. }
  128. // Resolve Model Config
  129. const selectedModel = models.find(m => m.id === settings.selectedLLMId && m.type === 'llm');
  130. if (!selectedModel) {
  131. const errorMsg: Message = {
  132. id: generateUUID(),
  133. role: Role.MODEL,
  134. text: `${t('errorNoModel')} - LLM ID: ${settings.selectedLLMId}, Models: ${models.length} `,
  135. timestamp: Date.now(),
  136. isError: true,
  137. };
  138. setMessages(prev => [...prev, errorMsg]);
  139. return;
  140. }
  141. const newMessage: Message = {
  142. id: generateUUID(),
  143. role: Role.USER,
  144. text: userText,
  145. timestamp: Date.now(),
  146. };
  147. setIsLoading(true);
  148. setMessages((prev) => [...prev, newMessage]);
  149. const authToken = localStorage.getItem('authToken');
  150. if (!authToken) {
  151. const errorMsg: Message = {
  152. id: generateUUID(),
  153. role: Role.MODEL,
  154. text: t('needLogin'),
  155. timestamp: Date.now(),
  156. isError: true,
  157. };
  158. setMessages(prev => [...prev, errorMsg]);
  159. setIsLoading(false);
  160. return;
  161. }
  162. try {
  163. const history: ChatMsg[] = messages
  164. .filter(m => m.id !== 'welcome')
  165. .map(m => ({
  166. role: m.role === Role.USER ? 'user' : 'assistant',
  167. content: m.text,
  168. }));
  169. const botMessageId = generateUUID();
  170. let botContent = '';
  171. // 初期ボットメッセージを追加
  172. const botMessage: Message = {
  173. id: botMessageId,
  174. role: Role.MODEL,
  175. text: '',
  176. timestamp: Date.now(),
  177. };
  178. setMessages(prev => [...prev, botMessage]);
  179. const stream = chatService.streamChat(
  180. userText,
  181. history,
  182. authToken,
  183. language,
  184. settings.selectedEmbeddingId,
  185. settings.selectedLLMId, // Pass selected LLM ID
  186. selectedGroups.length > 0 ? selectedGroups : undefined, // グループフィルタを渡す
  187. selectedFiles?.length > 0 ? selectedFiles : undefined, // ファイルフィルタを渡す
  188. currentHistoryId, // 履歴IDを渡す
  189. settings.enableRerank, // Rerankスイッチを渡す
  190. settings.selectedRerankId, // RerankモデルIDを渡す
  191. settings.temperature, // 温度パラメータを渡す
  192. settings.maxTokens, // 最大トークン数を渡す
  193. settings.topK, // Top-Kパラメータを渡す
  194. settings.similarityThreshold, // 類似度しきい値を渡す
  195. settings.enableQueryExpansion,
  196. settings.enableHyDE,
  197. settings.scoreThreshold
  198. );
  199. for await (const chunk of stream) {
  200. if (chunk.type === 'content') {
  201. botContent += chunk.data;
  202. setMessages(prev =>
  203. prev.map(msg =>
  204. msg.id === botMessageId
  205. ? { ...msg, text: botContent }
  206. : msg
  207. )
  208. );
  209. } else if (chunk.type === 'sources') {
  210. // Attach sources to the current bot message
  211. setMessages(prev =>
  212. prev.map(msg =>
  213. msg.id === botMessageId
  214. ? { ...msg, sources: chunk.data }
  215. : msg
  216. )
  217. );
  218. } else if (chunk.type === 'error') {
  219. setMessages(prev =>
  220. prev.map(msg =>
  221. msg.id === botMessageId
  222. ? { ...msg, text: t('errorMessage').replace('$1', chunk.data), isError: true }
  223. : msg
  224. )
  225. );
  226. break;
  227. }
  228. }
  229. } catch (error: any) {
  230. console.error('Chat error:', error);
  231. let errorText = t('errorGeneric');
  232. if (error.message === "API_KEY_MISSING") errorText = t('apiError');
  233. else if (error.message.includes("OpenAI API Error")) errorText = `OpenAI Error: ${error.message} `;
  234. else if (error.message === "GEMINI_API_ERROR") errorText = t('geminiError');
  235. else if (error.message) errorText = `Error: ${error.message} `;
  236. const errorMessage: Message = {
  237. id: generateUUID(),
  238. role: Role.MODEL,
  239. text: errorText,
  240. timestamp: Date.now(),
  241. isError: true,
  242. };
  243. setMessages((prev) => [...prev, errorMessage]);
  244. } finally {
  245. setIsLoading(false);
  246. lastSubmitTime.current = 0;
  247. }
  248. };
  249. const handleKeyDown = (e: React.KeyboardEvent) => {
  250. if (e.key === 'Enter' && !e.shiftKey) {
  251. e.preventDefault();
  252. if (!isLoading && input.trim()) {
  253. handleSend();
  254. }
  255. }
  256. };
  257. const handleInputResize = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  258. setInput(e.target.value);
  259. e.target.style.height = 'auto';
  260. e.target.style.height = `${Math.min(e.target.scrollHeight, 200)} px`;
  261. };
  262. return (
  263. <div className="flex flex-col h-full bg-slate-50 relative overflow-hidden">
  264. <div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6 scrollbar-hide">
  265. {messages.map((msg) => (
  266. <ChatMessage
  267. key={msg.id}
  268. message={msg}
  269. onPreviewSource={onPreviewSource}
  270. onOpenFile={onOpenFile}
  271. />
  272. ))}
  273. {isLoading && (
  274. <div className="flex justify-start animate-in fade-in duration-300">
  275. <div className="flex flex-row gap-3">
  276. <div className="w-8 h-8 rounded-full bg-white border border-slate-200 flex items-center justify-center shadow-sm">
  277. <Loader2 className="w-4 h-4 text-purple-600 animate-spin" />
  278. </div>
  279. <div className="bg-white border border-slate-100 px-4 py-3 rounded-2xl rounded-tl-none shadow-sm flex items-center">
  280. <span className="text-sm text-slate-500">{t('analyzing')}</span>
  281. </div>
  282. </div>
  283. </div>
  284. )}
  285. <div ref={messagesEndRef} />
  286. </div>
  287. <div className="p-4 bg-gradient-to-t from-slate-50 via-slate-50 to-transparent shrink-0">
  288. <div className="max-w-3xl mx-auto">
  289. {((selectedFiles && selectedFiles.length > 0) || true) && (
  290. <div className="mb-2 flex flex-wrap gap-2 animate-in slide-in-from-bottom-2">
  291. {/* Group Selection Button */}
  292. <button
  293. type="button"
  294. onClick={onOpenGroupSelection}
  295. className={`flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium transition-colors border ${selectedGroups.length > 0
  296. ? 'bg-slate-100 text-slate-700 border-slate-300 hover:bg-slate-200'
  297. : 'bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100'
  298. }`}
  299. title={t('selectKnowledgeGroup')}
  300. >
  301. <Database size={12} />
  302. <span className="truncate max-w-[150px]">
  303. {selectedGroups.length === 0
  304. ? t('allKnowledgeGroups')
  305. : selectedGroups.length <= 1
  306. ? (groups.find(g => g.id === selectedGroups[0])?.name || t('unknownGroup'))
  307. : t('selectedGroupsCount').replace('$1', selectedGroups.length.toString())}
  308. </span>
  309. </button>
  310. {files.filter(f => selectedFiles?.includes(f.id)).map(file => (
  311. <div key={file.id} className="flex items-center gap-1 bg-blue-100 text-blue-700 px-2 py-1 rounded-full text-xs font-medium border border-blue-200">
  312. <span className="truncate max-w-[150px]">{file.name}</span>
  313. <button
  314. onClick={onClearFileSelection}
  315. className="hover:bg-blue-200 rounded-full p-0.5 transition-colors"
  316. >
  317. <X size={12} />
  318. </button>
  319. </div>
  320. ))}
  321. </div>
  322. )}
  323. <div className="bg-white rounded-xl shadow-lg border border-slate-200 flex items-end p-2 transition-shadow focus-within:shadow-xl focus-within:border-blue-300">
  324. <button
  325. onClick={onMobileUploadClick}
  326. className="md:hidden p-3 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
  327. >
  328. <Paperclip className="w-5 h-5" />
  329. </button>
  330. <textarea
  331. ref={inputRef}
  332. value={input}
  333. onChange={handleInputResize}
  334. onKeyDown={handleKeyDown}
  335. placeholder={files.length > 0 ? t('placeholderWithFiles') : t('placeholderEmpty')}
  336. className="flex-1 max-h-40 min-h-[50px] bg-transparent border-none focus:ring-0 text-slate-700 placeholder:text-slate-400 resize-none py-3 px-3"
  337. rows={1}
  338. disabled={files.length === 0 && messages.length < 2 && false} // Disable logic might need review, relaxed for now
  339. />
  340. <button
  341. onClick={handleSend}
  342. disabled={!input.trim() || isLoading}
  343. className={`p - 3 rounded - lg mb - 0.5 ml - 2 transition - all ${input.trim() && !isLoading
  344. ? 'bg-blue-600 text-white hover:bg-blue-700 shadow-md transform hover:scale-105'
  345. : 'bg-slate-100 text-slate-400 cursor-not-allowed'
  346. } `}
  347. type="button"
  348. >
  349. {isLoading ? (
  350. <Loader2 className="w-5 h-5 animate-spin" />
  351. ) : (
  352. <Send className="w-5 h-5" />
  353. )}
  354. </button>
  355. </div>
  356. <p className="text-center text-[10px] text-slate-400 mt-2">
  357. {t('aiDisclaimer')}
  358. </p>
  359. </div>
  360. </div>
  361. </div>
  362. );
  363. };
  364. export default ChatInterface;