| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470 |
- 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 [userSettings, globalSettings, tenantSettings] = await Promise.all([
- userSettingService.get(authToken),
- userSettingService.getGlobal(authToken),
- userSettingService.getTenant(authToken).catch(() => ({} as Partial<AppSettings>))
- ]);
- const appSettings: AppSettings = {
- language: userSettings.language || tenantSettings.language || globalSettings.language || DEFAULT_SETTINGS.language,
- selectedLLMId: userSettings.selectedLLMId || tenantSettings.selectedLLMId || globalSettings.selectedLLMId || DEFAULT_SETTINGS.selectedLLMId,
- selectedEmbeddingId: userSettings.selectedEmbeddingId || tenantSettings.selectedEmbeddingId || globalSettings.selectedEmbeddingId || DEFAULT_SETTINGS.selectedEmbeddingId,
- selectedRerankId: userSettings.selectedRerankId || tenantSettings.selectedRerankId || globalSettings.selectedRerankId || '',
- temperature: userSettings.temperature ?? tenantSettings.temperature ?? globalSettings.temperature ?? DEFAULT_SETTINGS.temperature,
- maxTokens: userSettings.maxTokens ?? tenantSettings.maxTokens ?? globalSettings.maxTokens ?? DEFAULT_SETTINGS.maxTokens,
- enableRerank: userSettings.enableRerank ?? tenantSettings.enableRerank ?? globalSettings.enableRerank ?? DEFAULT_SETTINGS.enableRerank,
- topK: userSettings.topK ?? tenantSettings.topK ?? globalSettings.topK ?? DEFAULT_SETTINGS.topK,
- similarityThreshold: userSettings.similarityThreshold ?? tenantSettings.similarityThreshold ?? globalSettings.similarityThreshold ?? DEFAULT_SETTINGS.similarityThreshold,
- rerankSimilarityThreshold: userSettings.rerankSimilarityThreshold ?? tenantSettings.rerankSimilarityThreshold ?? globalSettings.rerankSimilarityThreshold ?? DEFAULT_SETTINGS.rerankSimilarityThreshold,
- enableFullTextSearch: userSettings.enableFullTextSearch ?? tenantSettings.enableFullTextSearch ?? globalSettings.enableFullTextSearch ?? DEFAULT_SETTINGS.enableFullTextSearch,
- hybridVectorWeight: userSettings.hybridVectorWeight ?? tenantSettings.hybridVectorWeight ?? globalSettings.hybridVectorWeight ?? DEFAULT_SETTINGS.hybridVectorWeight,
- enableQueryExpansion: userSettings.enableQueryExpansion ?? tenantSettings.enableQueryExpansion ?? globalSettings.enableQueryExpansion ?? DEFAULT_SETTINGS.enableQueryExpansion,
- enableHyDE: userSettings.enableHyDE ?? tenantSettings.enableHyDE ?? globalSettings.enableHyDE ?? DEFAULT_SETTINGS.enableHyDE
- }
- 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}
- 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>
- )
- }
|