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:
SpecialX
2026-06-24 12:02:29 +08:00
parent 61e76f0d67
commit a48e7d0e27
8 changed files with 988 additions and 99 deletions

View File

@@ -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>