0a9588abb7
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
|
|
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 optional auth token prop
|
|
}
|
|
|
|
const ChatInterface: React.FC<ChatInterfaceProps> = ({
|
|
files,
|
|
settings,
|
|
models,
|
|
groups,
|
|
selectedGroups,
|
|
onGroupSelectionChange,
|
|
onOpenGroupSelection,
|
|
selectedFiles,
|
|
onClearFileSelection,
|
|
onMobileUploadClick,
|
|
currentHistoryId,
|
|
historyMessages,
|
|
onHistoryMessagesLoaded,
|
|
onPreviewSource,
|
|
onOpenFile,
|
|
onHistoryIdCreated,
|
|
authToken
|
|
}) => {
|
|
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)
|
|
// });
|
|
|
|
// Handle loading of history messages
|
|
// Handle loading of history messages
|
|
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);
|
|
|
|
// Notify parent component that history messages have been loaded
|
|
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;
|
|
|
|
// Debounce mechanism: prevent duplicate submissions within 500ms
|
|
const now = Date.now();
|
|
if (now - lastSubmitTime.current < 500) {
|
|
console.log('Preventing duplicate submission');
|
|
return;
|
|
}
|
|
lastSubmitTime.current = now;
|
|
|
|
const userText = input.trim();
|
|
|
|
// Instantly clear input field and reset height to prevent duplicate submission
|
|
setInput('');
|
|
if (inputRef.current) {
|
|
inputRef.current.style.height = 'auto';
|
|
inputRef.current.blur(); // Remove focus
|
|
}
|
|
|
|
// 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]);
|
|
|
|
const effectiveToken = authToken || localStorage.getItem('kb_api_key') || localStorage.getItem('authToken');
|
|
|
|
if (!effectiveToken) {
|
|
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 = '';
|
|
|
|
// Add initial bot message
|
|
const botMessage: Message = {
|
|
id: botMessageId,
|
|
role: Role.MODEL,
|
|
text: '',
|
|
timestamp: Date.now(),
|
|
};
|
|
setMessages(prev => [...prev, botMessage]);
|
|
|
|
const stream = chatService.streamChat(
|
|
userText,
|
|
history,
|
|
effectiveToken,
|
|
language,
|
|
settings.selectedEmbeddingId,
|
|
settings.selectedLLMId, // Pass selected LLM ID
|
|
selectedGroups.length > 0 ? selectedGroups : undefined, // Pass group filter
|
|
selectedFiles?.length > 0 ? selectedFiles : undefined, // Pass file filter
|
|
currentHistoryId, // Pass history ID
|
|
settings.enableRerank, // Pass Rerank switch
|
|
settings.selectedRerankId, // Pass Rerank model ID
|
|
settings.temperature, // Pass temperature parameter
|
|
settings.maxTokens, // Pass max tokens
|
|
settings.topK, // Pass Top-K parameter
|
|
settings.similarityThreshold, // Pass similarity threshold
|
|
settings.rerankSimilarityThreshold, // Pass Rerank threshold
|
|
settings.enableQueryExpansion, // Pass query expansion
|
|
settings.enableHyDE // Pass 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;
|