ChatInterface.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  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, ChatStreamChunk, HistoryData, StatusData } 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. authToken?: string; // Add authToken prop
  34. }
  35. const ChatInterface: React.FC<ChatInterfaceProps> = ({
  36. files,
  37. settings,
  38. models,
  39. groups,
  40. selectedGroups,
  41. onGroupSelectionChange,
  42. onOpenGroupSelection,
  43. selectedFiles,
  44. onClearFileSelection,
  45. onMobileUploadClick,
  46. currentHistoryId,
  47. historyMessages,
  48. onHistoryMessagesLoaded,
  49. onPreviewSource,
  50. onOpenFile,
  51. onHistoryIdCreated,
  52. authToken: propAuthToken // Use prop
  53. }) => {
  54. const { t, language } = useLanguage();
  55. const [messages, setMessages] = useState<Message[]>([]);
  56. const [input, setInput] = useState('');
  57. const [isLoading, setIsLoading] = useState(false);
  58. const [sources, setSources] = useState<ChatSource[]>([]);
  59. const [showSources, setShowSources] = useState(false);
  60. const [thinking, setThinking] = useState<string>('');
  61. const [showThinking, setShowThinking] = useState(false);
  62. const [statusMessage, setStatusMessage] = useState<string>('');
  63. const messagesEndRef = useRef<HTMLDivElement>(null);
  64. const inputRef = useRef<HTMLTextAreaElement>(null);
  65. const lastSubmitTime = useRef<number>(0);
  66. // Debug logging
  67. // console.log('ChatInterface Render:', {
  68. // selectedFilesCount: selectedFiles?.length,
  69. // totalFilesCount: files.length,
  70. // selectedFiles: selectedFiles,
  71. // matchedFiles: files.filter(f => selectedFiles?.includes(f.id)).map(f => f.name)
  72. // });
  73. // 履歴メッセージの読み込みを処理
  74. // 履歴メッセージの読み込みを処理
  75. useEffect(() => {
  76. if (historyMessages && historyMessages.length > 0) {
  77. const convertedMessages: Message[] = historyMessages.map(msg => ({
  78. id: msg.id,
  79. role: msg.role === 'user' ? Role.USER : Role.MODEL,
  80. text: msg.content,
  81. timestamp: new Date(msg.createdAt).getTime(),
  82. sources: msg.sources // Attach sources to message
  83. }));
  84. setMessages(convertedMessages);
  85. // 履歴メッセージが読み込まれたことを親コンポーネントに通知
  86. onHistoryMessagesLoaded?.();
  87. }
  88. }, [historyMessages, onHistoryMessagesLoaded]);
  89. useEffect(() => {
  90. const welcomeText = t('welcomeMessage');
  91. setMessages((prevMessages) => {
  92. if (prevMessages.length === 0) {
  93. return [
  94. {
  95. id: 'welcome',
  96. role: Role.MODEL,
  97. text: welcomeText,
  98. timestamp: Date.now(),
  99. },
  100. ];
  101. }
  102. const hasWelcome = prevMessages.some(m => m.id === 'welcome');
  103. if (hasWelcome) {
  104. return prevMessages.map(m =>
  105. m.id === 'welcome'
  106. ? { ...m, text: welcomeText }
  107. : m
  108. );
  109. }
  110. return prevMessages;
  111. });
  112. }, [t]);
  113. const scrollToBottom = () => {
  114. messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  115. };
  116. useEffect(() => {
  117. scrollToBottom();
  118. }, [messages]);
  119. const handleSend = async () => {
  120. if (!input.trim() || isLoading) return;
  121. // デバウンス機構:500ms以内の重複送信を防止
  122. const now = Date.now();
  123. if (now - lastSubmitTime.current < 500) {
  124. console.log('Preventing duplicate submission');
  125. return;
  126. }
  127. lastSubmitTime.current = now;
  128. const userText = input.trim();
  129. // 入力欄を即座にクリアして高さをリセットし、重複送信を防止
  130. setInput('');
  131. if (inputRef.current) {
  132. inputRef.current.style.height = 'auto';
  133. inputRef.current.blur(); // フォーカスを外す
  134. }
  135. // Resolve Model Config
  136. const selectedModel = models.find(m => m.id === settings.selectedLLMId && m.type === 'llm');
  137. if (!selectedModel) {
  138. const errorMsg: Message = {
  139. id: generateUUID(),
  140. role: Role.MODEL,
  141. text: `${t('errorNoModel')} - LLM ID: ${settings.selectedLLMId}, Models: ${models.length} `,
  142. timestamp: Date.now(),
  143. isError: true,
  144. };
  145. setMessages(prev => [...prev, errorMsg]);
  146. return;
  147. }
  148. const newMessage: Message = {
  149. id: generateUUID(),
  150. role: Role.USER,
  151. text: userText,
  152. timestamp: Date.now(),
  153. };
  154. setIsLoading(true);
  155. setMessages((prev) => [...prev, newMessage]);
  156. // Use token from props if available, fallback to localStorage (with correct key)
  157. const finalToken = propAuthToken || localStorage.getItem('kb_api_key') || localStorage.getItem('authToken');
  158. if (!finalToken) {
  159. const errorMsg: Message = {
  160. id: generateUUID(),
  161. role: Role.MODEL,
  162. text: t('needLogin'),
  163. timestamp: Date.now(),
  164. isError: true,
  165. };
  166. setMessages(prev => [...prev, errorMsg]);
  167. setIsLoading(false);
  168. return;
  169. }
  170. try {
  171. const history: ChatMsg[] = messages
  172. .filter(m => m.id !== 'welcome')
  173. .map(m => ({
  174. role: m.role === Role.USER ? 'user' : 'assistant',
  175. content: m.text,
  176. }));
  177. const botMessageId = generateUUID();
  178. let botContent = '';
  179. let botThinking = '';
  180. // Reset thinking state for new message
  181. setThinking('');
  182. setShowThinking(false);
  183. setStatusMessage('');
  184. // 初期ボットメッセージを追加
  185. const botMessage: Message = {
  186. id: botMessageId,
  187. role: Role.MODEL,
  188. text: '',
  189. timestamp: Date.now(),
  190. };
  191. setMessages(prev => [...prev, botMessage]);
  192. const stream = chatService.streamChat(
  193. userText,
  194. history,
  195. finalToken,
  196. language,
  197. settings.selectedEmbeddingId,
  198. settings.selectedLLMId, // Pass selected LLM ID
  199. selectedGroups.length > 0 ? selectedGroups : undefined, // グループフィルタを渡す
  200. selectedFiles?.length > 0 ? selectedFiles : undefined, // ファイルフィルタを渡す
  201. currentHistoryId, // 履歴IDを渡す
  202. settings.enableRerank, // Rerankスイッチを渡す
  203. settings.selectedRerankId, // RerankモデルIDを渡す
  204. settings.temperature, // 温度パラメータを渡す
  205. settings.maxTokens, // 最大トークン数を渡す
  206. settings.topK, // Top-Kパラメータを渡す
  207. settings.similarityThreshold, // 類似度しきい値を渡す
  208. settings.rerankSimilarityThreshold, // Rerankしきい値を渡す
  209. settings.enableQueryExpansion, // クエリ拡張を渡す
  210. settings.enableHyDE // HyDEを渡す
  211. );
  212. for await (const chunk of stream) {
  213. switch (chunk.type) {
  214. case 'content':
  215. botContent += chunk.data;
  216. setMessages(prev =>
  217. prev.map(msg =>
  218. msg.id === botMessageId
  219. ? { ...msg, text: botContent }
  220. : msg
  221. )
  222. );
  223. break;
  224. case 'thinking':
  225. botThinking += chunk.data;
  226. setThinking(botThinking);
  227. setShowThinking(true);
  228. break;
  229. case 'sources':
  230. // Attach sources to the current bot message
  231. setMessages(prev =>
  232. prev.map(msg =>
  233. msg.id === botMessageId
  234. ? { ...msg, sources: chunk.data }
  235. : msg
  236. )
  237. );
  238. break;
  239. case 'historyId':
  240. onHistoryIdCreated?.(chunk.data);
  241. break;
  242. case 'status':
  243. const statusData = chunk.data as StatusData;
  244. if (statusData.stage === 'searching' || statusData.stage === 'generating') {
  245. setStatusMessage(statusData.message);
  246. }
  247. // Debug messages can be logged or shown in dev mode
  248. if (statusData.stage === 'debug') {
  249. console.log('[Chat Debug]', statusData.message);
  250. }
  251. break;
  252. case 'error':
  253. setMessages(prev =>
  254. prev.map(msg =>
  255. msg.id === botMessageId
  256. ? { ...msg, text: t('errorMessage').replace('$1', chunk.data), isError: true }
  257. : msg
  258. )
  259. );
  260. break;
  261. }
  262. }
  263. } catch (error: any) {
  264. console.error('Chat error:', error);
  265. let errorText = t('errorGeneric');
  266. if (error.message === "API_KEY_MISSING") errorText = t('apiError');
  267. else if (error.message.includes("OpenAI API Error")) errorText = `OpenAI Error: ${error.message} `;
  268. else if (error.message === "GEMINI_API_ERROR") errorText = t('geminiError');
  269. else if (error.message) errorText = `Error: ${error.message} `;
  270. const errorMessage: Message = {
  271. id: generateUUID(),
  272. role: Role.MODEL,
  273. text: errorText,
  274. timestamp: Date.now(),
  275. isError: true,
  276. };
  277. setMessages((prev) => [...prev, errorMessage]);
  278. } finally {
  279. setIsLoading(false);
  280. setStatusMessage('');
  281. setShowThinking(false);
  282. lastSubmitTime.current = 0;
  283. }
  284. };
  285. const handleKeyDown = (e: React.KeyboardEvent) => {
  286. if (e.key === 'Enter' && !e.shiftKey) {
  287. e.preventDefault();
  288. if (!isLoading && input.trim()) {
  289. handleSend();
  290. }
  291. }
  292. };
  293. const handleInputResize = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
  294. setInput(e.target.value);
  295. e.target.style.height = 'auto';
  296. e.target.style.height = `${Math.min(e.target.scrollHeight, 200)} px`;
  297. };
  298. return (
  299. <div className="flex flex-col h-full bg-transparent relative overflow-hidden">
  300. <div className="flex-1 overflow-y-auto px-4 md:px-8 pt-6 pb-32 space-y-8 scrollbar-hide">
  301. {messages.map((msg) => (
  302. <ChatMessage
  303. key={msg.id}
  304. message={msg}
  305. onPreviewSource={onPreviewSource}
  306. onOpenFile={onOpenFile}
  307. />
  308. ))}
  309. {/* Thinking Section */}
  310. {showThinking && thinking && (
  311. <div className="flex justify-start animate-in fade-in slide-in-from-left-2 duration-300">
  312. <div className="flex flex-row gap-4 items-start translate-x-1">
  313. <div className="w-9 h-9 rounded-xl bg-purple-100 backdrop-blur-sm border border-purple-200/50 flex items-center justify-center shadow-sm">
  314. <Loader2 className="w-4 h-4 text-purple-600 animate-spin" />
  315. </div>
  316. <div className="bg-purple-50/80 backdrop-blur-md border border-purple-200/40 px-5 py-3.5 rounded-2xl rounded-tl-none shadow-sm max-w-2xl">
  317. <div className="flex items-center gap-2 mb-2">
  318. <span className="text-xs font-semibold text-purple-600 uppercase tracking-wide">深度思考</span>
  319. </div>
  320. <div className="text-sm text-purple-800 whitespace-pre-wrap">{thinking}</div>
  321. </div>
  322. </div>
  323. </div>
  324. )}
  325. {/* Status Message */}
  326. {statusMessage && (
  327. <div className="flex justify-start animate-in fade-in slide-in-from-left-2 duration-300">
  328. <div className="flex flex-row gap-4 items-start translate-x-1">
  329. <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">
  330. <Loader2 className="w-4 h-4 text-indigo-600 animate-spin" />
  331. </div>
  332. <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">
  333. <div className="flex items-center gap-2">
  334. <div className="flex gap-1">
  335. <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]"></span>
  336. <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]"></span>
  337. <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce"></span>
  338. </div>
  339. <span className="text-sm font-medium text-slate-500 ml-2 tracking-wide">{statusMessage}</span>
  340. </div>
  341. </div>
  342. </div>
  343. </div>
  344. )}
  345. {isLoading && !statusMessage && !showThinking && (
  346. <div className="flex justify-start animate-in fade-in slide-in-from-left-2 duration-300">
  347. <div className="flex flex-row gap-4 items-start translate-x-1">
  348. <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">
  349. <Loader2 className="w-4 h-4 text-indigo-600 animate-spin" />
  350. </div>
  351. <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">
  352. <div className="flex items-center gap-2">
  353. <div className="flex gap-1">
  354. <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]"></span>
  355. <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]"></span>
  356. <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce"></span>
  357. </div>
  358. <span className="text-sm font-medium text-slate-500 ml-2 tracking-wide uppercase text-[10px]">{t('analyzing')}</span>
  359. </div>
  360. </div>
  361. </div>
  362. </div>
  363. )}
  364. <div ref={messagesEndRef} />
  365. </div>
  366. <div className="absolute bottom-6 left-0 right-0 px-4 md:px-8 pointer-events-none">
  367. <div className="max-w-4xl mx-auto pointer-events-auto">
  368. {((selectedFiles && selectedFiles.length > 0) || true) && (
  369. <div className="mb-3 flex flex-wrap gap-2 animate-in slide-in-from-bottom-2 duration-300">
  370. {/* Group Selection Button */}
  371. <button
  372. type="button"
  373. onClick={onOpenGroupSelection}
  374. 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
  375. ? 'bg-blue-600 text-white border-blue-500 hover:bg-blue-700'
  376. : 'bg-white/90 backdrop-blur-md text-slate-600 border-slate-200/60 hover:bg-white'
  377. }`}
  378. title={t('selectKnowledgeGroup')}
  379. >
  380. <Database size={13} className={selectedGroups.length > 0 ? "text-blue-100" : "text-blue-500"} />
  381. <span className="truncate max-w-[150px]">
  382. {selectedGroups.length === 0
  383. ? t('allKnowledgeGroups')
  384. : selectedGroups.length <= 1
  385. ? (groups.find(g => g.id === selectedGroups[0])?.name || t('unknownGroup'))
  386. : t('selectedGroupsCount').replace('$1', selectedGroups.length.toString())}
  387. </span>
  388. </button>
  389. {files.filter(f => selectedFiles?.includes(f.id)).map(file => (
  390. <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">
  391. <span className="truncate max-w-[150px]">{file.title || file.name}</span>
  392. <button
  393. onClick={onClearFileSelection}
  394. className="hover:bg-indigo-200/50 rounded-full p-0.5 transition-colors"
  395. >
  396. <X size={12} />
  397. </button>
  398. </div>
  399. ))}
  400. </div>
  401. )}
  402. <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">
  403. <button
  404. onClick={onMobileUploadClick}
  405. className="md:hidden p-3 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-colors"
  406. >
  407. <Paperclip className="w-5 h-5" />
  408. </button>
  409. <textarea
  410. ref={inputRef}
  411. value={input}
  412. onChange={handleInputResize}
  413. onKeyDown={handleKeyDown}
  414. placeholder={files.length > 0 ? t('placeholderWithFiles') : t('placeholderEmpty')}
  415. 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"
  416. rows={1}
  417. disabled={files.length === 0 && messages.length < 2 && false}
  418. />
  419. <button
  420. onClick={handleSend}
  421. disabled={!input.trim() || isLoading}
  422. className={`p-3 rounded-xl mb-0.5 ml-2 transition-all duration-300 ${input.trim() && !isLoading
  423. ? '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'
  424. : 'bg-slate-100 text-slate-300 cursor-not-allowed'
  425. } `}
  426. type="button"
  427. >
  428. {isLoading ? (
  429. <Loader2 className="w-5 h-5 animate-spin" />
  430. ) : (
  431. <Send className="w-5 h-5" />
  432. )}
  433. </button>
  434. </div>
  435. <p className="text-center text-[10px] text-slate-400/80 mt-3 font-medium tracking-tight uppercase">
  436. {t('aiDisclaimer')}
  437. </p>
  438. </div>
  439. </div>
  440. </div>
  441. );
  442. };
  443. export default ChatInterface;