AICommandModal.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import React, { useState, useEffect } from 'react'
  2. import { Sparkles, ArrowRight, X, RefreshCw, Check } from 'lucide-react'
  3. import ReactMarkdown from 'react-markdown'
  4. import { chatService } from '../services/chatService'
  5. import { useLanguage } from '../contexts/LanguageContext'
  6. interface AICommandModalProps {
  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 AICommandModal: React.FC<AICommandModalProps> = ({ 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. if (!isOpen) return null
  56. return (
  57. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
  58. <div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col max-h-[85vh] animate-in fade-in zoom-in duration-200">
  59. {/* Header */}
  60. <div className="bg-gradient-to-r from-purple-600 to-blue-600 p-4 shrink-0 flex justify-between items-center text-white">
  61. <div className="flex items-center gap-2">
  62. <Sparkles size={20} className="animate-pulse" />
  63. <h3 className="font-bold text-lg">{t('aiAssistant')}</h3>
  64. </div>
  65. <button onClick={onClose} className="p-1 hover:bg-white/20 rounded-lg transition-colors">
  66. <X size={20} />
  67. </button>
  68. </div>
  69. {/* Content */}
  70. <div className="p-6 overflow-y-auto flex-1">
  71. {mode === 'input' ? (
  72. <div className="space-y-6">
  73. <div>
  74. <label className="block text-sm font-medium text-slate-700 mb-2">{t('aiCommandsModalPreset')}</label>
  75. <div className="flex flex-wrap gap-2">
  76. {PRESET_COMMANDS.map(cmd => (
  77. <button
  78. key={cmd.label}
  79. onClick={() => setInstruction(t(cmd.valueKey as any))}
  80. className={`px-3 py-1.5 text-sm rounded-full border transition-all ${instruction === t(cmd.valueKey as any)
  81. ? 'bg-purple-100 border-purple-300 text-purple-700'
  82. : 'bg-white border-slate-200 text-slate-600 hover:border-purple-300 hover:text-purple-600'
  83. }`}
  84. >
  85. {t(cmd.label as keyof typeof t)}
  86. </button>
  87. ))}
  88. </div>
  89. </div>
  90. <div>
  91. <label className="block text-sm font-medium text-slate-700 mb-2">{t('aiCommandsModalCustom')}</label>
  92. <textarea
  93. 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"
  94. placeholder={t('aiCommandsModalCustomPlaceholder')}
  95. value={instruction}
  96. onChange={e => setInstruction(e.target.value)}
  97. autoFocus
  98. />
  99. </div>
  100. {context && (
  101. <div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
  102. <p className="text-xs text-slate-400 mb-1">{t('aiCommandsModalBasedOnSelection')}</p>
  103. <p className="text-sm text-slate-600 line-clamp-3 font-mono">{context}</p>
  104. </div>
  105. )}
  106. </div>
  107. ) : (
  108. <div className="h-full flex flex-col">
  109. <div className="flex justify-between items-center mb-2">
  110. <h4 className="font-bold text-slate-700">{t('aiCommandsModalResult')}</h4>
  111. {isGenerating && (
  112. <span className="text-xs text-purple-600 flex items-center gap-1">
  113. <RefreshCw size={12} className="animate-spin" /> {t('aiCommandsGenerating')}
  114. </span>
  115. )}
  116. </div>
  117. <div className="flex-1 bg-slate-50 border border-slate-200 rounded-lg p-4 overflow-y-auto markdown-body text-sm">
  118. {result ? <ReactMarkdown>{result}</ReactMarkdown> : <span className="text-slate-400 italic">{t('aiCommandsGenerating')}</span>}
  119. </div>
  120. </div>
  121. )}
  122. </div>
  123. {/* Footer */}
  124. <div className="p-4 border-t border-slate-200 bg-slate-50 shrink-0 flex justify-end gap-3">
  125. {mode === 'input' ? (
  126. <button
  127. onClick={handleGenerate}
  128. disabled={!instruction}
  129. className="flex items-center gap-2 px-5 py-2.5 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"
  130. >
  131. <Sparkles size={16} />
  132. {t('aiCommandsStartGeneration')}
  133. </button>
  134. ) : (
  135. <>
  136. <button
  137. onClick={() => setMode('input')}
  138. className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg"
  139. disabled={isGenerating}
  140. >
  141. {t('aiCommandsGoBack')}
  142. </button>
  143. <button
  144. onClick={() => {
  145. onApply(result)
  146. onClose()
  147. }}
  148. disabled={isGenerating || !result}
  149. className="flex items-center gap-2 px-5 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 shadow-sm"
  150. >
  151. <Check size={16} />
  152. {t('aiCommandsModalApply')}
  153. </button>
  154. </>
  155. )}
  156. </div>
  157. </div>
  158. </div>
  159. )
  160. }