Files
aurak/web/components/AICommandModal.tsx
Developer 0a9588abb7 feat: implement QuestionBank CRUD with pagination and template query
- 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
2026-04-23 17:19:11 +08:00

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>
)
}