ChatView.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  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. // 個人設定(言語など)とグローバル設定(RAGパラメータなど)を並列で取得
  88. const [userSettings, globalSettings] = await Promise.all([
  89. userSettingService.get(authToken),
  90. userSettingService.getGlobal(authToken)
  91. ]);
  92. const appSettings: AppSettings = {
  93. // 言語は個人の設定を尊重
  94. language: userSettings.language ?? globalSettings.language ?? DEFAULT_SETTINGS.language,
  95. // すべてのパラメータをグローバル設定優先にする
  96. selectedLLMId: globalSettings.selectedLLMId ?? userSettings.selectedLLMId ?? DEFAULT_SETTINGS.selectedLLMId,
  97. selectedEmbeddingId: globalSettings.selectedEmbeddingId ?? userSettings.selectedEmbeddingId ?? DEFAULT_SETTINGS.selectedEmbeddingId,
  98. selectedRerankId: globalSettings.selectedRerankId ?? userSettings.selectedRerankId ?? '',
  99. temperature: globalSettings.temperature ?? userSettings.temperature ?? DEFAULT_SETTINGS.temperature,
  100. maxTokens: globalSettings.maxTokens ?? userSettings.maxTokens ?? DEFAULT_SETTINGS.maxTokens,
  101. enableRerank: globalSettings.enableRerank ?? userSettings.enableRerank ?? DEFAULT_SETTINGS.enableRerank,
  102. topK: globalSettings.topK ?? userSettings.topK ?? DEFAULT_SETTINGS.topK,
  103. similarityThreshold: globalSettings.similarityThreshold ?? userSettings.similarityThreshold ?? DEFAULT_SETTINGS.similarityThreshold,
  104. rerankSimilarityThreshold: globalSettings.rerankSimilarityThreshold ?? userSettings.rerankSimilarityThreshold ?? DEFAULT_SETTINGS.rerankSimilarityThreshold,
  105. enableFullTextSearch: globalSettings.enableFullTextSearch ?? userSettings.enableFullTextSearch ?? DEFAULT_SETTINGS.enableFullTextSearch,
  106. hybridVectorWeight: globalSettings.hybridVectorWeight ?? userSettings.hybridVectorWeight ?? DEFAULT_SETTINGS.hybridVectorWeight
  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-slate-50 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. {/* Header */}
  299. <div className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between shrink-0 z-20">
  300. <div>
  301. <h1 className="text-xl font-bold text-slate-800 flex items-center gap-2">
  302. <Sparkles className="w-6 h-6 text-blue-600" />
  303. <span className="bg-gradient-to-r from-blue-600 to-purple-600 text-transparent bg-clip-text">
  304. {t('chatTitle')}
  305. </span>
  306. </h1>
  307. <p className="text-sm text-slate-500 mt-1">{t('chatDesc')}</p>
  308. </div>
  309. <div className='flex items-center gap-2 flex-shrink-0'>
  310. {/* 历史记录按钮 */}
  311. <button
  312. onClick={handleShowHistory}
  313. className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors flex-shrink-0"
  314. title={t('viewHistory')}
  315. >
  316. <History size={20} />
  317. </button>
  318. {/* 新建对话按钮 */}
  319. <button
  320. onClick={handleNewChat}
  321. className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors flex-shrink-0"
  322. title={t('newChat')}
  323. >
  324. <Plus size={24} />
  325. </button>
  326. </div>
  327. </div>
  328. <div className='flex-1 overflow-hidden'>
  329. <ChatInterface
  330. files={files}
  331. settings={settings}
  332. models={modelConfigs}
  333. groups={groups}
  334. selectedGroups={selectedGroups}
  335. onGroupSelectionChange={setSelectedGroups}
  336. onOpenGroupSelection={() => setIsGroupSelectionOpen(true)} // Pass handler
  337. selectedFiles={selectedFiles}
  338. onClearFileSelection={() => setSelectedFiles([])}
  339. onMobileUploadClick={() => {
  340. fileInputRef.current?.click()
  341. }}
  342. currentHistoryId={currentHistoryId}
  343. historyMessages={historyMessages}
  344. onHistoryMessagesLoaded={() => setHistoryMessages(null)}
  345. onHistoryIdCreated={setCurrentHistoryId}
  346. onPreviewSource={setPreviewSource}
  347. onOpenFile={(source) => {
  348. if (source.fileId) {
  349. if (isFormatSupportedForPreview(source.fileName)) {
  350. setPdfPreview({ fileId: source.fileId, fileName: source.fileName });
  351. } else {
  352. showWarning(t('previewNotSupported'));
  353. }
  354. }
  355. }}
  356. />
  357. </div>
  358. </div>
  359. {/* Modals */}
  360. <IndexingModalWithMode
  361. isOpen={isIndexingModalOpen}
  362. onClose={handleCancelIndexing}
  363. files={pendingFiles}
  364. embeddingModels={modelConfigs.filter(m => m.type === ModelType.EMBEDDING)}
  365. defaultEmbeddingId={settings.selectedEmbeddingId}
  366. onConfirm={handleConfirmIndexing}
  367. />
  368. {/* Group Selection Drawer */}
  369. <GroupSelectionDrawer
  370. isOpen={isGroupSelectionOpen}
  371. onClose={() => setIsGroupSelectionOpen(false)}
  372. groups={groups}
  373. selectedGroups={selectedGroups}
  374. onSelectionChange={setSelectedGroups}
  375. />
  376. {/* 知识库增强功能模态框 (Legacy) */}
  377. {isGroupManagerOpen && (
  378. <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
  379. <div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
  380. <div className="flex items-center justify-between mb-4">
  381. <h2 className="text-xl font-semibold">{t('notebooks')}</h2>
  382. <button
  383. onClick={() => setIsGroupManagerOpen(false)}
  384. className="text-gray-400 hover:text-gray-600"
  385. >
  386. <X size={24} />
  387. </button>
  388. </div>
  389. <GroupManager
  390. groups={groups}
  391. onGroupsChange={handleGroupsChange}
  392. />
  393. </div>
  394. </div>
  395. )}
  396. <HistoryDrawer
  397. isOpen={isHistoryOpen}
  398. onClose={() => setIsHistoryOpen(false)}
  399. groups={groups}
  400. onSelectHistory={handleSelectHistory}
  401. />
  402. {pdfPreview && (
  403. <PDFPreview
  404. fileId={pdfPreview.fileId}
  405. fileName={pdfPreview.fileName}
  406. authToken={authToken}
  407. onClose={() => setPdfPreview(null)}
  408. />
  409. )}
  410. <SourcePreviewDrawer
  411. isOpen={!!previewSource}
  412. onClose={() => setPreviewSource(null)}
  413. source={previewSource}
  414. onOpenFile={(source) => {
  415. if (source.fileId) {
  416. if (isFormatSupportedForPreview(source.fileName)) {
  417. setPdfPreview({ fileId: source.fileId, fileName: source.fileName });
  418. } else {
  419. showWarning(t('previewNotSupported'));
  420. }
  421. }
  422. }}
  423. />
  424. </div>
  425. )
  426. }