Files
aurak/web/components/ChatInterface.tsx
T
Developer 0a9588abb7 feat: implement QuestionBank CRUD with pagination and template query
- 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
2026-04-23 17:19:11 +08:00

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;