Files
aurak/web/components/AICommandDrawer.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

203 lines
10 KiB
TypeScript

import React, { useState, useEffect } from 'react'
import { Sparkles, ArrowRight, X, RefreshCw, Check, Eraser } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { chatService } from '../services/chatService'
import { useLanguage } from '../contexts/LanguageContext'
interface AICommandDrawerProps {
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 AICommandDrawer: React.FC<AICommandDrawerProps> = ({ 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)
}
}
// Drawer Styles
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'
}`
// Overlay for closing on click outside (optional, but good UX)
// Using a transparent overlay that allows interaction with the editor might be desired?
// Usually drawers have a backdrop. User said "Show AI assistant page in right drawer", likely implies a standard drawer behavior.
return (
<>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 bg-black/20 z-40 backdrop-blur-none transition-opacity"
onClick={onClose}
/>
)}
<div className={drawerClasses}>
<div className="flex flex-col h-full">
{/* Header */}
<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">
<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="flex-1 overflow-y-auto p-4">
{mode === 'input' ? (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('aiCommandsPreset')}</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-xs 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('aiCommandsCustom')}</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 text-sm"
placeholder={t('aiCommandsCustomPlaceholder')}
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('aiCommandsReferenceContext')}</p>
<p className="text-xs text-slate-600 line-clamp-3 font-mono">{context.slice(0, 200)}</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 text-sm">{t('aiCommandsResult')}</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-3 overflow-y-auto markdown-body text-sm relative">
{result ? <ReactMarkdown>{result}</ReactMarkdown> : <span className="text-slate-400 italic text-xs">Thinking...</span>}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-200 bg-slate-50 shrink-0 flex flex-col gap-3">
{mode === 'input' ? (
<button
onClick={handleGenerate}
disabled={!instruction}
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"
>
<Sparkles size={16} />
{t('aiCommandsStartGeneration')}
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => setMode('input')}
className="flex-1 px-3 py-2 text-slate-600 bg-white border border-slate-200 hover:bg-slate-50 rounded-lg text-sm"
disabled={isGenerating}
>
{t('aiCommandsGoBack')}
</button>
<button
onClick={() => {
onApply(result)
// Optional: Don't close drawer immediately to allow multiple edits?
// Usually "Apply" implies "Done". User can reopen if needed.
onClose()
}}
disabled={isGenerating || !result}
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"
>
<Check size={16} />
{t('aiCommandsApplyResult')}
</button>
</div>
)}
<div className="text-center">
<button onClick={() => {
setInstruction('')
setResult('')
setMode('input')
}} className="text-xs text-slate-400 hover:text-slate-600 flex items-center justify-center gap-1 mx-auto">
<Eraser size={12} /> {t('aiCommandsReset')}
</button>
</div>
</div>
</div>
</div>
</>
)
}