- 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
418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
"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>
|
||
)
|
||
}
|