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,459 @@
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import ChatInterface from '../../components/ChatInterface'
|
||||
import IndexingModalWithMode from '../../components/IndexingModalWithMode'
|
||||
import { GroupManager } from '../../components/GroupManager'
|
||||
import { GroupSelector } from '../../components/GroupSelector'
|
||||
import { SearchHistoryList } from '../../components/SearchHistoryList'
|
||||
import { HistoryDrawer } from '../../components/HistoryDrawer'
|
||||
import { GroupSelectionDrawer } from '../../components/GroupSelectionDrawer'
|
||||
import { PDFPreview } from '../../components/PDFPreview'
|
||||
import { SourcePreviewDrawer } from '../../components/SourcePreviewDrawer'
|
||||
import { ChatSource } from '../../services/chatService'
|
||||
import {
|
||||
AppSettings,
|
||||
DEFAULT_MODELS,
|
||||
DEFAULT_SETTINGS,
|
||||
IndexingConfig,
|
||||
KnowledgeFile,
|
||||
ModelConfig,
|
||||
ModelType,
|
||||
RawFile,
|
||||
KnowledgeGroup,
|
||||
} from '../../types'
|
||||
import { readFile, formatBytes } from '../../utils/fileUtils'
|
||||
import { isFormatSupportedForPreview } from '../../constants/fileSupport'
|
||||
import { Key, LogOut, Menu, Users, X, Folder, History, Plus, Sparkles, Settings } from 'lucide-react'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { useToast } from '../../contexts/ToastContext'
|
||||
import { modelConfigService } from '../../services/modelConfigService'
|
||||
import { userSettingService } from '../../services/userSettingService'
|
||||
import { uploadService } from '../../services/uploadService'
|
||||
import { knowledgeBaseService } from '../../services/knowledgeBaseService'
|
||||
import { knowledgeGroupService } from '../../services/knowledgeGroupService'
|
||||
import { searchHistoryService } from '../../services/searchHistoryService'
|
||||
import { userService } from '../../services/userService'
|
||||
|
||||
interface ChatViewProps {
|
||||
authToken: string;
|
||||
onLogout: () => void;
|
||||
modelConfigs?: ModelConfig[]; // Optional to allow backward compat while refactoring
|
||||
onNavigate: (view: any) => void;
|
||||
initialChatContext?: { selectedGroups?: string[], selectedFiles?: string[] } | null;
|
||||
onClearContext?: () => void;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export const ChatView: React.FC<ChatViewProps> = ({
|
||||
authToken,
|
||||
onLogout,
|
||||
modelConfigs = DEFAULT_MODELS,
|
||||
onNavigate,
|
||||
initialChatContext,
|
||||
onClearContext,
|
||||
isAdmin = false
|
||||
}) => {
|
||||
const { showError, showWarning } = useToast()
|
||||
|
||||
const [files, setFiles] = useState<KnowledgeFile[]>([])
|
||||
const [groups, setGroups] = useState<KnowledgeGroup[]>([])
|
||||
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS)
|
||||
const [isLoadingSettings, setIsLoadingSettings] = useState(true)
|
||||
|
||||
const [isGroupManagerOpen, setIsGroupManagerOpen] = useState(false)
|
||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
|
||||
const [isGroupSelectionOpen, setIsGroupSelectionOpen] = useState(false) // New state
|
||||
const [currentHistoryId, setCurrentHistoryId] = useState<string | undefined>()
|
||||
const [historyMessages, setHistoryMessages] = useState<any[] | null>(null)
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>([])
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||
const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
|
||||
const [previewSource, setPreviewSource] = useState<ChatSource | null>(null)
|
||||
|
||||
const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
|
||||
|
||||
// Modals state removed as they are moved to Settings
|
||||
const [isLanguageLoading, setIsLanguageLoading] = useState(false)
|
||||
|
||||
const { t, language, setLanguage } = useLanguage()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
|
||||
const handleNewChat = () => {
|
||||
const currentLanguage = language
|
||||
localStorage.removeItem('chatHistory')
|
||||
localStorage.removeItem('chatMessages')
|
||||
localStorage.removeItem('chatSources')
|
||||
localStorage.setItem('userLanguage', currentLanguage)
|
||||
setCurrentHistoryId(undefined)
|
||||
setHistoryMessages(null)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// Function to fetch user settings from backend
|
||||
const fetchAndSetSettings = useCallback(async () => {
|
||||
if (!authToken) return
|
||||
try {
|
||||
const [personalSettings, tenantSettings] = await Promise.all([
|
||||
userSettingService.getPersonal(authToken).catch(() => null),
|
||||
userSettingService.get(authToken).catch(() => ({} as Partial<AppSettings>))
|
||||
]);
|
||||
|
||||
const appSettings: AppSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...tenantSettings,
|
||||
language: personalSettings?.language || tenantSettings?.language || DEFAULT_SETTINGS.language,
|
||||
};
|
||||
setSettings(appSettings)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error)
|
||||
setSettings(DEFAULT_SETTINGS)
|
||||
} finally {
|
||||
setIsLoadingSettings(false)
|
||||
}
|
||||
}, [authToken])
|
||||
|
||||
const fetchAndSetFiles = useCallback(async () => {
|
||||
if (!authToken) return
|
||||
try {
|
||||
const data = await knowledgeBaseService.getAll(authToken)
|
||||
setFiles(data.items)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch files:', error)
|
||||
}
|
||||
}, [authToken])
|
||||
|
||||
// Function to fetch groups from backend
|
||||
const fetchAndSetGroups = useCallback(async () => {
|
||||
if (!authToken) return
|
||||
try {
|
||||
const remoteGroups = await knowledgeGroupService.getGroups()
|
||||
setGroups(remoteGroups)
|
||||
|
||||
// Filter out selected groups that no longer exist
|
||||
setSelectedGroups(prev => {
|
||||
const validGroupIds = new Set(remoteGroups.map(g => g.id))
|
||||
return prev.filter(id => validGroupIds.has(id))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch groups:', error)
|
||||
}
|
||||
}, [authToken])
|
||||
|
||||
useEffect(() => {
|
||||
if (authToken) {
|
||||
fetchAndSetSettings()
|
||||
fetchAndSetFiles()
|
||||
fetchAndSetGroups()
|
||||
}
|
||||
}, [authToken, fetchAndSetSettings, fetchAndSetFiles, fetchAndSetGroups])
|
||||
|
||||
// Handle Initial Context
|
||||
useEffect(() => {
|
||||
if (initialChatContext) {
|
||||
if (initialChatContext.selectedGroups) {
|
||||
setSelectedGroups(initialChatContext.selectedGroups)
|
||||
}
|
||||
if (initialChatContext.selectedFiles) {
|
||||
setSelectedFiles(initialChatContext.selectedFiles)
|
||||
}
|
||||
}
|
||||
}, [initialChatContext])
|
||||
|
||||
// Load chat history from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedHistory = localStorage.getItem('chatMessages');
|
||||
if (savedHistory) {
|
||||
try {
|
||||
const parsedHistory = JSON.parse(savedHistory);
|
||||
if (Array.isArray(parsedHistory) && parsedHistory.length > 0) {
|
||||
setHistoryMessages(parsedHistory);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse saved chat history:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFileUpload = async (fileList: FileList) => {
|
||||
if (!authToken) {
|
||||
showWarning(t('loginToUpload'))
|
||||
return
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 104857600
|
||||
const MAX_SIZE_MB = 100
|
||||
const rawFiles: RawFile[] = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const file = fileList[i]
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
errors.push(t('fileSizeLimitExceeded').replace('$1', file.name).replace('$2', formatBytes(file.size)).replace('$3', MAX_SIZE_MB.toString()))
|
||||
continue
|
||||
}
|
||||
|
||||
const allowedTypes = [
|
||||
'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
'application/vnd.oasis.opendocument.presentation', 'application/vnd.oasis.opendocument.graphics',
|
||||
'text/plain', 'text/markdown', 'text/html', 'text/csv', 'text/xml', 'application/xml', 'application/json',
|
||||
'text/x-python', 'text/x-java', 'text/x-c', 'text/x-c++', 'text/javascript', 'text/typescript',
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/tiff', 'image/bmp', 'image/svg+xml',
|
||||
'application/zip', 'application/x-tar', 'application/gzip', 'application/x-7z-compressed',
|
||||
'application/rtf', 'application/epub+zip', 'application/x-mobipocket-ebook',
|
||||
]
|
||||
|
||||
const ext = file.name.toLowerCase().split('.').pop()
|
||||
const allowedExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'html', 'csv', 'rtf', 'odt', 'ods', 'odp', 'json', 'xml']
|
||||
|
||||
const isAllowed = allowedTypes.includes(file.type) ||
|
||||
file.type.startsWith('text/') ||
|
||||
file.type.startsWith('application/vnd.') ||
|
||||
file.type.startsWith('application/x-') ||
|
||||
file.type === '' ||
|
||||
allowedExtensions.includes(ext || '')
|
||||
|
||||
if (!isAllowed) {
|
||||
errors.push(t('unsupportedFileType').replace('$1', file.name).replace('$2', file.type || 'unknown'))
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const rawFile = await readFile(file)
|
||||
rawFiles.push(rawFile)
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file.name}:`, error)
|
||||
errors.push(t('readFailed').replace('$1', file.name))
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
showError(`${t('uploadErrors')}:\n${errors.join('\n')}`)
|
||||
}
|
||||
|
||||
if (rawFiles.length === 0) return
|
||||
|
||||
if (errors.length > 0 && rawFiles.length > 0) {
|
||||
showWarning(t('uploadWarning').replace('$1', rawFiles.length.toString()).replace('$2', errors.length.toString()))
|
||||
}
|
||||
|
||||
setPendingFiles(rawFiles);
|
||||
setIsIndexingModalOpen(true);
|
||||
}
|
||||
|
||||
const handleConfirmIndexing = async (config: IndexingConfig) => {
|
||||
if (!authToken) return
|
||||
let hasSuccess = false
|
||||
for (const rawFile of pendingFiles) {
|
||||
try {
|
||||
await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
|
||||
hasSuccess = true
|
||||
} catch (error) {
|
||||
console.error(`Error uploading file ${rawFile.name}:`, error)
|
||||
showError(t('uploadFailed').replace('$1', rawFile.name).replace('$2', error.message))
|
||||
}
|
||||
}
|
||||
if (hasSuccess) {
|
||||
await fetchAndSetFiles()
|
||||
}
|
||||
setPendingFiles([])
|
||||
setIsIndexingModalOpen(false)
|
||||
}
|
||||
|
||||
const handleCancelIndexing = () => {
|
||||
setPendingFiles([])
|
||||
setIsIndexingModalOpen(false)
|
||||
}
|
||||
|
||||
const handleGroupsChange = (newGroups: KnowledgeGroup[]) => {
|
||||
setGroups(newGroups)
|
||||
}
|
||||
|
||||
const handleSelectHistory = async (historyId: string) => {
|
||||
try {
|
||||
const historyDetail = await searchHistoryService.getHistoryDetail(historyId)
|
||||
setCurrentHistoryId(historyId)
|
||||
setIsHistoryOpen(false)
|
||||
setHistoryMessages(historyDetail.messages)
|
||||
} catch (error) {
|
||||
console.error('Failed to load history detail:', error)
|
||||
showError(t('loadHistoryFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowHistory = () => {
|
||||
setIsHistoryOpen(true)
|
||||
}
|
||||
|
||||
const handleInputFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFileUpload(e.target.files)
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoadingSettings) {
|
||||
return (
|
||||
<div className='flex items-center justify-center min-h-screen bg-slate-50 w-full'>
|
||||
<div className='text-blue-600 animate-spin rounded-full h-12 w-12 border-4 border-t-4 border-blue-300'></div>
|
||||
<p className='ml-4 text-slate-700'>{t('loadingUserData')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full bg-transparent overflow-hidden relative'>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleInputFileChange}
|
||||
multiple
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.html,.csv,.rtf,.odt,.ods,.odp,.json,.js,.jsx,.ts,.tsx,.css,.xml,image/*"
|
||||
/>
|
||||
{/* Main Content */}
|
||||
<div className='flex-1 flex flex-col h-full w-full relative overflow-hidden'>
|
||||
{/* Header */}
|
||||
<div className="px-8 pt-8 pb-4 flex items-start justify-between shrink-0 z-20">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 leading-tight">
|
||||
{t('chatTitle')}
|
||||
</h1>
|
||||
<p className="text-[15px] text-slate-500 mt-1">{t('chatDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3 flex-shrink-0'>
|
||||
{/* History button */}
|
||||
<button
|
||||
onClick={handleShowHistory}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 text-slate-700 hover:text-blue-600 hover:bg-slate-50 rounded-lg font-semibold text-sm transition-all shadow-sm"
|
||||
>
|
||||
<History size={18} />
|
||||
{t('viewHistory')}
|
||||
</button>
|
||||
|
||||
{/* New chat button */}
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="flex items-center gap-2 px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('newChat')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
<ChatInterface
|
||||
files={files}
|
||||
settings={settings}
|
||||
models={modelConfigs}
|
||||
groups={groups}
|
||||
selectedGroups={selectedGroups}
|
||||
onGroupSelectionChange={setSelectedGroups}
|
||||
onOpenGroupSelection={() => setIsGroupSelectionOpen(true)} // Pass handler
|
||||
selectedFiles={selectedFiles}
|
||||
onClearFileSelection={() => setSelectedFiles([])}
|
||||
onMobileUploadClick={() => {
|
||||
fileInputRef.current?.click()
|
||||
}}
|
||||
currentHistoryId={currentHistoryId}
|
||||
historyMessages={historyMessages}
|
||||
onHistoryMessagesLoaded={() => setHistoryMessages(null)}
|
||||
onHistoryIdCreated={setCurrentHistoryId}
|
||||
onPreviewSource={setPreviewSource}
|
||||
authToken={authToken}
|
||||
onOpenFile={(source) => {
|
||||
if (source.fileId) {
|
||||
if (isFormatSupportedForPreview(source.fileName)) {
|
||||
setPdfPreview({ fileId: source.fileId, fileName: source.fileName });
|
||||
} else {
|
||||
showWarning(t('previewNotSupported'));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
|
||||
<IndexingModalWithMode
|
||||
isOpen={isIndexingModalOpen}
|
||||
onClose={handleCancelIndexing}
|
||||
files={pendingFiles}
|
||||
embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
|
||||
defaultEmbeddingId={settings.selectedEmbeddingId}
|
||||
onConfirm={handleConfirmIndexing}
|
||||
/>
|
||||
|
||||
{/* Group Selection Drawer */}
|
||||
<GroupSelectionDrawer
|
||||
isOpen={isGroupSelectionOpen}
|
||||
onClose={() => setIsGroupSelectionOpen(false)}
|
||||
groups={groups}
|
||||
selectedGroups={selectedGroups}
|
||||
onSelectionChange={setSelectedGroups}
|
||||
/>
|
||||
|
||||
{/* Knowledge base enhancement features modal (Legacy) */}
|
||||
{isGroupManagerOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">{t('notebooks')}</h2>
|
||||
<button
|
||||
onClick={() => setIsGroupManagerOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<GroupManager
|
||||
groups={groups}
|
||||
onGroupsChange={handleGroupsChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HistoryDrawer
|
||||
isOpen={isHistoryOpen}
|
||||
onClose={() => setIsHistoryOpen(false)}
|
||||
groups={groups}
|
||||
onSelectHistory={handleSelectHistory}
|
||||
/>
|
||||
|
||||
{pdfPreview && (
|
||||
<PDFPreview
|
||||
fileId={pdfPreview.fileId}
|
||||
fileName={pdfPreview.fileName}
|
||||
authToken={authToken}
|
||||
onClose={() => setPdfPreview(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SourcePreviewDrawer
|
||||
isOpen={!!previewSource}
|
||||
onClose={() => setPreviewSource(null)}
|
||||
source={previewSource}
|
||||
onOpenFile={(source) => {
|
||||
if (source.fileId) {
|
||||
if (isFormatSupportedForPreview(source.fileName)) {
|
||||
setPdfPreview({ fileId: source.fileId, fileName: source.fileName });
|
||||
} else {
|
||||
showWarning(t('previewNotSupported'));
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user