ChatView.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import React, { useCallback, useEffect, useState, useRef } from 'react'
  2. import ChatInterface from '../../components/ChatInterface'
  3. import IndexingModalWithMode from '../../components/IndexingModalWithMode'
  4. import { GroupManager } from '../../components/GroupManager'
  5. import { GroupSelector } from '../../components/GroupSelector'
  6. import { SearchHistoryList } from '../../components/SearchHistoryList'
  7. import { HistoryDrawer } from '../../components/HistoryDrawer'
  8. import { GroupSelectionDrawer } from '../../components/GroupSelectionDrawer'
  9. import { PDFPreview } from '../../components/PDFPreview'
  10. import { SourcePreviewDrawer } from '../../components/SourcePreviewDrawer'
  11. import { ChatSource } from '../../services/chatService'
  12. import {
  13. AppSettings,
  14. DEFAULT_MODELS,
  15. DEFAULT_SETTINGS,
  16. IndexingConfig,
  17. KnowledgeFile,
  18. ModelConfig,
  19. ModelType,
  20. RawFile,
  21. KnowledgeGroup,
  22. } from '../../types'
  23. import { readFile, formatBytes } from '../../utils/fileUtils'
  24. import { isFormatSupportedForPreview } from '../../constants/fileSupport'
  25. import { Key, LogOut, Menu, Users, X, Folder, History, Plus, Sparkles, Settings } from 'lucide-react'
  26. import { useLanguage } from '../../contexts/LanguageContext'
  27. import { useToast } from '../../contexts/ToastContext'
  28. import { modelConfigService } from '../../services/modelConfigService'
  29. import { userSettingService } from '../../services/userSettingService'
  30. import { uploadService } from '../../services/uploadService'
  31. import { knowledgeBaseService } from '../../services/knowledgeBaseService'
  32. import { knowledgeGroupService } from '../../services/knowledgeGroupService'
  33. import { searchHistoryService } from '../../services/searchHistoryService'
  34. import { userService } from '../../services/userService'
  35. interface ChatViewProps {
  36. authToken: string;
  37. onLogout: () => void;
  38. modelConfigs?: ModelConfig[]; // Optional to allow backward compat while refactoring
  39. onNavigate: (view: any) => void;
  40. initialChatContext?: { selectedGroups?: string[], selectedFiles?: string[] } | null;
  41. onClearContext?: () => void;
  42. isAdmin?: boolean;
  43. }
  44. export const ChatView: React.FC<ChatViewProps> = ({
  45. authToken,
  46. onLogout,
  47. modelConfigs = DEFAULT_MODELS,
  48. onNavigate,
  49. initialChatContext,
  50. onClearContext,
  51. isAdmin = false
  52. }) => {
  53. const { showError, showWarning } = useToast()
  54. const [files, setFiles] = useState<KnowledgeFile[]>([])
  55. const [groups, setGroups] = useState<KnowledgeGroup[]>([])
  56. const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS)
  57. const [isLoadingSettings, setIsLoadingSettings] = useState(true)
  58. const [isGroupManagerOpen, setIsGroupManagerOpen] = useState(false)
  59. const [isHistoryOpen, setIsHistoryOpen] = useState(false)
  60. const [isGroupSelectionOpen, setIsGroupSelectionOpen] = useState(false) // New state
  61. const [currentHistoryId, setCurrentHistoryId] = useState<string | undefined>()
  62. const [historyMessages, setHistoryMessages] = useState<any[] | null>(null)
  63. const [selectedGroups, setSelectedGroups] = useState<string[]>([])
  64. const [selectedFiles, setSelectedFiles] = useState<string[]>([])
  65. const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
  66. const [previewSource, setPreviewSource] = useState<ChatSource | null>(null)
  67. const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
  68. const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
  69. // Modals state removed as they are moved to Settings
  70. const [isLanguageLoading, setIsLanguageLoading] = useState(false)
  71. const { t, language, setLanguage } = useLanguage()
  72. const fileInputRef = useRef<HTMLInputElement>(null)
  73. const handleNewChat = () => {
  74. const currentLanguage = language
  75. localStorage.removeItem('chatHistory')
  76. localStorage.removeItem('chatMessages')
  77. localStorage.removeItem('chatSources')
  78. localStorage.setItem('userLanguage', currentLanguage)
  79. setCurrentHistoryId(undefined)
  80. setHistoryMessages(null)
  81. window.location.reload()
  82. }
  83. // Function to fetch user settings from backend
  84. const fetchAndSetSettings = useCallback(async () => {
  85. if (!authToken) return
  86. try {
  87. const [userSettings, globalSettings, tenantSettings] = await Promise.all([
  88. userSettingService.get(authToken),
  89. userSettingService.getGlobal(authToken),
  90. userSettingService.getTenant(authToken).catch(() => ({} as Partial<AppSettings>))
  91. ]);
  92. const appSettings: AppSettings = {
  93. language: userSettings.language || tenantSettings.language || globalSettings.language || DEFAULT_SETTINGS.language,
  94. selectedLLMId: userSettings.selectedLLMId || tenantSettings.selectedLLMId || globalSettings.selectedLLMId || DEFAULT_SETTINGS.selectedLLMId,
  95. selectedEmbeddingId: userSettings.selectedEmbeddingId || tenantSettings.selectedEmbeddingId || globalSettings.selectedEmbeddingId || DEFAULT_SETTINGS.selectedEmbeddingId,
  96. selectedRerankId: userSettings.selectedRerankId || tenantSettings.selectedRerankId || globalSettings.selectedRerankId || '',
  97. temperature: userSettings.temperature ?? tenantSettings.temperature ?? globalSettings.temperature ?? DEFAULT_SETTINGS.temperature,
  98. maxTokens: userSettings.maxTokens ?? tenantSettings.maxTokens ?? globalSettings.maxTokens ?? DEFAULT_SETTINGS.maxTokens,
  99. enableRerank: userSettings.enableRerank ?? tenantSettings.enableRerank ?? globalSettings.enableRerank ?? DEFAULT_SETTINGS.enableRerank,
  100. topK: userSettings.topK ?? tenantSettings.topK ?? globalSettings.topK ?? DEFAULT_SETTINGS.topK,
  101. similarityThreshold: userSettings.similarityThreshold ?? tenantSettings.similarityThreshold ?? globalSettings.similarityThreshold ?? DEFAULT_SETTINGS.similarityThreshold,
  102. rerankSimilarityThreshold: userSettings.rerankSimilarityThreshold ?? tenantSettings.rerankSimilarityThreshold ?? globalSettings.rerankSimilarityThreshold ?? DEFAULT_SETTINGS.rerankSimilarityThreshold,
  103. enableFullTextSearch: userSettings.enableFullTextSearch ?? tenantSettings.enableFullTextSearch ?? globalSettings.enableFullTextSearch ?? DEFAULT_SETTINGS.enableFullTextSearch,
  104. hybridVectorWeight: userSettings.hybridVectorWeight ?? tenantSettings.hybridVectorWeight ?? globalSettings.hybridVectorWeight ?? DEFAULT_SETTINGS.hybridVectorWeight,
  105. enableQueryExpansion: userSettings.enableQueryExpansion ?? tenantSettings.enableQueryExpansion ?? globalSettings.enableQueryExpansion ?? DEFAULT_SETTINGS.enableQueryExpansion,
  106. enableHyDE: userSettings.enableHyDE ?? tenantSettings.enableHyDE ?? globalSettings.enableHyDE ?? DEFAULT_SETTINGS.enableHyDE
  107. }
  108. setSettings(appSettings)
  109. } catch (error) {
  110. console.error('Failed to fetch settings:', error)
  111. setSettings(DEFAULT_SETTINGS)
  112. } finally {
  113. setIsLoadingSettings(false)
  114. }
  115. }, [authToken])
  116. // Function to fetch files from backend
  117. const fetchAndSetFiles = useCallback(async () => {
  118. if (!authToken) return
  119. try {
  120. const remoteFiles = await knowledgeBaseService.getAll(authToken)
  121. setFiles(remoteFiles)
  122. } catch (error) {
  123. console.error('Failed to fetch files:', error)
  124. }
  125. }, [authToken])
  126. // Function to fetch groups from backend
  127. const fetchAndSetGroups = useCallback(async () => {
  128. if (!authToken) return
  129. try {
  130. const remoteGroups = await knowledgeGroupService.getGroups()
  131. setGroups(remoteGroups)
  132. // Filter out selected groups that no longer exist
  133. setSelectedGroups(prev => {
  134. const validGroupIds = new Set(remoteGroups.map(g => g.id))
  135. return prev.filter(id => validGroupIds.has(id))
  136. })
  137. } catch (error) {
  138. console.error('Failed to fetch groups:', error)
  139. }
  140. }, [authToken])
  141. useEffect(() => {
  142. if (authToken) {
  143. fetchAndSetSettings()
  144. fetchAndSetFiles()
  145. fetchAndSetGroups()
  146. }
  147. }, [authToken, fetchAndSetSettings, fetchAndSetFiles, fetchAndSetGroups])
  148. // Handle Initial Context
  149. useEffect(() => {
  150. if (initialChatContext) {
  151. if (initialChatContext.selectedGroups) {
  152. setSelectedGroups(initialChatContext.selectedGroups)
  153. }
  154. if (initialChatContext.selectedFiles) {
  155. setSelectedFiles(initialChatContext.selectedFiles)
  156. }
  157. }
  158. }, [initialChatContext])
  159. // Load chat history from localStorage on mount
  160. useEffect(() => {
  161. const savedHistory = localStorage.getItem('chatMessages');
  162. if (savedHistory) {
  163. try {
  164. const parsedHistory = JSON.parse(savedHistory);
  165. if (Array.isArray(parsedHistory) && parsedHistory.length > 0) {
  166. setHistoryMessages(parsedHistory);
  167. }
  168. } catch (error) {
  169. console.error('Failed to parse saved chat history:', error);
  170. }
  171. }
  172. }, []);
  173. const handleFileUpload = async (fileList: FileList) => {
  174. if (!authToken) {
  175. showWarning(t('loginToUpload'))
  176. return
  177. }
  178. const MAX_FILE_SIZE = 104857600
  179. const MAX_SIZE_MB = 100
  180. const rawFiles: RawFile[] = []
  181. const errors: string[] = []
  182. for (let i = 0; i < fileList.length; i++) {
  183. const file = fileList[i]
  184. if (file.size > MAX_FILE_SIZE) {
  185. errors.push(t('fileSizeLimitExceeded').replace('$1', file.name).replace('$2', formatBytes(file.size)).replace('$3', MAX_SIZE_MB.toString()))
  186. continue
  187. }
  188. const allowedTypes = [
  189. 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  190. 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  191. 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  192. 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet',
  193. 'application/vnd.oasis.opendocument.presentation', 'application/vnd.oasis.opendocument.graphics',
  194. 'text/plain', 'text/markdown', 'text/html', 'text/csv', 'text/xml', 'application/xml', 'application/json',
  195. 'text/x-python', 'text/x-java', 'text/x-c', 'text/x-c++', 'text/javascript', 'text/typescript',
  196. 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/tiff', 'image/bmp', 'image/svg+xml',
  197. 'application/zip', 'application/x-tar', 'application/gzip', 'application/x-7z-compressed',
  198. 'application/rtf', 'application/epub+zip', 'application/x-mobipocket-ebook',
  199. ]
  200. const ext = file.name.toLowerCase().split('.').pop()
  201. const allowedExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'html', 'csv', 'rtf', 'odt', 'ods', 'odp', 'json', 'xml']
  202. const isAllowed = allowedTypes.includes(file.type) ||
  203. file.type.startsWith('text/') ||
  204. file.type.startsWith('application/vnd.') ||
  205. file.type.startsWith('application/x-') ||
  206. file.type === '' ||
  207. allowedExtensions.includes(ext || '')
  208. if (!isAllowed) {
  209. errors.push(t('unsupportedFileType').replace('$1', file.name).replace('$2', file.type || 'unknown'))
  210. continue
  211. }
  212. try {
  213. const rawFile = await readFile(file)
  214. rawFiles.push(rawFile)
  215. } catch (error) {
  216. console.error(`Error reading file ${file.name}:`, error)
  217. errors.push(t('readFailed').replace('$1', file.name))
  218. }
  219. }
  220. if (errors.length > 0) {
  221. showError(`${t('uploadErrors')}:\n${errors.join('\n')}`)
  222. }
  223. if (rawFiles.length === 0) return
  224. if (errors.length > 0 && rawFiles.length > 0) {
  225. showWarning(t('uploadWarning').replace('$1', rawFiles.length.toString()).replace('$2', errors.length.toString()))
  226. }
  227. setPendingFiles(rawFiles);
  228. setIsIndexingModalOpen(true);
  229. }
  230. const handleConfirmIndexing = async (config: IndexingConfig) => {
  231. if (!authToken) return
  232. let hasSuccess = false
  233. for (const rawFile of pendingFiles) {
  234. try {
  235. await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
  236. hasSuccess = true
  237. } catch (error) {
  238. console.error(`Error uploading file ${rawFile.name}:`, error)
  239. showError(t('uploadFailed').replace('$1', rawFile.name).replace('$2', error.message))
  240. }
  241. }
  242. if (hasSuccess) {
  243. await fetchAndSetFiles()
  244. }
  245. setPendingFiles([])
  246. setIsIndexingModalOpen(false)
  247. }
  248. const handleCancelIndexing = () => {
  249. setPendingFiles([])
  250. setIsIndexingModalOpen(false)
  251. }
  252. const handleGroupsChange = (newGroups: KnowledgeGroup[]) => {
  253. setGroups(newGroups)
  254. }
  255. const handleSelectHistory = async (historyId: string) => {
  256. try {
  257. const historyDetail = await searchHistoryService.getHistoryDetail(historyId)
  258. setCurrentHistoryId(historyId)
  259. setIsHistoryOpen(false)
  260. setHistoryMessages(historyDetail.messages)
  261. } catch (error) {
  262. console.error('Failed to load history detail:', error)
  263. showError(t('loadHistoryFailed'))
  264. }
  265. }
  266. const handleShowHistory = () => {
  267. setIsHistoryOpen(true)
  268. }
  269. const handleInputFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  270. if (e.target.files && e.target.files.length > 0) {
  271. handleFileUpload(e.target.files)
  272. }
  273. if (fileInputRef.current) {
  274. fileInputRef.current.value = ''
  275. }
  276. }
  277. if (isLoadingSettings) {
  278. return (
  279. <div className='flex items-center justify-center min-h-screen bg-slate-50 w-full'>
  280. <div className='text-blue-600 animate-spin rounded-full h-12 w-12 border-4 border-t-4 border-blue-300'></div>
  281. <p className='ml-4 text-slate-700'>{t('loadingUserData')}</p>
  282. </div>
  283. )
  284. }
  285. return (
  286. <div className='flex h-full w-full bg-transparent overflow-hidden relative'>
  287. <input
  288. type="file"
  289. ref={fileInputRef}
  290. onChange={handleInputFileChange}
  291. multiple
  292. className="hidden"
  293. accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.html,.csv,.rtf,.odt,.ods,.odp,.json,.js,.jsx,.ts,.tsx,.css,.xml,image/*"
  294. />
  295. {/* Main Content */}
  296. <div className='flex-1 flex flex-col h-full w-full relative overflow-hidden'>
  297. {/* Header */}
  298. <div className="px-8 pt-8 pb-4 flex items-start justify-between shrink-0 z-20">
  299. <div>
  300. <h1 className="text-2xl font-bold text-slate-900 leading-tight">
  301. {t('chatTitle')}
  302. </h1>
  303. <p className="text-[15px] text-slate-500 mt-1">{t('chatDesc')}</p>
  304. </div>
  305. <div className='flex items-center gap-3 flex-shrink-0'>
  306. {/* 历史记录按钮 */}
  307. <button
  308. onClick={handleShowHistory}
  309. 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"
  310. >
  311. <History size={18} />
  312. {t('viewHistory')}
  313. </button>
  314. {/* 新建对话按钮 */}
  315. <button
  316. onClick={handleNewChat}
  317. 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"
  318. >
  319. <Plus size={18} />
  320. {t('newChat')}
  321. </button>
  322. </div>
  323. </div>
  324. <div className='flex-1 overflow-hidden'>
  325. <ChatInterface
  326. files={files}
  327. settings={settings}
  328. models={modelConfigs}
  329. groups={groups}
  330. selectedGroups={selectedGroups}
  331. onGroupSelectionChange={setSelectedGroups}
  332. onOpenGroupSelection={() => setIsGroupSelectionOpen(true)} // Pass handler
  333. selectedFiles={selectedFiles}
  334. onClearFileSelection={() => setSelectedFiles([])}
  335. onMobileUploadClick={() => {
  336. fileInputRef.current?.click()
  337. }}
  338. currentHistoryId={currentHistoryId}
  339. historyMessages={historyMessages}
  340. onHistoryMessagesLoaded={() => setHistoryMessages(null)}
  341. onHistoryIdCreated={setCurrentHistoryId}
  342. onPreviewSource={setPreviewSource}
  343. onOpenFile={(source) => {
  344. if (source.fileId) {
  345. if (isFormatSupportedForPreview(source.fileName)) {
  346. setPdfPreview({ fileId: source.fileId, fileName: source.fileName });
  347. } else {
  348. showWarning(t('previewNotSupported'));
  349. }
  350. }
  351. }}
  352. />
  353. </div>
  354. </div>
  355. {/* Modals */}
  356. <IndexingModalWithMode
  357. isOpen={isIndexingModalOpen}
  358. onClose={handleCancelIndexing}
  359. files={pendingFiles}
  360. embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
  361. defaultEmbeddingId={settings.selectedEmbeddingId}
  362. onConfirm={handleConfirmIndexing}
  363. />
  364. {/* Group Selection Drawer */}
  365. <GroupSelectionDrawer
  366. isOpen={isGroupSelectionOpen}
  367. onClose={() => setIsGroupSelectionOpen(false)}
  368. groups={groups}
  369. selectedGroups={selectedGroups}
  370. onSelectionChange={setSelectedGroups}
  371. />
  372. {/* 知识库增强功能模态框 (Legacy) */}
  373. {isGroupManagerOpen && (
  374. <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
  375. <div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
  376. <div className="flex items-center justify-between mb-4">
  377. <h2 className="text-xl font-semibold">{t('notebooks')}</h2>
  378. <button
  379. onClick={() => setIsGroupManagerOpen(false)}
  380. className="text-gray-400 hover:text-gray-600"
  381. >
  382. <X size={24} />
  383. </button>
  384. </div>
  385. <GroupManager
  386. groups={groups}
  387. onGroupsChange={handleGroupsChange}
  388. />
  389. </div>
  390. </div>
  391. )}
  392. <HistoryDrawer
  393. isOpen={isHistoryOpen}
  394. onClose={() => setIsHistoryOpen(false)}
  395. groups={groups}
  396. onSelectHistory={handleSelectHistory}
  397. />
  398. {pdfPreview && (
  399. <PDFPreview
  400. fileId={pdfPreview.fileId}
  401. fileName={pdfPreview.fileName}
  402. authToken={authToken}
  403. onClose={() => setPdfPreview(null)}
  404. />
  405. )}
  406. <SourcePreviewDrawer
  407. isOpen={!!previewSource}
  408. onClose={() => setPreviewSource(null)}
  409. source={previewSource}
  410. onOpenFile={(source) => {
  411. if (source.fileId) {
  412. if (isFormatSupportedForPreview(source.fileName)) {
  413. setPdfPreview({ fileId: source.fileId, fileName: source.fileName });
  414. } else {
  415. showWarning(t('previewNotSupported'));
  416. }
  417. }
  418. }}
  419. />
  420. </div>
  421. )
  422. }