AIAssistant.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. import * as React from 'react';
  2. import { ChatMessage, AIAssistantSettings } from '../types';
  3. import { sendChatMessage, createAIChatSession } from '../services/geminiService';
  4. import { Icon } from './ui/Icon';
  5. import { Tabs } from './ui/Tabs';
  6. import { useTranslation } from '../hooks/useI18n';
  7. const ChatWindow: React.FC = () => {
  8. const { t } = useTranslation();
  9. const [messages, setMessages] = React.useState<ChatMessage[]>([]);
  10. const [userInput, setUserInput] = React.useState('');
  11. const [isLoading, setIsLoading] = React.useState(false);
  12. const messagesEndRef = React.useRef<HTMLDivElement>(null);
  13. React.useEffect(() => {
  14. messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  15. }, [messages, isLoading]);
  16. const handleSendMessage = React.useCallback(async () => {
  17. if (!userInput.trim()) return;
  18. const userMessage: ChatMessage = { id: `msg-user-${Date.now()}`, sender: 'user', text: userInput };
  19. setMessages(prev => [...prev, userMessage]);
  20. const messageToSend = userInput;
  21. setUserInput('');
  22. setIsLoading(true);
  23. const aiResponse = await sendChatMessage(messageToSend);
  24. setMessages(prev => [...prev, aiResponse]);
  25. setIsLoading(false);
  26. }, [userInput]);
  27. return (
  28. <div className="flex flex-col h-full bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
  29. <div className="p-4 border-b border-gray-200 dark:border-gray-700">
  30. <h3 className="font-semibold text-lg text-gray-900 dark:text-white">{t('ai_assistant.test_title')}</h3>
  31. </div>
  32. <div className="flex-1 p-4 overflow-y-auto space-y-4">
  33. {messages.map(msg => (
  34. <div key={msg.id} className={`flex items-start gap-3 ${msg.sender === 'user' ? 'justify-end' : ''}`}>
  35. {msg.sender === 'ai' && (
  36. <div className="w-8 h-8 rounded-full bg-brand-primary/20 text-brand-primary flex items-center justify-center flex-shrink-0">
  37. <Icon className="h-5 w-5"><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></Icon>
  38. </div>
  39. )}
  40. <div className={`max-w-md p-3 rounded-lg ${msg.sender === 'user' ? 'bg-blue-600 text-white rounded-br-none' : 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-bl-none'}`}>
  41. <p className="text-sm">{msg.text}</p>
  42. </div>
  43. {msg.sender === 'user' && (
  44. <div className="w-8 h-8 rounded-full bg-gray-500 dark:bg-gray-600 flex items-center justify-center flex-shrink-0 text-white">
  45. <Icon className="h-5 w-5"><path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></Icon>
  46. </div>
  47. )}
  48. </div>
  49. ))}
  50. {isLoading && (
  51. <div className="flex items-start gap-3">
  52. <div className="w-8 h-8 rounded-full bg-brand-primary/20 text-brand-primary flex items-center justify-center flex-shrink-0">
  53. <Icon className="h-5 w-5"><path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></Icon>
  54. </div>
  55. <div className="max-w-md p-3 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-bl-none">
  56. <div className="flex items-center space-x-1">
  57. <span className="w-2 h-2 bg-gray-400 dark:bg-gray-400 rounded-full animate-pulse delay-0"></span>
  58. <span className="w-2 h-2 bg-gray-400 dark:bg-gray-400 rounded-full animate-pulse delay-150"></span>
  59. <span className="w-2 h-2 bg-gray-400 dark:bg-gray-400 rounded-full animate-pulse delay-300"></span>
  60. </div>
  61. </div>
  62. </div>
  63. )}
  64. <div ref={messagesEndRef} />
  65. </div>
  66. <div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center gap-3">
  67. <input
  68. type="text"
  69. value={userInput}
  70. onChange={(e) => setUserInput(e.target.value)}
  71. onKeyPress={(e) => e.key === 'Enter' && !isLoading && handleSendMessage()}
  72. placeholder="Type your message..."
  73. className="flex-1 bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white p-2 rounded-md border-2 border-gray-300 dark:border-gray-600 focus:outline-none focus:border-brand-primary focus:ring-1 focus:ring-brand-primary/50"
  74. disabled={isLoading}
  75. />
  76. <button onClick={handleSendMessage} disabled={isLoading} className="px-4 py-2 bg-brand-primary text-white font-semibold rounded-md hover:bg-brand-secondary disabled:bg-gray-500 transition-colors">
  77. Send
  78. </button>
  79. </div>
  80. </div>
  81. );
  82. };
  83. const sectionClasses = "bg-white dark:bg-gray-800/50 p-6 rounded-lg border border-gray-200 dark:border-gray-700/50";
  84. const labelClasses = "block text-lg font-semibold text-gray-900 dark:text-white";
  85. const descriptionClasses = "text-sm text-gray-500 dark:text-gray-400 mt-1 mb-4";
  86. const inputBaseClasses = "w-full text-gray-900 dark:text-white p-3 rounded-md border focus:outline-none focus:border-brand-primary focus:ring-0 transition-colors";
  87. const inputBgClasses = "bg-gray-100/50 dark:bg-black/20 border-gray-300 dark:border-white/10";
  88. const tagColors = [
  89. { bg: 'bg-red-500', text: 'text-white', closeButton: 'text-red-100 hover:text-white' },
  90. { bg: 'bg-blue-600', text: 'text-white', closeButton: 'text-blue-100 hover:text-white' },
  91. { bg: 'bg-green-600', text: 'text-white', closeButton: 'text-green-100 hover:text-white' },
  92. { bg: 'bg-yellow-500', text: 'text-black', closeButton: 'text-yellow-800 hover:text-black' },
  93. { bg: 'bg-purple-600', text: 'text-white', closeButton: 'text-purple-100 hover:text-white' },
  94. { bg: 'bg-indigo-600', text: 'text-white', closeButton: 'text-indigo-100 hover:text-white' },
  95. { bg: 'bg-pink-600', text: 'text-white', closeButton: 'text-pink-100 hover:text-white' },
  96. { bg: 'bg-teal-500', text: 'text-white', closeButton: 'text-teal-100 hover:text-white' },
  97. ];
  98. const getColorForKeyword = (keyword: string) => {
  99. let hash = 0;
  100. for (let i = 0; i < keyword.length; i++) {
  101. hash = keyword.charCodeAt(i) + ((hash << 5) - hash);
  102. hash = hash & hash;
  103. }
  104. const index = Math.abs(hash % tagColors.length);
  105. return tagColors[index];
  106. };
  107. const KeywordInput: React.FC<{
  108. label: string;
  109. description: string;
  110. keywords: string[];
  111. onAdd: (keyword: string) => void;
  112. onRemove: (keyword: string) => void;
  113. }> = ({ label, description, keywords, onAdd, onRemove }) => {
  114. const { t } = useTranslation();
  115. const [newKeyword, setNewKeyword] = React.useState('');
  116. const handleAdd = () => {
  117. if (newKeyword.trim() && !keywords.includes(newKeyword.trim())) {
  118. onAdd(newKeyword.trim());
  119. setNewKeyword('');
  120. }
  121. };
  122. return (
  123. <div className={sectionClasses}>
  124. <label className={labelClasses}>{label}</label>
  125. <p className={descriptionClasses}>{description}</p>
  126. <div className="flex items-center gap-2">
  127. <input
  128. type="text"
  129. value={newKeyword}
  130. onChange={(e) => setNewKeyword(e.target.value)}
  131. onKeyPress={(e) => e.key === 'Enter' && handleAdd()}
  132. className={`${inputBaseClasses} ${inputBgClasses}`}
  133. placeholder={`${t('add')} a keyword...`}
  134. />
  135. <button onClick={handleAdd} className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-sm font-semibold rounded-md hover:bg-gray-300 dark:hover:bg-gray-500">{t('add')}</button>
  136. </div>
  137. <div className="flex flex-wrap gap-3 mt-4 min-h-[3rem]">
  138. {keywords.map(kw => {
  139. const color = getColorForKeyword(kw);
  140. return (
  141. <div key={kw} className={`flex items-center gap-2 ${color.bg} ${color.text} text-base font-bold px-4 py-2 rounded-full`}>
  142. <span>{kw}</span>
  143. <button onClick={() => onRemove(kw)} className={`${color.closeButton} text-lg font-bold leading-none transition-colors`}>×</button>
  144. </div>
  145. );
  146. })}
  147. </div>
  148. </div>
  149. );
  150. };
  151. const KnowledgeBaseModal: React.FC<{
  152. onClose: () => void;
  153. onAddFiles: (files: File[]) => void;
  154. onAddUrl: (url: string) => void;
  155. }> = ({ onClose, onAddFiles, onAddUrl }) => {
  156. const { t } = useTranslation();
  157. const [activeTab, setActiveTab] = React.useState<'files' | 'web'>('files');
  158. const [newUrl, setNewUrl] = React.useState('');
  159. const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  160. if (e.target.files) {
  161. onAddFiles(Array.from(e.target.files));
  162. onClose();
  163. }
  164. };
  165. const handleAddUrl = () => {
  166. if (newUrl.trim()) {
  167. try {
  168. new URL(newUrl.trim());
  169. onAddUrl(newUrl.trim());
  170. onClose();
  171. } catch (_) {
  172. alert('Please enter a valid URL.');
  173. }
  174. }
  175. };
  176. return (
  177. <div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center backdrop-blur-sm" onClick={onClose}>
  178. <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-2xl border border-gray-200 dark:border-gray-700 flex flex-col" onClick={e => e.stopPropagation()}>
  179. <div className="p-6 border-b border-gray-200 dark:border-gray-700">
  180. <h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('ai_assistant.add_knowledge')}</h2>
  181. </div>
  182. <div className="p-6">
  183. <Tabs tabs={['files', 'web']} activeTab={activeTab} onTabClick={(t) => setActiveTab(t as 'files' | 'web')} />
  184. </div>
  185. <div className="p-6 pt-0 flex-1">
  186. {activeTab === 'files' ? (
  187. <div className="mt-1 flex justify-center px-6 pt-10 pb-10 border-2 border-gray-300 dark:border-gray-600 border-dashed rounded-md h-full">
  188. <div className="space-y-1 text-center">
  189. <Icon className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"><path d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></Icon>
  190. <div className="flex text-sm text-gray-500 dark:text-gray-400">
  191. <label htmlFor="file-upload" className="relative cursor-pointer bg-white dark:bg-gray-800 rounded-md font-medium text-brand-primary hover:text-brand-secondary focus-within:outline-none">
  192. <span>Upload files</span>
  193. <input id="file-upload" name="file-upload" type="file" multiple className="sr-only" onChange={handleFileChange} />
  194. </label>
  195. <p className="pl-1">or drag and drop</p>
  196. </div>
  197. <p className="text-xs text-gray-400 dark:text-gray-500">PDF, TXT, DOCX up to 10MB</p>
  198. </div>
  199. </div>
  200. ) : (
  201. <div>
  202. <label htmlFor="url-input" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Add from Web</label>
  203. <div className="flex items-center gap-2">
  204. <input id="url-input" type="url" value={newUrl} onChange={e => setNewUrl(e.target.value)} onKeyPress={e => e.key === 'Enter' && handleAddUrl()}
  205. className={`${inputBaseClasses} ${inputBgClasses}`}
  206. placeholder="https://example.com/about" />
  207. <button onClick={handleAddUrl} className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-sm font-semibold rounded-md hover:bg-gray-300 dark:hover:bg-gray-500">{t('add')} URL</button>
  208. </div>
  209. </div>
  210. )}
  211. </div>
  212. </div>
  213. </div>
  214. );
  215. };
  216. interface EditorProps {
  217. settings: AIAssistantSettings;
  218. updateSettings: (updates: Partial<AIAssistantSettings>) => void;
  219. }
  220. const PersonaEditor: React.FC<EditorProps> = ({ settings, updateSettings }) => {
  221. const { t } = useTranslation();
  222. return (
  223. <div className="space-y-8">
  224. <div className={sectionClasses}>
  225. <label htmlFor="persona" className={labelClasses}>{t('ai_assistant.system_instruction')}</label>
  226. <p className={descriptionClasses}>{t('ai_assistant.system_instruction_desc')}</p>
  227. <textarea id="persona" rows={6} value={settings.persona} onChange={e => updateSettings({ persona: e.target.value })}
  228. className={`${inputBaseClasses} ${inputBgClasses}`}
  229. placeholder="e.g., You are a witty pirate captain who gives business advice."/>
  230. </div>
  231. <div className={sectionClasses}>
  232. <div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
  233. <div>
  234. <label htmlFor="voice" className={labelClasses}>{t('ai_assistant.voice')}</label>
  235. <p className={`${descriptionClasses} mb-2`}>{t('ai_assistant.voice_desc')}</p>
  236. <div className="flex items-center gap-2">
  237. <select id="voice" value={settings.voiceId} onChange={e => updateSettings({ voiceId: e.target.value })}
  238. className={`${inputBaseClasses} ${inputBgClasses}`}>
  239. <option value="vo1">Alloy</option><option value="vo2">Echo</option><option value="vo3">Fable</option>
  240. </select>
  241. <button className="p-3 rounded-md bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500">
  242. <Icon className="h-5 w-5"><path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.108 12 5v14c0 .892-1.077 1.337-1.707.707L5.586 15z" /></Icon>
  243. </button>
  244. </div>
  245. </div>
  246. <div>
  247. <label className={labelClasses}>{t('ai_assistant.clone')}</label>
  248. <p className={`${descriptionClasses} mb-2`}>{t('ai_assistant.clone_desc')}</p>
  249. <button disabled className="w-full p-3 rounded-md bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed flex items-center justify-center gap-2">
  250. {t('ai_assistant.clone_voice')} <span className="text-xs bg-yellow-400 text-yellow-900 px-2 py-0.5 rounded-full font-bold">{t('pro')}</span>
  251. </button>
  252. </div>
  253. <div>
  254. <label htmlFor="language" className={labelClasses}>{t('ai_assistant.language')}</label>
  255. <p className={`${descriptionClasses} mb-2`}>{t('ai_assistant.language_desc')}</p>
  256. <select id="language" value={settings.language} onChange={e => updateSettings({ language: e.target.value })}
  257. className={`${inputBaseClasses} ${inputBgClasses}`}>
  258. <option>English</option>
  259. <option>Japanese</option>
  260. <option>Chinese</option>
  261. <option>Korean</option>
  262. </select>
  263. </div>
  264. <div>
  265. <label className={labelClasses}>{t('ai_assistant.conversation_style')}</label>
  266. <p className={`${descriptionClasses} mb-2`}>{t('ai_assistant.conversation_style_desc')}</p>
  267. <div className="flex items-center gap-1 bg-gray-100/50 dark:bg-black/20 p-1 rounded-lg border border-gray-300 dark:border-white/10">
  268. {(['friendly', 'professional', 'witty'] as const).map(style => (
  269. <button key={style} onClick={() => updateSettings({ conversationStyle: style })}
  270. className={`flex-1 px-3 py-2 text-sm rounded-md capitalize transition-colors ${settings.conversationStyle === style ? 'bg-brand-primary text-white shadow' : 'hover:bg-gray-200 dark:hover:bg-gray-600'}`}>
  271. {style}
  272. </button>
  273. ))}
  274. </div>
  275. </div>
  276. </div>
  277. </div>
  278. </div>
  279. );
  280. };
  281. const KnowledgeEditor: React.FC<EditorProps & { onOpenModal: () => void }> = ({ settings, updateSettings, onOpenModal }) => {
  282. const { t } = useTranslation();
  283. const handleRemoveFile = (fileName: string) => {
  284. updateSettings({ knowledgeBaseFiles: settings.knowledgeBaseFiles.filter(f => f.name !== fileName) });
  285. };
  286. const handleRemoveUrl = (urlToRemove: string) => {
  287. updateSettings({ knowledgeBaseUrls: settings.knowledgeBaseUrls.filter(url => url !== urlToRemove) });
  288. };
  289. const hasContent = settings.knowledgeBaseFiles.length > 0 || settings.knowledgeBaseUrls.length > 0;
  290. return (
  291. <div className="space-y-8">
  292. <div>
  293. <button onClick={onOpenModal} className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-brand-primary text-white font-semibold rounded-md hover:bg-brand-secondary transition-colors">
  294. <Icon className="h-5 w-5"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></Icon>
  295. {t('ai_assistant.add_knowledge')}
  296. </button>
  297. </div>
  298. <div className={sectionClasses}>
  299. {!hasContent ? (
  300. <div className="text-center py-10">
  301. <Icon className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"><path strokeLinecap="round" strokeLinejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7l8-4 8 4m-8-4v14" /></Icon>
  302. <h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">{t('ai_assistant.no_knowledge')}</h3>
  303. <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{t('ai_assistant.no_knowledge_desc')}</p>
  304. </div>
  305. ) : (
  306. <div className="space-y-3">
  307. {settings.knowledgeBaseUrls.map(url => (
  308. <div key={url} className="flex items-center justify-between px-4 py-4 bg-gray-100 dark:bg-gray-900/50 rounded-lg">
  309. <div className="flex items-center gap-3 min-w-0">
  310. <Icon className="h-6 w-6 text-gray-500 dark:text-gray-400 flex-shrink-0"><path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></Icon>
  311. <a href={url} target="_blank" rel="noopener noreferrer" className="text-sm font-medium text-gray-900 dark:text-white truncate hover:underline">{url}</a>
  312. </div>
  313. <button onClick={() => handleRemoveUrl(url)} className="text-gray-400 dark:text-gray-500 hover:text-white p-1 rounded-full">
  314. <Icon className="h-5 w-5"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></Icon>
  315. </button>
  316. </div>
  317. ))}
  318. {(settings.knowledgeBaseFiles.length > 0 && settings.knowledgeBaseUrls.length > 0) && (
  319. <hr className="border-gray-200 dark:border-gray-700" />
  320. )}
  321. {settings.knowledgeBaseFiles.map(file => (
  322. <div key={file.name} className="flex items-center justify-between px-4 py-4 bg-gray-100 dark:bg-gray-900/50 rounded-lg">
  323. <div className="flex items-center gap-3 min-w-0">
  324. <Icon className="h-6 w-6 text-gray-500 dark:text-gray-400 flex-shrink-0"><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></Icon>
  325. <span className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</span>
  326. <span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">({(file.size / 1024).toFixed(1)} KB)</span>
  327. </div>
  328. <button onClick={() => handleRemoveFile(file.name)} className="text-gray-400 dark:text-gray-500 hover:text-white p-1 rounded-full">
  329. <Icon className="h-5 w-5"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></Icon>
  330. </button>
  331. </div>
  332. ))}
  333. </div>
  334. )}
  335. </div>
  336. </div>
  337. );
  338. };
  339. const SensitivityEditor: React.FC<EditorProps> = ({ settings, updateSettings }) => {
  340. const { t } = useTranslation();
  341. return (
  342. <div className="space-y-8">
  343. <KeywordInput
  344. label={t('ai_assistant.forbidden_user')}
  345. description={t('ai_assistant.forbidden_user_desc')}
  346. keywords={settings.forbiddenUserKeywords}
  347. onAdd={(kw) => updateSettings({ forbiddenUserKeywords: [...settings.forbiddenUserKeywords, kw] })}
  348. onRemove={(kw) => updateSettings({ forbiddenUserKeywords: settings.forbiddenUserKeywords.filter(k => k !== kw) })}
  349. />
  350. <KeywordInput
  351. label={t('ai_assistant.forbidden_ai')}
  352. description={t('ai_assistant.forbidden_ai_desc')}
  353. keywords={settings.forbiddenAIKeywords}
  354. onAdd={(kw) => updateSettings({ forbiddenAIKeywords: [...settings.forbiddenAIKeywords, kw] })}
  355. onRemove={(kw) => updateSettings({ forbiddenAIKeywords: settings.forbiddenAIKeywords.filter(k => k !== kw) })}
  356. />
  357. </div>
  358. );
  359. };
  360. interface AIAssistantProps {
  361. aiAssistantSettings: AIAssistantSettings;
  362. onUpdateSettings: (newSettings: AIAssistantSettings) => void;
  363. initialTab: 'persona' | 'knowledge' | 'sensitivity';
  364. }
  365. const AIAssistant: React.FC<AIAssistantProps> = ({ aiAssistantSettings, onUpdateSettings, initialTab }) => {
  366. const { t } = useTranslation();
  367. const [isKnowledgeModalOpen, setIsKnowledgeModalOpen] = React.useState(false);
  368. React.useEffect(() => {
  369. createAIChatSession(aiAssistantSettings.persona);
  370. }, [aiAssistantSettings.persona]);
  371. const updateSettings = (updates: Partial<AIAssistantSettings>) => {
  372. onUpdateSettings({ ...aiAssistantSettings, ...updates });
  373. };
  374. const handleAddFiles = (files: File[]) => {
  375. const newFiles = files.map(file => ({
  376. name: file.name,
  377. size: file.size,
  378. type: file.type,
  379. }));
  380. updateSettings({ knowledgeBaseFiles: [...aiAssistantSettings.knowledgeBaseFiles, ...newFiles] });
  381. };
  382. const handleAddUrl = (newUrl: string) => {
  383. if (!aiAssistantSettings.knowledgeBaseUrls.includes(newUrl)) {
  384. updateSettings({ knowledgeBaseUrls: [...aiAssistantSettings.knowledgeBaseUrls, newUrl] });
  385. }
  386. };
  387. const handleSaveChanges = () => {
  388. createAIChatSession(aiAssistantSettings.persona);
  389. alert(t('ai_assistant.save_success'));
  390. };
  391. const renderEditor = () => {
  392. switch (initialTab) {
  393. case 'persona':
  394. return <PersonaEditor settings={aiAssistantSettings} updateSettings={updateSettings} />;
  395. case 'knowledge':
  396. return <KnowledgeEditor settings={aiAssistantSettings} updateSettings={updateSettings} onOpenModal={() => setIsKnowledgeModalOpen(true)} />;
  397. case 'sensitivity':
  398. return <SensitivityEditor settings={aiAssistantSettings} updateSettings={updateSettings} />;
  399. default:
  400. return <PersonaEditor settings={aiAssistantSettings} updateSettings={updateSettings} />;
  401. }
  402. };
  403. const pageTitles = {
  404. persona: t('nav.ai_assistant.persona'),
  405. knowledge: t('nav.ai_assistant.knowledge'),
  406. sensitivity: t('nav.ai_assistant.sensitivity')
  407. };
  408. return (
  409. <>
  410. {isKnowledgeModalOpen && (
  411. <KnowledgeBaseModal
  412. onClose={() => setIsKnowledgeModalOpen(false)}
  413. onAddFiles={handleAddFiles}
  414. onAddUrl={handleAddUrl}
  415. />
  416. )}
  417. <div className="flex h-full p-8 gap-8">
  418. <div className="flex-1 flex flex-col min-w-0">
  419. <header className="flex justify-between items-center flex-shrink-0 mb-8">
  420. <div>
  421. <h2 className="text-3xl font-bold text-gray-900 dark:text-white">{t('ai_assistant.title')} / {pageTitles[initialTab]}</h2>
  422. <p className="text-gray-500 dark:text-gray-400 mt-1">{t('ai_assistant.subtitle')}</p>
  423. </div>
  424. {initialTab === 'persona' && (
  425. <button onClick={handleSaveChanges} className="bg-brand-primary text-white font-bold py-2 px-5 rounded-md hover:bg-brand-secondary transition-colors">
  426. {t('ai_assistant.save_changes')}
  427. </button>
  428. )}
  429. </header>
  430. <div className="overflow-y-auto flex-1 -mr-4 pr-4">
  431. {renderEditor()}
  432. </div>
  433. </div>
  434. <aside className="w-[450px] flex-shrink-0">
  435. <div className="sticky top-8">
  436. <ChatWindow />
  437. </div>
  438. </aside>
  439. </div>
  440. </>
  441. );
  442. };
  443. export default AIAssistant;