"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(null) const isWidget = variant === "widget" // 自动滚动到底部 useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } }, [messages]) const handleSend = useCallback( async (content?: string): Promise => { 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): 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 } const defaultSuggestedPrompts = suggestedPrompts ?? [ t("chat.suggestedPrompts.teacher.0"), t("chat.suggestedPrompts.teacher.1"), t("chat.suggestedPrompts.teacher.2"), ] // widget 变体:无边框、撑满容器、消息区自适应高度 if (isWidget) { return (
{error ? (
{error}
) : null} {messages.length > 0 ? (
{messages.map((message, index) => (
{message.role === "assistant" ? ( ) : ( )}
{message.role === "assistant" ? ( ) : (

{message.content}

)}
))} {streaming ? (
) : null}
) : (

{t("widget.welcome")}

{t("widget.welcomeDesc")}

{defaultSuggestedPrompts.map((prompt, index) => ( ))}
)}