ChatView.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  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 [personalSettings, tenantSettings] = await Promise.all([
  88. userSettingService.getPersonal(authToken).catch(() => null),
  89. userSettingService.get(authToken).catch(() => ({} as Partial<AppSettings>))
  90. ]);
  91. const appSettings: AppSettings = {
  92. ...DEFAULT_SETTINGS,
  93. ...tenantSettings,
  94. language: personalSettings?.language || tenantSettings?.language || DEFAULT_SETTINGS.language,
  95. };
  96. setSettings(appSettings)
  97. } catch (error) {
  98. console.error('Failed to fetch settings:', error)
  99. setSettings(DEFAULT_SETTINGS)
  100. } finally {
  101. setIsLoadingSettings(false)
  102. }
  103. }, [authToken])
  104. const fetchAndSetFiles = useCallback(async () => {
  105. if (!authToken) return
  106. try {
  107. const data = await knowledgeBaseService.getAll(authToken)
  108. setFiles(data.items)
  109. } catch (error) {
  110. console.error('Failed to fetch files:', error)
  111. }
  112. }, [authToken])
  113. // Function to fetch groups from backend
  114. const fetchAndSetGroups = useCallback(async () => {
  115. if (!authToken) return
  116. try {
  117. const remoteGroups = await knowledgeGroupService.getGroups()
  118. setGroups(remoteGroups)
  119. // Filter out selected groups that no longer exist
  120. setSelectedGroups(prev => {
  121. const validGroupIds = new Set(remoteGroups.map(g => g.id))
  122. return prev.filter(id => validGroupIds.has(id))
  123. })
  124. } catch (error) {
  125. console.error('Failed to fetch groups:', error)
  126. }
  127. }, [authToken])
  128. useEffect(() => {
  129. if (authToken) {
  130. fetchAndSetSettings()
  131. fetchAndSetFiles()
  132. fetchAndSetGroups()
  133. }
  134. }, [authToken, fetchAndSetSettings, fetchAndSetFiles, fetchAndSetGroups])
  135. // Handle Initial Context
  136. useEffect(() => {
  137. if (initialChatContext) {
  138. if (initialChatContext.selectedGroups) {
  139. setSelectedGroups(initialChatContext.selectedGroups)
  140. }
  141. if (initialChatContext.selectedFiles) {
  142. setSelectedFiles(initialChatContext.selectedFiles)
  143. }
  144. }
  145. }, [initialChatContext])
  146. // Load chat history from localStorage on mount
  147. useEffect(() => {
  148. const savedHistory = localStorage.getItem('chatMessages');
  149. if (savedHistory) {
  150. try {
  151. const parsedHistory = JSON.parse(savedHistory);
  152. if (Array.isArray(parsedHistory) && parsedHistory.length > 0) {
  153. setHistoryMessages(parsedHistory);
  154. }
  155. } catch (error) {
  156. console.error('Failed to parse saved chat history:', error);
  157. }
  158. }
  159. }, []);
  160. const handleFileUpload = async (fileList: FileList) => {
  161. if (!authToken) {
  162. showWarning(t('loginToUpload'))
  163. return
  164. }
  165. const MAX_FILE_SIZE = 104857600
  166. const MAX_SIZE_MB = 100
  167. const rawFiles: RawFile[] = []
  168. const errors: string[] = []
  169. for (let i = 0; i < fileList.length; i++) {
  170. const file = fileList[i]
  171. if (file.size > MAX_FILE_SIZE) {
  172. errors.push(t('fileSizeLimitExceeded').replace('$1', file.name).replace('$2', formatBytes(file.size)).replace('$3', MAX_SIZE_MB.toString()))
  173. continue
  174. }
  175. const allowedTypes = [
  176. 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  177. 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  178. 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  179. 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet',
  180. 'application/vnd.oasis.opendocument.presentation', 'application/vnd.oasis.opendocument.graphics',
  181. 'text/plain', 'text/markdown', 'text/html', 'text/csv', 'text/xml', 'application/xml', 'application/json',
  182. 'text/x-python', 'text/x-java', 'text/x-c', 'text/x-c++', 'text/javascript', 'text/typescript',
  183. 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/tiff', 'image/bmp', 'image/svg+xml',
  184. 'application/zip', 'application/x-tar', 'application/gzip', 'application/x-7z-compressed',
  185. 'application/rtf', 'application/epub+zip', 'application/x-mobipocket-ebook',
  186. ]
  187. const ext = file.name.toLowerCase().split('.').pop()
  188. const allowedExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'html', 'csv', 'rtf', 'odt', 'ods', 'odp', 'json', 'xml']
  189. const isAllowed = allowedTypes.includes(file.type) ||
  190. file.type.startsWith('text/') ||
  191. file.type.startsWith('application/vnd.') ||
  192. file.type.startsWith('application/x-') ||
  193. file.type === '' ||
  194. allowedExtensions.includes(ext || '')
  195. if (!isAllowed) {
  196. errors.push(t('unsupportedFileType').replace('$1', file.name).replace('$2', file.type || 'unknown'))
  197. continue
  198. }
  199. try {
  200. const rawFile = await readFile(file)
  201. rawFiles.push(rawFile)
  202. } catch (error) {
  203. console.error(`Error reading file ${file.name}:`, error)
  204. errors.push(t('readFailed').replace('$1', file.name))
  205. }
  206. }
  207. if (errors.length > 0) {
  208. showError(`${t('uploadErrors')}:\n${errors.join('\n')}`)
  209. }
  210. if (rawFiles.length === 0) return
  211. if (errors.length > 0 && rawFiles.length > 0) {
  212. showWarning(t('uploadWarning').replace('$1', rawFiles.length.toString()).replace('$2', errors.length.toString()))
  213. }
  214. setPendingFiles(rawFiles);
  215. setIsIndexingModalOpen(true);
  216. }
  217. const handleConfirmIndexing = async (config: IndexingConfig) => {
  218. if (!authToken) return
  219. let hasSuccess = false
  220. for (const rawFile of pendingFiles) {
  221. try {
  222. await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
  223. hasSuccess = true
  224. } catch (error) {
  225. console.error(`Error uploading file ${rawFile.name}:`, error)
  226. showError(t('uploadFailed').replace('$1', rawFile.name).replace('$2', error.message))
  227. }
  228. }
  229. if (hasSuccess) {
  230. await fetchAndSetFiles()
  231. }
  232. setPendingFiles([])
  233. setIsIndexingModalOpen(false)
  234. }
  235. const handleCancelIndexing = () => {
  236. setPendingFiles([])
  237. setIsIndexingModalOpen(false)
  238. }
  239. const handleGroupsChange = (newGroups: KnowledgeGroup[]) => {
  240. setGroups(newGroups)
  241. }
  242. const handleSelectHistory = async (historyId: string) => {
  243. try {
  244. const historyDetail = await searchHistoryService.getHistoryDetail(historyId)
  245. setCurrentHistoryId(historyId)
  246. setIsHistoryOpen(false)
  247. setHistoryMessages(historyDetail.messages)
  248. } catch (error) {
  249. console.error('Failed to load history detail:', error)
  250. showError(t('loadHistoryFailed'))
  251. }
  252. }
  253. const handleShowHistory = () => {
  254. setIsHistoryOpen(true)
  255. }
  256. const handleInputFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  257. if (e.target.files && e.target.files.length > 0) {
  258. handleFileUpload(e.target.files)
  259. }
  260. if (fileInputRef.current) {
  261. fileInputRef.current.value = ''
  262. }
  263. }
  264. if (isLoadingSettings) {
  265. return (
  266. <div className='flex items-center justify-center min-h-screen bg-slate-50 w-full'>
  267. <div className='text-blue-600 animate-spin rounded-full h-12 w-12 border-4 border-t-4 border-blue-300'></div>
  268. <p className='ml-4 text-slate-700'>{t('loadingUserData')}</p>
  269. </div>
  270. )
  271. }
  272. return (
  273. <div className='flex h-full w-full bg-transparent overflow-hidden relative'>
  274. <input
  275. type="file"
  276. ref={fileInputRef}
  277. onChange={handleInputFileChange}
  278. multiple
  279. className="hidden"
  280. accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.html,.csv,.rtf,.odt,.ods,.odp,.json,.js,.jsx,.ts,.tsx,.css,.xml,image/*"
  281. />
  282. {/* Main Content */}
  283. <div className='flex-1 flex flex-col h-full w-full relative overflow-hidden'>
  284. {/* Header */}
  285. <div className="px-8 pt-8 pb-4 flex items-start justify-between shrink-0 z-20">
  286. <div>
  287. <h1 className="text-2xl font-bold text-slate-900 leading-tight">
  288. {t('chatTitle')}
  289. </h1>
  290. <p className="text-[15px] text-slate-500 mt-1">{t('chatDesc')}</p>
  291. </div>
  292. <div className='flex items-center gap-3 flex-shrink-0'>
  293. {/* 历史记录按钮 */}
  294. <button
  295. onClick={handleShowHistory}
  296. 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"
  297. >
  298. <History size={18} />
  299. {t('viewHistory')}
  300. </button>
  301. {/* New chat button */}
  302. <button
  303. onClick={handleNewChat}
  304. 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"
  305. >
  306. <Plus size={18} />
  307. {t('newChat')}
  308. </button>
  309. </div>
  310. </div>
  311. <div className='flex-1 overflow-hidden'>
  312. <ChatInterface
  313. files={files}
  314. settings={settings}
  315. models={modelConfigs}
  316. groups={groups}
  317. selectedGroups={selectedGroups}
  318. onGroupSelectionChange={setSelectedGroups}
  319. onOpenGroupSelection={() => setIsGroupSelectionOpen(true)} // Pass handler
  320. selectedFiles={selectedFiles}
  321. onClearFileSelection={() => setSelectedFiles([])}
  322. onMobileUploadClick={() => {
  323. fileInputRef.current?.click()
  324. }}
  325. currentHistoryId={currentHistoryId}
  326. historyMessages={historyMessages}
  327. onHistoryMessagesLoaded={() => setHistoryMessages(null)}
  328. onHistoryIdCreated={setCurrentHistoryId}
  329. onPreviewSource={setPreviewSource}
  330. authToken={authToken}
  331. onOpenFile={(source) => {
  332. if (source.fileId) {
  333. if (isFormatSupportedForPreview(source.fileName)) {
  334. setPdfPreview({ fileId: source.fileId, fileName: source.fileName });
  335. } else {
  336. showWarning(t('previewNotSupported'));
  337. }
  338. }
  339. }}
  340. />
  341. </div>
  342. </div>
  343. {/* Modals */}
  344. <IndexingModalWithMode
  345. isOpen={isIndexingModalOpen}
  346. onClose={handleCancelIndexing}
  347. files={pendingFiles}
  348. embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
  349. defaultEmbeddingId={settings.selectedEmbeddingId}
  350. onConfirm={handleConfirmIndexing}
  351. />
  352. {/* Group Selection Drawer */}
  353. <GroupSelectionDrawer
  354. isOpen={isGroupSelectionOpen}
  355. onClose={() => setIsGroupSelectionOpen(false)}
  356. groups={groups}
  357. selectedGroups={selectedGroups}
  358. onSelectionChange={setSelectedGroups}
  359. />
  360. {/* Knowledge base enhancement features modal (Legacy) */}
  361. {isGroupManagerOpen && (
  362. <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
  363. <div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
  364. <div className="flex items-center justify-between mb-4">
  365. <h2 className="text-xl font-semibold">{t('notebooks')}</h2>
  366. <button
  367. onClick={() => setIsGroupManagerOpen(false)}
  368. className="text-gray-400 hover:text-gray-600"
  369. >
  370. <X size={24} />
  371. </button>
  372. </div>
  373. <GroupManager
  374. groups={groups}
  375. onGroupsChange={handleGroupsChange}
  376. />
  377. </div>
  378. </div>
  379. )}
  380. <HistoryDrawer
  381. isOpen={isHistoryOpen}
  382. onClose={() => setIsHistoryOpen(false)}
  383. groups={groups}
  384. onSelectHistory={handleSelectHistory}
  385. />
  386. {pdfPreview && (
  387. <PDFPreview
  388. fileId={pdfPreview.fileId}
  389. fileName={pdfPreview.fileName}
  390. authToken={authToken}
  391. onClose={() => setPdfPreview(null)}
  392. />
  393. )}
  394. <SourcePreviewDrawer
  395. isOpen={!!previewSource}
  396. onClose={() => setPreviewSource(null)}
  397. source={previewSource}
  398. onOpenFile={(source) => {
  399. if (source.fileId) {
  400. if (isFormatSupportedForPreview(source.fileName)) {
  401. setPdfPreview({ fileId: source.fileId, fileName: source.fileName });
  402. } else {
  403. showWarning(t('previewNotSupported'));
  404. }
  405. }
  406. }}
  407. />
  408. </div>
  409. )
  410. }