ChatView.tsx 21 KB

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