Files
NextEdu/src/modules/ai/components/ai-chat-panel.tsx
SpecialX a48e7d0e27 feat(ai): add chart renderer, floating ball hook, and provider updates
- Add ai-chart-renderer for rendering charts in AI responses

- Add use-floating-ball hook for draggable AI assistant widget

- Update ai-assistant-widget, ai-chat-panel, ai-markdown-renderer, ai-provider-selector

- Update use-ai-chat-stream hook and prompt-templates service
2026-06-24 12:02:29 +08:00

418 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useRef, useEffect, useCallback } from "react"
import { useTranslations } from "next-intl"
import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/shared/lib/utils"
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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
import { AiChatSkeleton } from "./ai-skeleton"
import { AiMarkdownRenderer } from "./ai-markdown-renderer"
import { useAiChatStream } from "../hooks/use-ai-chat-stream"
import type { AiChatMessage } from "../types"
type AiChatPanelProps = {
/** 初始系统提示词 */
systemPrompt?: string
/** 上下文信息(注入到 user message 前面) */
contextMessage?: string
/** 占位提示文本 */
placeholder?: string
/** 标题 */
title?: string
/** 最大消息数 */
maxMessages?: number
/** 建议提示词列表(空状态展示) */
suggestedPrompts?: string[]
/** 视觉变体card默认卡片/ widget悬浮球内嵌无边框撑满容器 */
variant?: "card" | "widget"
}
/**
* AI 聊天面板
*
* 通用 AI 对话组件,可嵌入任何页面。
* V2 增强:
* - 流式响应SSE逐 token 渲染
* - Markdown 渲染(代码块、表格、列表)
* - 复制按钮
* - 停止生成按钮
* - 清除对话按钮
* - 建议提示词
* - aria-live 无障碍
* - 对话历史持久化localStorage防抖写入
*/
export function AiChatPanel({
systemPrompt,
contextMessage,
placeholder,
title,
maxMessages = 50,
suggestedPrompts,
variant = "card",
}: AiChatPanelProps): React.ReactNode {
const t = useTranslations("ai")
const { messages, streaming, error, send, stop, clear } = useAiChatStream()
const [input, setInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null)
const isWidget = variant === "widget"
// 自动滚动到底部
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages])
const handleSend = useCallback(
async (content?: string): Promise<void> => {
const trimmed = (content ?? input).trim()
if (!trimmed || streaming || messages.length >= maxMessages) return
// 上下文信息合并到 systemPrompt 发送给 AI用户气泡只显示真实输入
const fullSystemPrompt = contextMessage
? `${systemPrompt ?? ""}\n\n[Page Context]\n${contextMessage}`.trim()
: systemPrompt
const requestMessages: AiChatMessage[] = [
...messages,
{ role: "user", content: trimmed },
]
setInput("")
await send(requestMessages, { systemPrompt: fullSystemPrompt })
},
[input, streaming, messages, maxMessages, systemPrompt, contextMessage, send]
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
void handleSend()
}
}
const handleClear = (): void => {
if (window.confirm(t("chat.clearConfirm"))) {
clear()
toast.success(t("chat.clear"))
}
}
const handleSuggestedPrompt = (prompt: string): void => {
void handleSend(prompt)
}
if (streaming && messages.length === 0) {
return <AiChatSkeleton />
}
const defaultSuggestedPrompts = suggestedPrompts ?? [
t("chat.suggestedPrompts.teacher.0"),
t("chat.suggestedPrompts.teacher.1"),
t("chat.suggestedPrompts.teacher.2"),
]
// widget 变体:无边框、撑满容器、消息区自适应高度
if (isWidget) {
return (
<div className="flex h-full flex-col">
{error ? (
<div
className="mx-3 mt-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
role="alert"
>
{error}
</div>
) : null}
{messages.length > 0 ? (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-4 py-4"
aria-live="polite"
aria-relevant="additions text"
>
<div className="space-y-4">
{messages.map((message, index) => (
<div
key={index}
className={cn(
"flex gap-2.5",
message.role === "user" ? "justify-end" : "justify-start"
)}
>
{message.role === "assistant" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
<Bot className="h-4 w-4" />
</span>
) : (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<User className="h-4 w-4" />
</span>
)}
<div
className={cn(
"rounded-2xl px-3.5 py-2 text-sm max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-muted rounded-tl-sm"
)}
>
{message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} />
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
</div>
))}
{streaming ? (
<div className="flex gap-2.5 justify-start">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
<Bot className="h-4 w-4" />
</span>
<div className="rounded-2xl rounded-tl-sm px-3.5 py-2 text-sm bg-muted">
<span className="inline-flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60" />
</span>
</div>
</div>
) : null}
</div>
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center px-6 py-8">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-md mb-3">
<Sparkles className="h-7 w-7" />
</span>
<p className="text-base font-medium mb-1">{t("widget.welcome")}</p>
<p className="text-sm text-muted-foreground mb-4 text-center">{t("widget.welcomeDesc")}</p>
<div className="flex flex-wrap gap-2 justify-center max-w-sm">
{defaultSuggestedPrompts.map((prompt, index) => (
<Button
key={index}
type="button"
variant="outline"
size="sm"
className="text-xs rounded-full"
onClick={() => handleSuggestedPrompt(prompt)}
>
{prompt}
</Button>
))}
</div>
</div>
)}
<div className="border-t p-3">
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-10 max-h-32 resize-none rounded-2xl"
disabled={streaming || messages.length >= maxMessages}
aria-label={t("chat.inputLabel")}
/>
{streaming ? (
<Button
type="button"
size="icon"
variant="destructive"
className="rounded-full shrink-0"
onClick={stop}
aria-label={t("chat.stopGeneration")}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
size="icon"
className="rounded-full shrink-0"
onClick={() => void handleSend()}
disabled={!input.trim() || 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 mt-2">
{t("chat.maxReached")}
</p>
) : null}
</div>
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
{title ?? t("chat.title")}
</CardTitle>
{messages.length > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground"
onClick={handleClear}
aria-label={t("chat.clear")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("chat.clear")}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3">
{error ? (
<div
className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
role="alert"
>
{error}
</div>
) : null}
{messages.length > 0 ? (
<ScrollArea
className="h-72 w-full rounded-md border p-3"
aria-live="polite"
aria-relevant="additions text"
>
<div className="space-y-3" ref={scrollRef}>
{messages.map((message, index) => (
<div
key={index}
className={cn(
"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={cn(
// 任意值:聊天气泡需按容器宽度百分比限制,固定 max-w-* 无法适应不同面板宽度
"rounded-md px-3 py-2 text-sm max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
)}
>
{message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} />
) : (
<p className="whitespace-pre-wrap">{message.content}</p>
)}
</div>
</div>
))}
{streaming ? (
<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.streaming")}</span>
<span className="inline-block w-1 h-4 ml-1 bg-primary animate-pulse" />
</div>
</div>
) : null}
</div>
</ScrollArea>
) : (
<div className="space-y-3">
<div className="rounded-md border border-dashed p-6 text-center">
<Sparkles className="h-8 w-8 text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground mb-3">
{t("chat.suggestedPrompts.title")}
</p>
<div className="flex flex-wrap gap-2 justify-center">
{defaultSuggestedPrompts.map((prompt, index) => (
<Button
key={index}
type="button"
variant="outline"
size="sm"
className="text-xs"
onClick={() => handleSuggestedPrompt(prompt)}
>
{prompt}
</Button>
))}
</div>
</div>
</div>
)}
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-16 resize-none"
disabled={streaming || messages.length >= maxMessages}
aria-label={t("chat.inputLabel")}
/>
{streaming ? (
<Button
type="button"
size="icon"
variant="destructive"
onClick={stop}
aria-label={t("chat.stopGeneration")}
>
<Square className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
size="icon"
onClick={() => void handleSend()}
disabled={!input.trim() || 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>
)
}