forked from hangshuo652/aurak
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
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user