AICommandDrawer.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. import React, { useState, useEffect } from 'react'
  2. import { Sparkles, ArrowRight, X, RefreshCw, Check, Eraser } from 'lucide-react'
  3. import ReactMarkdown from 'react-markdown'
  4. import { chatService } from '../services/chatService'
  5. import { useLanguage } from '../contexts/LanguageContext'
  6. interface AICommandDrawerProps {
  7. isOpen: boolean
  8. onClose: () => void
  9. context: string
  10. onApply: (content: string) => void
  11. authToken: string
  12. }
  13. const PRESET_COMMANDS = [
  14. { label: 'polishContent', valueKey: 'aiCommandInstructPolish' },
  15. { label: 'expandContent', valueKey: 'aiCommandInstructExpand' },
  16. { label: 'summarizeContent', valueKey: 'aiCommandInstructSummarize' },
  17. { label: 'translateToEnglish', valueKey: 'aiCommandInstructTranslateToEn' },
  18. { label: 'fixGrammar', valueKey: 'aiCommandInstructFixGrammar' },
  19. ]
  20. export const AICommandDrawer: React.FC<AICommandDrawerProps> = ({ isOpen, onClose, context, onApply, authToken }) => {
  21. const { t } = useLanguage()
  22. const [instruction, setInstruction] = useState('')
  23. const [result, setResult] = useState('')
  24. const [isGenerating, setIsGenerating] = useState(false)
  25. const [mode, setMode] = useState<'input' | 'preview'>('input')
  26. useEffect(() => {
  27. if (isOpen) {
  28. setInstruction('')
  29. setResult('')
  30. setMode('input')
  31. setIsGenerating(false)
  32. }
  33. }, [isOpen])
  34. const handleGenerate = async () => {
  35. if (!instruction) return
  36. setMode('preview')
  37. setIsGenerating(true)
  38. setResult('')
  39. try {
  40. const stream = chatService.streamAssist(instruction, context, authToken)
  41. for await (const chunk of stream) {
  42. if (chunk.type === 'content') {
  43. setResult(prev => prev + chunk.data)
  44. } else if (chunk.type === 'error') {
  45. setResult(prev => prev + `\n\n[Error: ${chunk.data}]`)
  46. }
  47. }
  48. } catch (error) {
  49. console.error(error)
  50. setResult(prev => prev + '\n\n[' + t('aiCommandsError') + ']')
  51. } finally {
  52. setIsGenerating(false)
  53. }
  54. }
  55. // Drawer Styles
  56. const drawerClasses = `fixed inset-y-0 right-0 z-50 w-96 bg-white shadow-2xl transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
  57. }`
  58. // Overlay for closing on click outside (optional, but good UX)
  59. // Using a transparent overlay that allows interaction with the editor might be desired?
  60. // Usually drawers have a backdrop. User said "Show AI assistant page in right drawer", likely implies a standard drawer behavior.
  61. return (
  62. <>
  63. {/* Backdrop */}
  64. {isOpen && (
  65. <div
  66. className="fixed inset-0 bg-black/20 z-40 backdrop-blur-none transition-opacity"
  67. onClick={onClose}
  68. />
  69. )}
  70. <div className={drawerClasses}>
  71. <div className="flex flex-col h-full">
  72. {/* Header */}
  73. <div className="bg-gradient-to-r from-purple-600 to-blue-600 p-4 shrink-0 flex justify-between items-center text-white shadow-md">
  74. <div className="flex items-center gap-2">
  75. <Sparkles size={20} className="animate-pulse" />
  76. <h3 className="font-bold text-lg">{t('aiAssistant')}</h3>
  77. </div>
  78. <button onClick={onClose} className="p-1 hover:bg-white/20 rounded-lg transition-colors">
  79. <X size={20} />
  80. </button>
  81. </div>
  82. {/* Content */}
  83. <div className="flex-1 overflow-y-auto p-4">
  84. {mode === 'input' ? (
  85. <div className="space-y-6">
  86. <div>
  87. <label className="block text-sm font-medium text-slate-700 mb-2">{t('aiCommandsPreset')}</label>
  88. <div className="flex flex-wrap gap-2">
  89. {PRESET_COMMANDS.map(cmd => (
  90. <button
  91. key={cmd.label}
  92. onClick={() => setInstruction(t(cmd.valueKey as any))}
  93. className={`px-3 py-1.5 text-xs rounded-full border transition-all ${instruction === t(cmd.valueKey as any)
  94. ? 'bg-purple-100 border-purple-300 text-purple-700'
  95. : 'bg-white border-slate-200 text-slate-600 hover:border-purple-300 hover:text-purple-600'
  96. }`}
  97. >
  98. {t(cmd.label as keyof typeof t)}
  99. </button>
  100. ))}
  101. </div>
  102. </div>
  103. <div>
  104. <label className="block text-sm font-medium text-slate-700 mb-2">{t('aiCommandsCustom')}</label>
  105. <textarea
  106. className="w-full h-32 p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent outline-none resize-none text-sm"
  107. placeholder={t('aiCommandsCustomPlaceholder')}
  108. value={instruction}
  109. onChange={e => setInstruction(e.target.value)}
  110. autoFocus
  111. />
  112. </div>
  113. {context && (
  114. <div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
  115. <p className="text-xs text-slate-400 mb-1">{t('aiCommandsReferenceContext')}</p>
  116. <p className="text-xs text-slate-600 line-clamp-3 font-mono">{context.slice(0, 200)}</p>
  117. </div>
  118. )}
  119. </div>
  120. ) : (
  121. <div className="h-full flex flex-col">
  122. <div className="flex justify-between items-center mb-2">
  123. <h4 className="font-bold text-slate-700 text-sm">{t('aiCommandsResult')}</h4>
  124. {isGenerating && (
  125. <span className="text-xs text-purple-600 flex items-center gap-1">
  126. <RefreshCw size={12} className="animate-spin" /> {t('aiCommandsGenerating')}
  127. </span>
  128. )}
  129. </div>
  130. <div className="flex-1 bg-slate-50 border border-slate-200 rounded-lg p-3 overflow-y-auto markdown-body text-sm relative">
  131. {result ? <ReactMarkdown>{result}</ReactMarkdown> : <span className="text-slate-400 italic text-xs">Thinking...</span>}
  132. </div>
  133. </div>
  134. )}
  135. </div>
  136. {/* Footer */}
  137. <div className="p-4 border-t border-slate-200 bg-slate-50 shrink-0 flex flex-col gap-3">
  138. {mode === 'input' ? (
  139. <button
  140. onClick={handleGenerate}
  141. disabled={!instruction}
  142. className="w-full flex justify-center items-center gap-2 px-4 py-2 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-lg hover:shadow-lg disabled:opacity-50 disabled:shadow-none transition-all font-medium text-sm"
  143. >
  144. <Sparkles size={16} />
  145. {t('aiCommandsStartGeneration')}
  146. </button>
  147. ) : (
  148. <div className="flex gap-2">
  149. <button
  150. onClick={() => setMode('input')}
  151. className="flex-1 px-3 py-2 text-slate-600 bg-white border border-slate-200 hover:bg-slate-50 rounded-lg text-sm"
  152. disabled={isGenerating}
  153. >
  154. {t('aiCommandsGoBack')}
  155. </button>
  156. <button
  157. onClick={() => {
  158. onApply(result)
  159. // Optional: Don't close drawer immediately to allow multiple edits?
  160. // Usually "Apply" implies "Done". User can reopen if needed.
  161. onClose()
  162. }}
  163. disabled={isGenerating || !result}
  164. className="flex-1 flex justify-center items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 shadow-sm text-sm"
  165. >
  166. <Check size={16} />
  167. {t('aiCommandsApplyResult')}
  168. </button>
  169. </div>
  170. )}
  171. <div className="text-center">
  172. <button onClick={() => {
  173. setInstruction('')
  174. setResult('')
  175. setMode('input')
  176. }} className="text-xs text-slate-400 hover:text-slate-600 flex items-center justify-center gap-1 mx-auto">
  177. <Eraser size={12} /> {t('aiCommandsReset')}
  178. </button>
  179. </div>
  180. </div>
  181. </div>
  182. </div>
  183. </>
  184. )
  185. }