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
460 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|