0a9588abb7
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
177 lines
8.5 KiB
TypeScript
177 lines
8.5 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
|
import { Sparkles, ArrowRight, X, RefreshCw, Check } from 'lucide-react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import { chatService } from '../services/chatService'
|
|
import { useLanguage } from '../contexts/LanguageContext'
|
|
|
|
interface AICommandModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
context: string
|
|
onApply: (content: string) => void
|
|
authToken: string
|
|
}
|
|
|
|
const PRESET_COMMANDS = [
|
|
{ label: 'polishContent', valueKey: 'aiCommandInstructPolish' },
|
|
{ label: 'expandContent', valueKey: 'aiCommandInstructExpand' },
|
|
{ label: 'summarizeContent', valueKey: 'aiCommandInstructSummarize' },
|
|
{ label: 'translateToEnglish', valueKey: 'aiCommandInstructTranslateToEn' },
|
|
{ label: 'fixGrammar', valueKey: 'aiCommandInstructFixGrammar' },
|
|
]
|
|
|
|
export const AICommandModal: React.FC<AICommandModalProps> = ({ isOpen, onClose, context, onApply, authToken }) => {
|
|
const { t } = useLanguage()
|
|
const [instruction, setInstruction] = useState('')
|
|
const [result, setResult] = useState('')
|
|
const [isGenerating, setIsGenerating] = useState(false)
|
|
const [mode, setMode] = useState<'input' | 'preview'>('input')
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setInstruction('')
|
|
setResult('')
|
|
setMode('input')
|
|
setIsGenerating(false)
|
|
}
|
|
}, [isOpen])
|
|
|
|
const handleGenerate = async () => {
|
|
if (!instruction) return
|
|
|
|
setMode('preview')
|
|
setIsGenerating(true)
|
|
setResult('')
|
|
|
|
try {
|
|
const stream = chatService.streamAssist(instruction, context, authToken)
|
|
|
|
for await (const chunk of stream) {
|
|
if (chunk.type === 'content') {
|
|
setResult(prev => prev + chunk.data)
|
|
} else if (chunk.type === 'error') {
|
|
setResult(prev => prev + `\n\n[Error: ${chunk.data}]`)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
setResult(prev => prev + `\n\n[${t('aiCommandsError')}]`)
|
|
} finally {
|
|
setIsGenerating(false)
|
|
}
|
|
}
|
|
|
|
if (!isOpen) return null
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
<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">
|
|
|
|
{/* Header */}
|
|
<div className="bg-gradient-to-r from-purple-600 to-blue-600 p-4 shrink-0 flex justify-between items-center text-white">
|
|
<div className="flex items-center gap-2">
|
|
<Sparkles size={20} className="animate-pulse" />
|
|
<h3 className="font-bold text-lg">{t('aiAssistant')}</h3>
|
|
</div>
|
|
<button onClick={onClose} className="p-1 hover:bg-white/20 rounded-lg transition-colors">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-6 overflow-y-auto flex-1">
|
|
|
|
{mode === 'input' ? (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">{t('aiCommandsModalPreset')}</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{PRESET_COMMANDS.map(cmd => (
|
|
<button
|
|
key={cmd.label}
|
|
onClick={() => setInstruction(t(cmd.valueKey as any))}
|
|
className={`px-3 py-1.5 text-sm rounded-full border transition-all ${instruction === t(cmd.valueKey as any)
|
|
? 'bg-purple-100 border-purple-300 text-purple-700'
|
|
: 'bg-white border-slate-200 text-slate-600 hover:border-purple-300 hover:text-purple-600'
|
|
}`}
|
|
>
|
|
{t(cmd.label as keyof typeof t)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">{t('aiCommandsModalCustom')}</label>
|
|
<textarea
|
|
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"
|
|
placeholder={t('aiCommandsModalCustomPlaceholder')}
|
|
value={instruction}
|
|
onChange={e => setInstruction(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
{context && (
|
|
<div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
|
|
<p className="text-xs text-slate-400 mb-1">{t('aiCommandsModalBasedOnSelection')}</p>
|
|
<p className="text-sm text-slate-600 line-clamp-3 font-mono">{context}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="h-full flex flex-col">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h4 className="font-bold text-slate-700">{t('aiCommandsModalResult')}</h4>
|
|
{isGenerating && (
|
|
<span className="text-xs text-purple-600 flex items-center gap-1">
|
|
<RefreshCw size={12} className="animate-spin" /> {t('aiCommandsGenerating')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 bg-slate-50 border border-slate-200 rounded-lg p-4 overflow-y-auto markdown-body text-sm">
|
|
{result ? <ReactMarkdown>{result}</ReactMarkdown> : <span className="text-slate-400 italic">{t('aiCommandsGenerating')}</span>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-slate-200 bg-slate-50 shrink-0 flex justify-end gap-3">
|
|
{mode === 'input' ? (
|
|
<button
|
|
onClick={handleGenerate}
|
|
disabled={!instruction}
|
|
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"
|
|
>
|
|
<Sparkles size={16} />
|
|
{t('aiCommandsStartGeneration')}
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => setMode('input')}
|
|
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg"
|
|
disabled={isGenerating}
|
|
>
|
|
{t('aiCommandsGoBack')}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
onApply(result)
|
|
onClose()
|
|
}}
|
|
disabled={isGenerating || !result}
|
|
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"
|
|
>
|
|
<Check size={16} />
|
|
{t('aiCommandsModalApply')}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|