feat(ai): 新增 AI 模块并集成至备课/错题集/试卷/改题四大业务场景
- 新增 src/modules/ai 独立模块,遵循三层架构(actions → services → shared/lib/ai) - 通过 AiClientProvider + useAiClient 实现 React Context 依赖注入,业务组件零直接 import - 6 个 Server Actions 均调用 requirePermission() 权限校验,返回 ActionState<T> - withAiTracking 统一埋点,覆盖 chat/similar_question/grading_assist/lesson_content/question_variant/weakness_analysis - 集成场景:作业批改 AiGradingAssist、错题集 AiErrorBookAnalysis、备课 AiLessonContentGenerator、试卷 AiQuestionVariantGenerator - 全量 i18n(en/zh-CN ai.json),Error Boundary + Skeleton 边界处理 - 同步架构图 004/005,新增审计报告 ai-module-audit-report.md
This commit is contained in:
182
src/modules/ai/components/ai-chat-panel.tsx
Normal file
182
src/modules/ai/components/ai-chat-panel.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Send, Bot, User } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { AiChatSkeleton } from "./ai-skeleton"
|
||||
import { useAiClient } from "../context/ai-client-provider"
|
||||
import type { AiChatMessage } from "../types"
|
||||
|
||||
type AiChatPanelProps = {
|
||||
/** 初始系统提示词 */
|
||||
systemPrompt?: string
|
||||
/** 上下文信息(注入到 user message 前面) */
|
||||
contextMessage?: string
|
||||
/** 占位提示文本 */
|
||||
placeholder?: string
|
||||
/** 标题 */
|
||||
title?: string
|
||||
/** 最大消息数 */
|
||||
maxMessages?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 聊天面板
|
||||
*
|
||||
* 通用 AI 对话组件,可嵌入任何页面。
|
||||
* 通过 useAiClient() 获取 Server Action 引用,不直接 import actions。
|
||||
*/
|
||||
export function AiChatPanel({
|
||||
systemPrompt,
|
||||
contextMessage,
|
||||
placeholder,
|
||||
title,
|
||||
maxMessages = 50,
|
||||
}: AiChatPanelProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
const aiClient = useAiClient()
|
||||
const [messages, setMessages] = useState<AiChatMessage[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const handleSend = useCallback(async (): Promise<void> => {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed || loading || messages.length >= maxMessages) return
|
||||
|
||||
const userMessage: AiChatMessage = { role: "user", content: trimmed }
|
||||
const contextPrefix = contextMessage
|
||||
? `Context:\n${contextMessage}\n\nUser question: ${trimmed}`
|
||||
: trimmed
|
||||
const systemMessage: AiChatMessage | null = systemPrompt
|
||||
? { role: "system", content: systemPrompt }
|
||||
: null
|
||||
|
||||
const requestMessages: AiChatMessage[] = [
|
||||
...(systemMessage ? [systemMessage] : []),
|
||||
...messages,
|
||||
{ role: "user" as const, content: contextPrefix },
|
||||
]
|
||||
|
||||
setInput("")
|
||||
setLoading(true)
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
|
||||
try {
|
||||
const result = await aiClient.chat({
|
||||
messages: requestMessages,
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
const assistantContent = result.data.content
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: assistantContent },
|
||||
])
|
||||
} else {
|
||||
toast.error(result.message ?? t("error.chatFailed"))
|
||||
setMessages((prev) => prev.filter((m) => m !== userMessage))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error.chatFailed"))
|
||||
setMessages((prev) => prev.filter((m) => m !== userMessage))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [input, loading, messages, maxMessages, systemPrompt, contextMessage, aiClient, t])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && messages.length === 0) {
|
||||
return <AiChatSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
{title ?? t("chat.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{messages.length > 0 ? (
|
||||
<ScrollArea className="h-[300px] w-full rounded-md border p-3">
|
||||
<div className="space-y-3" ref={scrollRef}>
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex gap-2 ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
|
||||
) : (
|
||||
<User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
|
||||
)}
|
||||
<div
|
||||
className={`rounded-md px-3 py-2 text-sm max-w-[80%] ${
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading ? (
|
||||
<div className="flex gap-2 justify-start">
|
||||
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
|
||||
<div className="rounded-md px-3 py-2 text-sm bg-muted">
|
||||
<span className="animate-pulse">{t("chat.thinking")}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? t("chat.placeholder")}
|
||||
className="min-h-[60px] resize-none"
|
||||
disabled={loading || messages.length >= maxMessages}
|
||||
aria-label={t("chat.inputLabel")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!input.trim() || loading || messages.length >= maxMessages}
|
||||
aria-label={t("chat.send")}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{messages.length >= maxMessages ? (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("chat.maxReached")}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user