Files
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

460 lines
19 KiB
TypeScript

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>
)
}