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
This commit is contained in:
@@ -34,6 +34,8 @@ type AiChatPanelProps = {
|
||||
maxMessages?: number
|
||||
/** 建议提示词列表(空状态展示) */
|
||||
suggestedPrompts?: string[]
|
||||
/** 视觉变体:card(默认卡片)/ widget(悬浮球内嵌,无边框,撑满容器) */
|
||||
variant?: "card" | "widget"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,37 +59,13 @@ export function AiChatPanel({
|
||||
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 storageKey = "ai-chat-history"
|
||||
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// 持久化对话历史(防抖 500ms,避免流式过程中频繁写入)
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return
|
||||
// 流式过程中不写入,流结束后再写
|
||||
if (streaming) return
|
||||
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current)
|
||||
}
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-20)))
|
||||
} catch {
|
||||
// 忽略写入错误
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [messages, streaming])
|
||||
const isWidget = variant === "widget"
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
@@ -101,17 +79,18 @@ export function AiChatPanel({
|
||||
const trimmed = (content ?? input).trim()
|
||||
if (!trimmed || streaming || messages.length >= maxMessages) return
|
||||
|
||||
const contextPrefix = contextMessage
|
||||
? `Context:\n${contextMessage}\n\nUser question: ${trimmed}`
|
||||
: trimmed
|
||||
// 上下文信息合并到 systemPrompt 发送给 AI,用户气泡只显示真实输入
|
||||
const fullSystemPrompt = contextMessage
|
||||
? `${systemPrompt ?? ""}\n\n[Page Context]\n${contextMessage}`.trim()
|
||||
: systemPrompt
|
||||
|
||||
const requestMessages: AiChatMessage[] = [
|
||||
...messages,
|
||||
{ role: "user", content: contextPrefix },
|
||||
{ role: "user", content: trimmed },
|
||||
]
|
||||
|
||||
setInput("")
|
||||
await send(requestMessages, { systemPrompt })
|
||||
await send(requestMessages, { systemPrompt: fullSystemPrompt })
|
||||
},
|
||||
[input, streaming, messages, maxMessages, systemPrompt, contextMessage, send]
|
||||
)
|
||||
@@ -126,11 +105,6 @@ export function AiChatPanel({
|
||||
const handleClear = (): void => {
|
||||
if (window.confirm(t("chat.clearConfirm"))) {
|
||||
clear()
|
||||
try {
|
||||
localStorage.removeItem(storageKey)
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
toast.success(t("chat.clear"))
|
||||
}
|
||||
}
|
||||
@@ -149,6 +123,145 @@ export function AiChatPanel({
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user