| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418 |
- import React, { useState, useRef, useEffect } from 'react';
- import { Send, Loader2, Paperclip, X, Search, Database } from 'lucide-react';
- import { useLanguage } from '../contexts/LanguageContext';
- import {
- AppSettings,
- KnowledgeFile,
- ModelConfig,
- Message,
- Role,
- KnowledgeGroup
- } from '../types';
- import ChatMessage from './ChatMessage'; // Assuming ChatMessage is a default export based on original code
- import SearchResultsPanel from './SearchResultsPanel';
- import { chatService, ChatMessage as ChatMsg, ChatSource } from '../services/chatService';
- import { generateUUID } from '../utils/uuid';
- interface ChatInterfaceProps {
- files: KnowledgeFile[];
- settings: AppSettings;
- models: ModelConfig[];
- groups: KnowledgeGroup[];
- selectedGroups: string[];
- onGroupSelectionChange?: (groupIds: string[]) => void;
- onOpenGroupSelection?: () => void; // New prop
- selectedFiles?: string[];
- onClearFileSelection?: () => void;
- onMobileUploadClick: () => void;
- currentHistoryId?: string;
- historyMessages?: any[] | null;
- onHistoryMessagesLoaded?: () => void;
- onPreviewSource?: (source: ChatSource) => void;
- onOpenFile?: (source: ChatSource) => void;
- onHistoryIdCreated?: (historyId: string) => void;
- authToken?: string; // Add authToken prop
- }
- const ChatInterface: React.FC<ChatInterfaceProps> = ({
- files,
- settings,
- models,
- groups,
- selectedGroups,
- onGroupSelectionChange,
- onOpenGroupSelection,
- selectedFiles,
- onClearFileSelection,
- onMobileUploadClick,
- currentHistoryId,
- historyMessages,
- onHistoryMessagesLoaded,
- onPreviewSource,
- onOpenFile,
- onHistoryIdCreated,
- authToken: propAuthToken // Use prop
- }) => {
- const { t, language } = useLanguage();
- const [messages, setMessages] = useState<Message[]>([]);
- const [input, setInput] = useState('');
- const [isLoading, setIsLoading] = useState(false);
- const [sources, setSources] = useState<ChatSource[]>([]);
- const [showSources, setShowSources] = useState(false);
- const messagesEndRef = useRef<HTMLDivElement>(null);
- const inputRef = useRef<HTMLTextAreaElement>(null);
- const lastSubmitTime = useRef<number>(0);
- // Debug logging
- // console.log('ChatInterface Render:', {
- // selectedFilesCount: selectedFiles?.length,
- // totalFilesCount: files.length,
- // selectedFiles: selectedFiles,
- // matchedFiles: files.filter(f => selectedFiles?.includes(f.id)).map(f => f.name)
- // });
- // 履歴メッセージの読み込みを処理
- // 履歴メッセージの読み込みを処理
- useEffect(() => {
- if (historyMessages && historyMessages.length > 0) {
- const convertedMessages: Message[] = historyMessages.map(msg => ({
- id: msg.id,
- role: msg.role === 'user' ? Role.USER : Role.MODEL,
- text: msg.content,
- timestamp: new Date(msg.createdAt).getTime(),
- sources: msg.sources // Attach sources to message
- }));
- setMessages(convertedMessages);
- // 履歴メッセージが読み込まれたことを親コンポーネントに通知
- onHistoryMessagesLoaded?.();
- }
- }, [historyMessages, onHistoryMessagesLoaded]);
- useEffect(() => {
- const welcomeText = t('welcomeMessage');
- setMessages((prevMessages) => {
- if (prevMessages.length === 0) {
- return [
- {
- id: 'welcome',
- role: Role.MODEL,
- text: welcomeText,
- timestamp: Date.now(),
- },
- ];
- }
- const hasWelcome = prevMessages.some(m => m.id === 'welcome');
- if (hasWelcome) {
- return prevMessages.map(m =>
- m.id === 'welcome'
- ? { ...m, text: welcomeText }
- : m
- );
- }
- return prevMessages;
- });
- }, [t]);
- const scrollToBottom = () => {
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
- };
- useEffect(() => {
- scrollToBottom();
- }, [messages]);
- const handleSend = async () => {
- if (!input.trim() || isLoading) return;
- // デバウンス機構:500ms以内の重複送信を防止
- const now = Date.now();
- if (now - lastSubmitTime.current < 500) {
- console.log('Preventing duplicate submission');
- return;
- }
- lastSubmitTime.current = now;
- const userText = input.trim();
- // 入力欄を即座にクリアして高さをリセットし、重複送信を防止
- setInput('');
- if (inputRef.current) {
- inputRef.current.style.height = 'auto';
- inputRef.current.blur(); // フォーカスを外す
- }
- // Resolve Model Config
- const selectedModel = models.find(m => m.id === settings.selectedLLMId && m.type === 'llm');
- if (!selectedModel) {
- const errorMsg: Message = {
- id: generateUUID(),
- role: Role.MODEL,
- text: `${t('errorNoModel')} - LLM ID: ${settings.selectedLLMId}, Models: ${models.length} `,
- timestamp: Date.now(),
- isError: true,
- };
- setMessages(prev => [...prev, errorMsg]);
- return;
- }
- const newMessage: Message = {
- id: generateUUID(),
- role: Role.USER,
- text: userText,
- timestamp: Date.now(),
- };
- setIsLoading(true);
- setMessages((prev) => [...prev, newMessage]);
- // Use token from props if available, fallback to localStorage (with correct key)
- const finalToken = propAuthToken || localStorage.getItem('kb_api_key') || localStorage.getItem('authToken');
-
- if (!finalToken) {
- const errorMsg: Message = {
- id: generateUUID(),
- role: Role.MODEL,
- text: t('needLogin'),
- timestamp: Date.now(),
- isError: true,
- };
- setMessages(prev => [...prev, errorMsg]);
- setIsLoading(false);
- return;
- }
- try {
- const history: ChatMsg[] = messages
- .filter(m => m.id !== 'welcome')
- .map(m => ({
- role: m.role === Role.USER ? 'user' : 'assistant',
- content: m.text,
- }));
- const botMessageId = generateUUID();
- let botContent = '';
- // 初期ボットメッセージを追加
- const botMessage: Message = {
- id: botMessageId,
- role: Role.MODEL,
- text: '',
- timestamp: Date.now(),
- };
- setMessages(prev => [...prev, botMessage]);
- const stream = chatService.streamChat(
- userText,
- history,
- finalToken,
- language,
- settings.selectedEmbeddingId,
- settings.selectedLLMId, // Pass selected LLM ID
- selectedGroups.length > 0 ? selectedGroups : undefined, // グループフィルタを渡す
- selectedFiles?.length > 0 ? selectedFiles : undefined, // ファイルフィルタを渡す
- currentHistoryId, // 履歴IDを渡す
- settings.enableRerank, // Rerankスイッチを渡す
- settings.selectedRerankId, // RerankモデルIDを渡す
- settings.temperature, // 温度パラメータを渡す
- settings.maxTokens, // 最大トークン数を渡す
- settings.topK, // Top-Kパラメータを渡す
- settings.similarityThreshold, // 類似度しきい値を渡す
- settings.rerankSimilarityThreshold, // Rerankしきい値を渡す
- settings.enableQueryExpansion, // クエリ拡張を渡す
- settings.enableHyDE // HyDEを渡す
- );
- for await (const chunk of stream) {
- if (chunk.type === 'content') {
- botContent += chunk.data;
- setMessages(prev =>
- prev.map(msg =>
- msg.id === botMessageId
- ? { ...msg, text: botContent }
- : msg
- )
- );
- } else if (chunk.type === 'sources') {
- // Attach sources to the current bot message
- setMessages(prev =>
- prev.map(msg =>
- msg.id === botMessageId
- ? { ...msg, sources: chunk.data }
- : msg
- )
- );
- } else if (chunk.type === 'historyId') {
- onHistoryIdCreated?.(chunk.data);
- } else if (chunk.type === 'error') {
- setMessages(prev =>
- prev.map(msg =>
- msg.id === botMessageId
- ? { ...msg, text: t('errorMessage').replace('$1', chunk.data), isError: true }
- : msg
- )
- );
- break;
- }
- }
- } catch (error: any) {
- console.error('Chat error:', error);
- let errorText = t('errorGeneric');
- if (error.message === "API_KEY_MISSING") errorText = t('apiError');
- else if (error.message.includes("OpenAI API Error")) errorText = `OpenAI Error: ${error.message} `;
- else if (error.message === "GEMINI_API_ERROR") errorText = t('geminiError');
- else if (error.message) errorText = `Error: ${error.message} `;
- const errorMessage: Message = {
- id: generateUUID(),
- role: Role.MODEL,
- text: errorText,
- timestamp: Date.now(),
- isError: true,
- };
- setMessages((prev) => [...prev, errorMessage]);
- } finally {
- setIsLoading(false);
- lastSubmitTime.current = 0;
- }
- };
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- if (!isLoading && input.trim()) {
- handleSend();
- }
- }
- };
- const handleInputResize = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
- setInput(e.target.value);
- e.target.style.height = 'auto';
- e.target.style.height = `${Math.min(e.target.scrollHeight, 200)} px`;
- };
- return (
- <div className="flex flex-col h-full bg-transparent relative overflow-hidden">
- <div className="flex-1 overflow-y-auto px-4 md:px-8 pt-6 pb-32 space-y-8 scrollbar-hide">
- {messages.map((msg) => (
- <ChatMessage
- key={msg.id}
- message={msg}
- onPreviewSource={onPreviewSource}
- onOpenFile={onOpenFile}
- />
- ))}
- {isLoading && (
- <div className="flex justify-start animate-in fade-in slide-in-from-left-2 duration-300">
- <div className="flex flex-row gap-4 items-start translate-x-1">
- <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">
- <Loader2 className="w-4 h-4 text-indigo-600 animate-spin" />
- </div>
- <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">
- <div className="flex items-center gap-2">
- <div className="flex gap-1">
- <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.3s]"></span>
- <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce [animation-delay:-0.15s]"></span>
- <span className="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-bounce"></span>
- </div>
- <span className="text-sm font-medium text-slate-500 ml-2 tracking-wide uppercase text-[10px]">{t('analyzing')}</span>
- </div>
- </div>
- </div>
- </div>
- )}
- <div ref={messagesEndRef} />
- </div>
- <div className="absolute bottom-6 left-0 right-0 px-4 md:px-8 pointer-events-none">
- <div className="max-w-4xl mx-auto pointer-events-auto">
- {((selectedFiles && selectedFiles.length > 0) || true) && (
- <div className="mb-3 flex flex-wrap gap-2 animate-in slide-in-from-bottom-2 duration-300">
- {/* Group Selection Button */}
- <button
- type="button"
- onClick={onOpenGroupSelection}
- 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
- ? 'bg-blue-600 text-white border-blue-500 hover:bg-blue-700'
- : 'bg-white/90 backdrop-blur-md text-slate-600 border-slate-200/60 hover:bg-white'
- }`}
- title={t('selectKnowledgeGroup')}
- >
- <Database size={13} className={selectedGroups.length > 0 ? "text-blue-100" : "text-blue-500"} />
- <span className="truncate max-w-[150px]">
- {selectedGroups.length === 0
- ? t('allKnowledgeGroups')
- : selectedGroups.length <= 1
- ? (groups.find(g => g.id === selectedGroups[0])?.name || t('unknownGroup'))
- : t('selectedGroupsCount').replace('$1', selectedGroups.length.toString())}
- </span>
- </button>
- {files.filter(f => selectedFiles?.includes(f.id)).map(file => (
- <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">
- <span className="truncate max-w-[150px]">{file.title || file.name}</span>
- <button
- onClick={onClearFileSelection}
- className="hover:bg-indigo-200/50 rounded-full p-0.5 transition-colors"
- >
- <X size={12} />
- </button>
- </div>
- ))}
- </div>
- )}
- <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">
- <button
- onClick={onMobileUploadClick}
- className="md:hidden p-3 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-colors"
- >
- <Paperclip className="w-5 h-5" />
- </button>
- <textarea
- ref={inputRef}
- value={input}
- onChange={handleInputResize}
- onKeyDown={handleKeyDown}
- placeholder={files.length > 0 ? t('placeholderWithFiles') : t('placeholderEmpty')}
- 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"
- rows={1}
- disabled={files.length === 0 && messages.length < 2 && false}
- />
- <button
- onClick={handleSend}
- disabled={!input.trim() || isLoading}
- className={`p-3 rounded-xl mb-0.5 ml-2 transition-all duration-300 ${input.trim() && !isLoading
- ? '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'
- : 'bg-slate-100 text-slate-300 cursor-not-allowed'
- } `}
- type="button"
- >
- {isLoading ? (
- <Loader2 className="w-5 h-5 animate-spin" />
- ) : (
- <Send className="w-5 h-5" />
- )}
- </button>
- </div>
- <p className="text-center text-[10px] text-slate-400/80 mt-3 font-medium tracking-tight uppercase">
- {t('aiDisclaimer')}
- </p>
- </div>
- </div>
- </div>
- );
- };
- export default ChatInterface;
|