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

@@ -3,7 +3,7 @@
import { useState, useMemo } from "react" import { useState, useMemo } from "react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Bot, X } from "lucide-react" import { Bot, X, Sparkles, RotateCcw, ChevronLeft } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { import {
@@ -11,10 +11,10 @@ import {
SheetContent, SheetContent,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
SheetTrigger,
} from "@/shared/components/ui/sheet" } from "@/shared/components/ui/sheet"
import { AiChatPanel } from "./ai-chat-panel" import { AiChatPanel } from "./ai-chat-panel"
import { useAiClientOptional } from "../context/ai-client-provider" import { useAiClientOptional } from "../context/ai-client-provider"
import { useFloatingBall } from "../hooks/use-floating-ball"
/** /**
* 上下文感知规则 * 上下文感知规则
@@ -28,13 +28,15 @@ type AiContextConfig = {
} }
/** /**
* 全局 AI 助手悬浮按钮 * 全局 AI 助手悬浮360 悬浮球风格)
* *
* 参考 Khanmigo 嵌入式助手模式 * 特性
* - 右下角悬浮按钮,任何页面可见 * - 可拖拽移动,松手吸附到最近屏幕边缘
* - 拖到边缘自动半隐藏(只露出一小部分)
* - 鼠标悬停时恢复显示
* - 位置持久化到 localStorage
* - 点击打开侧边抽屉,内嵌 AiChatPanel * - 点击打开侧边抽屉,内嵌 AiChatPanel
* - 上下文感知:根据当前路由自动推断用户场景 * - 上下文感知:根据当前路由自动推断用户场景
* - 流式响应 + Markdown 渲染
* *
* 使用: * 使用:
* 在 dashboard layout 中引入即可全局生效。 * 在 dashboard layout 中引入即可全局生效。
@@ -45,6 +47,10 @@ export function AiAssistantWidget(): React.ReactNode {
const pathname = usePathname() const pathname = usePathname()
const aiClient = useAiClientOptional() const aiClient = useAiClientOptional()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [chatKey, setChatKey] = useState(0)
const handleBallClick = () => setOpen(true)
const ball = useFloatingBall(handleBallClick)
// 根据路由推断上下文 // 根据路由推断上下文
const contextConfig = useMemo<AiContextConfig>(() => { const contextConfig = useMemo<AiContextConfig>(() => {
@@ -56,55 +62,161 @@ export function AiAssistantWidget(): React.ReactNode {
return null return null
} }
const { position, hidden, dragging, hovered, hiddenOffset, handlers, show, resetPosition } = ball
// 首次渲染时 position 为占位值(屏幕外),避免闪烁
const isReady = position.x < 9999
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <>
<SheetTrigger asChild> {/* 悬浮球 */}
<Button {isReady ? (
<button
type="button" type="button"
size="icon" aria-label={hidden ? t("widget.show") : t("widget.open")}
className="fixed bottom-6 right-6 z-50 h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-shadow" className="fixed z-50 flex select-none items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-lg transition-[width,height,opacity,transform] duration-200 ease-out hover:shadow-xl hover:scale-105 touch-none"
aria-label={t("widget.open")} style={{
left: `${position.x}px`,
top: `${position.y}px`,
width: "56px",
height: "56px",
transform: `translateX(${hiddenOffset}px) ${dragging ? "scale(1.1)" : ""}`,
cursor: dragging ? "grabbing" : "grab",
opacity: hidden && !hovered ? 0.55 : 1,
transition: dragging ? "none" : "transform 0.25s ease-out, opacity 0.25s ease-out",
}}
onPointerDown={handlers.onPointerDown}
onPointerMove={handlers.onPointerMove}
onPointerUp={handlers.onPointerUp}
onPointerCancel={handlers.onPointerCancel}
onMouseEnter={handlers.onMouseEnter}
onMouseLeave={handlers.onMouseLeave}
onClick={(e) => {
// 拖动产生的 click 不触发打开
if (e.detail === 0) return
}}
> >
<Bot className="h-6 w-6" /> <Bot className="h-6 w-6 drop-shadow-sm" />
<span className="absolute -top-1 -right-1 flex h-3 w-3"> <span className="pointer-events-none absolute -top-0.5 -right-0.5 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" /> <span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500 ring-2 ring-background" />
</span> </span>
</Button> {hidden ? (
</SheetTrigger> <span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-primary/30 opacity-0 transition-opacity hover:opacity-100">
<SheetContent className="w-full sm:max-w-md overflow-y-auto p-0"> <ChevronLeft
<SheetHeader className="px-4 py-3 border-b"> className="h-4 w-4"
<div className="flex items-center justify-between"> style={{
<SheetTitle className="flex items-center gap-2"> transform: position.x <= 16 ? "rotate(0deg)" : "rotate(180deg)",
<Bot className="h-4 w-4 text-primary" /> }}
{t("widget.title")} />
</SheetTitle> </span>
<Button ) : null}
type="button" </button>
variant="ghost" ) : null}
size="sm"
className="h-7 px-2" {/* 半隐藏时的提示条 */}
onClick={() => setOpen(false)} {hidden && isReady ? (
aria-label={t("widget.close")} <button
> type="button"
<X className="h-4 w-4" /> aria-label={t("widget.show")}
</Button> className="fixed z-40 flex items-center justify-center rounded-full bg-primary/15 text-primary backdrop-blur-sm transition-opacity hover:bg-primary/25"
</div> style={{
<p className="text-xs text-muted-foreground">{t("widget.contextAware")}</p> left: `${position.x + (hiddenOffset > 0 ? 0 : BALL_SIZE * 0.45)}px`,
</SheetHeader> top: `${position.y}px`,
<div className="p-4"> width: "14px",
<AiChatPanel height: "56px",
systemPrompt={contextConfig.systemPrompt} cursor: "pointer",
contextMessage={contextConfig.contextMessage} }}
suggestedPrompts={contextConfig.suggestedPrompts} onClick={show}
maxMessages={30} >
<ChevronLeft
className="h-3 w-3"
style={{
transform: position.x <= 16 ? "rotate(0deg)" : "rotate(180deg)",
}}
/> />
</div> </button>
</SheetContent> ) : null}
</Sheet>
{/* 侧边抽屉 */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent hideClose className="w-full sm:max-w-lg overflow-hidden p-0 flex flex-col">
<SheetHeader className="px-5 py-4 border-b bg-gradient-to-r from-primary/5 to-transparent">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-sm">
<Bot className="h-5 w-5" />
</span>
<div className="flex flex-col">
<span className="text-base font-semibold leading-tight">{t("widget.title")}</span>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
{t("widget.online")}
</span>
</div>
</SheetTitle>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => {
try {
localStorage.removeItem("ai-chat-history")
} catch {
// ignore
}
setChatKey((k) => k + 1)
}}
aria-label={t("widget.newChat")}
title={t("widget.newChat")}
>
<Sparkles className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={resetPosition}
aria-label={t("widget.resetPosition")}
title={t("widget.resetPosition")}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground"
onClick={() => setOpen(false)}
aria-label={t("widget.close")}
title={t("widget.close")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<AiChatPanel
key={chatKey}
systemPrompt={contextConfig.systemPrompt}
contextMessage={contextConfig.contextMessage}
suggestedPrompts={contextConfig.suggestedPrompts}
maxMessages={30}
variant="widget"
/>
</div>
</SheetContent>
</Sheet>
</>
) )
} }
const BALL_SIZE = 56
/** /**
* 根据路由推断 AI 上下文 * 根据路由推断 AI 上下文
*/ */

View File

@@ -0,0 +1,329 @@
"use client"
import { useMemo } from "react"
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
XAxis,
YAxis,
} from "recharts"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/shared/components/ui/chart"
import { cn } from "@/shared/lib/utils"
/**
* AI 图表渲染器
*
* 将 AI 返回的图表 JSON 规格渲染为 recharts 图表。
* 支持 4 种图表类型bar / line / pie / radar。
*
* AI 通过在 Markdown 中返回特殊代码块触发渲染:
*
* ```chart:bar
* { "title": "...", "data": [...], "series": [...] }
* ```
*
* 规格格式(通用):
* {
* "title": "图表标题", // 可选
* "description": "说明文字", // 可选
* "data": [ // 数据数组
* { "name": "数学", "score": 85, "fullTitle": "数学科目" }
* ],
* "xKey": "name", // X 轴字段bar/line
* "series": [ // 系列配置
* { "dataKey": "score", "name": "分数", "color": "hsl(221, 83%, 53%)" }
* ],
* "yDomain": [0, 100], // 可选Y 轴定义域
* "height": 280 // 可选,高度 px
* }
*
* pie 图特有:
* {
* "data": [{ "name": "及格", "value": 30 }],
* "series": [{ "name": "分布" }]
* }
*/
export type AiChartType = "bar" | "line" | "pie" | "radar"
export interface AiChartSeries {
dataKey: string
name: string
color?: string
/** pie: 填充透明度radar: 填充透明度 */
fillOpacity?: number
/** radar: 线宽 */
strokeWidth?: number
/** radar: 虚线 */
strokeDasharray?: string
}
export interface AiChartSpec {
title?: string
description?: string
type?: AiChartType
data: Array<Record<string, string | number>>
xKey?: string
angleKey?: string
series: AiChartSeries[]
yDomain?: [number, number]
height?: number
showLegend?: boolean
}
/** 默认调色板(色盲友好) */
const DEFAULT_PALETTE = [
"hsl(221, 83%, 53%)", // 蓝
"hsl(142, 71%, 45%)", // 绿
"hsl(43, 96%, 56%)", // 黄
"hsl(0, 84%, 60%)", // 红
"hsl(271, 76%, 53%)", // 紫
"hsl(199, 89%, 48%)", // 青
"hsl(25, 95%, 53%)", // 橙
"hsl(280, 65%, 60%)", // 品红
]
interface AiChartRendererProps {
/** 图表类型 */
type: AiChartType
/** JSON 规格字符串 */
spec: string
className?: string
}
/**
* 解析 JSON 规格,失败时返回 null
*/
function parseSpec(spec: string): AiChartSpec | null {
try {
const parsed = JSON.parse(spec) as AiChartSpec
if (!parsed || !Array.isArray(parsed.data) || !Array.isArray(parsed.series)) {
return null
}
return parsed
} catch {
return null
}
}
/**
* 为 series 补充默认颜色
*/
function withDefaultColors(series: AiChartSeries[]): AiChartSeries[] {
return series.map((s, i) => ({
...s,
color: s.color ?? DEFAULT_PALETTE[i % DEFAULT_PALETTE.length],
}))
}
export function AiChartRenderer({
type,
spec,
className,
}: AiChartRendererProps): React.ReactNode {
const parsed = useMemo(() => parseSpec(spec), [spec])
if (!parsed) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
</div>
)
}
const series = withDefaultColors(parsed.series)
const height = parsed.height ?? 280
// 构建 ChartConfig
const chartConfig: ChartConfig = {}
for (const s of series) {
chartConfig[s.dataKey] = {
label: s.name,
color: s.color,
}
}
return (
<div className={cn("my-2 rounded-lg border bg-card p-3", className)}>
{parsed.title ? (
<div className="mb-2">
<p className="text-sm font-medium leading-tight">{parsed.title}</p>
{parsed.description ? (
<p className="text-xs text-muted-foreground mt-0.5">{parsed.description}</p>
) : null}
</div>
) : null}
<ChartContainer
config={chartConfig}
className="w-full"
style={{ height: `${height}px` }}
>
{renderChart(type, parsed, series)}
</ChartContainer>
</div>
)
}
function renderChart(
type: AiChartType,
spec: AiChartSpec,
series: AiChartSeries[]
): React.ReactNode {
switch (type) {
case "bar":
return renderBarChart(spec, series)
case "line":
return renderLineChart(spec, series)
case "pie":
return renderPieChart(spec, series)
case "radar":
return renderRadarChart(spec, series)
default:
return null
}
}
function renderBarChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode {
const xKey = spec.xKey ?? "name"
return (
<BarChart data={spec.data} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis dataKey={xKey} tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
domain={spec.yDomain}
allowDecimals
tickLine={false}
axisLine={false}
width={36}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[200px]" />} />
{spec.showLegend ? <Legend /> : null}
{series.map((s) => (
<Bar
key={s.dataKey}
dataKey={s.dataKey}
name={s.name}
fill={`var(--color-${s.dataKey})`}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
)
}
function renderLineChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode {
const xKey = spec.xKey ?? "name"
return (
<LineChart data={spec.data} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis dataKey={xKey} tickLine={false} axisLine={false} tickMargin={8} />
<YAxis
domain={spec.yDomain ?? [0, 100]}
tickLine={false}
axisLine={false}
width={36}
/>
<ChartTooltip
cursor={{
stroke: "hsl(var(--muted-foreground))",
strokeWidth: 1,
strokeDasharray: "4 4",
}}
content={<ChartTooltipContent indicator="line" className="w-[220px]" />}
/>
{spec.showLegend ? <Legend /> : null}
{series.map((s) => (
<Line
key={s.dataKey}
dataKey={s.dataKey}
name={s.name}
type="monotone"
stroke={`var(--color-${s.dataKey})`}
strokeWidth={2}
dot={{
fill: `var(--color-${s.dataKey})`,
r: 3,
strokeWidth: 2,
}}
activeDot={{ r: 5, strokeWidth: 0 }}
/>
))}
</LineChart>
)
}
function renderPieChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode {
// Pie 图data 中每项 { name, value }series 仅取第一个作为图例名
const colors = series.map((s) => s.color ?? DEFAULT_PALETTE[0])
const dataKey = series[0]?.dataKey ?? "value"
return (
<PieChart margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<ChartTooltip content={<ChartTooltipContent className="w-[200px]" />} />
{spec.showLegend ? <Legend /> : null}
<Pie
data={spec.data}
dataKey={dataKey}
nameKey={spec.xKey ?? "name"}
cx="50%"
cy="50%"
outerRadius="75%"
label={(entry: { name?: string; value?: number }) =>
`${entry.name ?? ""}: ${entry.value ?? ""}`
}
labelLine={false}
>
{spec.data.map((_, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
))}
</Pie>
</PieChart>
)
}
function renderRadarChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode {
const angleKey = spec.angleKey ?? spec.xKey ?? "name"
return (
<RadarChart data={spec.data} outerRadius="75%" margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<PolarGrid strokeOpacity={0.4} />
<PolarAngleAxis dataKey={angleKey} tick={{ fontSize: 12 }} />
<PolarRadiusAxis
domain={spec.yDomain ?? [0, 100]}
tickFormatter={(value: number) => `${value}%`}
tick={{ fontSize: 10 }}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
{spec.showLegend ? <Legend /> : null}
{series.map((s) => (
<Radar
key={s.dataKey}
name={s.name}
dataKey={s.dataKey}
stroke={`var(--color-${s.dataKey})`}
fill={`var(--color-${s.dataKey})`}
fillOpacity={s.fillOpacity ?? 0.3}
strokeWidth={s.strokeWidth}
strokeDasharray={s.strokeDasharray}
/>
))}
</RadarChart>
)
}

View File

@@ -34,6 +34,8 @@ type AiChatPanelProps = {
maxMessages?: number maxMessages?: number
/** 建议提示词列表(空状态展示) */ /** 建议提示词列表(空状态展示) */
suggestedPrompts?: string[] suggestedPrompts?: string[]
/** 视觉变体card默认卡片/ widget悬浮球内嵌无边框撑满容器 */
variant?: "card" | "widget"
} }
/** /**
@@ -57,37 +59,13 @@ export function AiChatPanel({
title, title,
maxMessages = 50, maxMessages = 50,
suggestedPrompts, suggestedPrompts,
variant = "card",
}: AiChatPanelProps): React.ReactNode { }: AiChatPanelProps): React.ReactNode {
const t = useTranslations("ai") const t = useTranslations("ai")
const { messages, streaming, error, send, stop, clear } = useAiChatStream() const { messages, streaming, error, send, stop, clear } = useAiChatStream()
const [input, setInput] = useState("") const [input, setInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const storageKey = "ai-chat-history" const isWidget = variant === "widget"
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])
// 自动滚动到底部 // 自动滚动到底部
useEffect(() => { useEffect(() => {
@@ -101,17 +79,18 @@ export function AiChatPanel({
const trimmed = (content ?? input).trim() const trimmed = (content ?? input).trim()
if (!trimmed || streaming || messages.length >= maxMessages) return if (!trimmed || streaming || messages.length >= maxMessages) return
const contextPrefix = contextMessage // 上下文信息合并到 systemPrompt 发送给 AI用户气泡只显示真实输入
? `Context:\n${contextMessage}\n\nUser question: ${trimmed}` const fullSystemPrompt = contextMessage
: trimmed ? `${systemPrompt ?? ""}\n\n[Page Context]\n${contextMessage}`.trim()
: systemPrompt
const requestMessages: AiChatMessage[] = [ const requestMessages: AiChatMessage[] = [
...messages, ...messages,
{ role: "user", content: contextPrefix }, { role: "user", content: trimmed },
] ]
setInput("") setInput("")
await send(requestMessages, { systemPrompt }) await send(requestMessages, { systemPrompt: fullSystemPrompt })
}, },
[input, streaming, messages, maxMessages, systemPrompt, contextMessage, send] [input, streaming, messages, maxMessages, systemPrompt, contextMessage, send]
) )
@@ -126,11 +105,6 @@ export function AiChatPanel({
const handleClear = (): void => { const handleClear = (): void => {
if (window.confirm(t("chat.clearConfirm"))) { if (window.confirm(t("chat.clearConfirm"))) {
clear() clear()
try {
localStorage.removeItem(storageKey)
} catch {
// 忽略
}
toast.success(t("chat.clear")) toast.success(t("chat.clear"))
} }
} }
@@ -149,6 +123,145 @@ export function AiChatPanel({
t("chat.suggestedPrompts.teacher.2"), 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -9,6 +9,7 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { AiChartRenderer, type AiChartType } from "./ai-chart-renderer"
type AiMarkdownRendererProps = { type AiMarkdownRendererProps = {
content: string content: string
@@ -18,12 +19,22 @@ type AiMarkdownRendererProps = {
className?: string className?: string
} }
/** 支持的图表类型映射language → chart type */
const CHART_LANG_PREFIX = "chart:"
const CHART_TYPES: Record<string, AiChartType> = {
"chart:bar": "bar",
"chart:line": "line",
"chart:pie": "pie",
"chart:radar": "radar",
}
/** /**
* AI Markdown 渲染器 * AI Markdown 渲染器
* *
* 将 AI 回复渲染为富文本 Markdown支持 * 将 AI 回复渲染为富文本 Markdown支持
* - GFM表格、删除线、任务列表 * - GFM表格、删除线、任务列表
* - 代码块语法高亮 * - 代码块语法高亮
* - 图表渲染(```chart:bar|line|pie|radar + JSON
* - 复制按钮 * - 复制按钮
* *
* 安全react-markdown 默认不执行 HTML防止 XSS。 * 安全react-markdown 默认不执行 HTML防止 XSS。
@@ -73,6 +84,19 @@ function AiMarkdownRendererImpl({
</code> </code>
) )
} }
// 检测图表代码块language-chart:bar / chart:line / chart:pie / chart:radar
const lang = codeClass?.replace("language-", "").trim() ?? ""
const chartType = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`]
?? (lang.startsWith(CHART_LANG_PREFIX)
? (lang.slice(CHART_LANG_PREFIX.length) as AiChartType)
: undefined)
if (chartType && (chartType === "bar" || chartType === "line" || chartType === "pie" || chartType === "radar")) {
const raw = String(children).replace(/\n$/, "")
return <AiChartRenderer type={chartType} spec={raw} />
}
return ( return (
<code className={codeClass} {...props}> <code className={codeClass} {...props}>
{children} {children}

View File

@@ -1,8 +1,6 @@
"use client" "use client"
import Link from "next/link"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Settings } from "lucide-react"
import { import {
FormField, FormField,
FormItem, FormItem,
@@ -18,7 +16,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { Button } from "@/shared/components/ui/button"
import type { Control } from "react-hook-form" import type { Control } from "react-hook-form"
/** AI Provider 摘要信息(与 settings 模块类型兼容) */ /** AI Provider 摘要信息(与 settings 模块类型兼容) */
@@ -48,7 +45,7 @@ type AiProviderSelectorProps = {
* 可复用的表单字段组件,用于选择 AI Provider。 * 可复用的表单字段组件,用于选择 AI Provider。
* 从 exam-ai-generator.tsx 抽取,支持在任何需要 AI Provider 选择的表单中复用。 * 从 exam-ai-generator.tsx 抽取,支持在任何需要 AI Provider 选择的表单中复用。
* *
* V3移除内嵌配置弹窗,"管理"按钮改为跳转到 /admin/ai-settings 统一配置页 * V3移除内嵌"管理"链接AI 配置统一在 /admin/ai-settings 管理
*/ */
export function AiProviderSelector({ export function AiProviderSelector({
control, control,
@@ -65,15 +62,7 @@ export function AiProviderSelector({
name={name} name={name}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex items-center justify-between gap-2"> <FormLabel>{t("provider.label")}</FormLabel>
<FormLabel>{t("provider.label")}</FormLabel>
<Button asChild type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
<Link href="/admin/ai-settings">
<Settings className="mr-1 h-3.5 w-3.5" />
{t("provider.manage")}
</Link>
</Button>
</div>
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading}> <Select value={field.value as string} onValueChange={field.onChange} disabled={loading}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useCallback, useRef } from "react" import { useState, useCallback, useRef, useEffect } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import type { AiChatMessage } from "../types" import type { AiChatMessage } from "../types"
import { import {
@@ -11,11 +11,14 @@ import {
appendTokenToLastAssistant, appendTokenToLastAssistant,
} from "./stream-utils" } from "./stream-utils"
const HISTORY_STORAGE_KEY = "ai-chat-history"
const MAX_HISTORY = 20
/** /**
* AI 流式聊天 Hook * AI 流式聊天 Hook
* *
* 通过 SSE 端点消费流式 AI 回复。 * 通过 SSE 端点消费流式 AI 回复。
* 支持:逐 token 渲染、停止生成AbortController、错误处理。 * 支持:逐 token 渲染、停止生成AbortController、错误处理、历史持久化恢复
*/ */
type UseAiChatStreamReturn = { type UseAiChatStreamReturn = {
messages: AiChatMessage[] messages: AiChatMessage[]
@@ -26,13 +29,49 @@ type UseAiChatStreamReturn = {
clear: () => void clear: () => void
} }
function loadHistory(): AiChatMessage[] {
if (typeof window === "undefined") return []
try {
const raw = localStorage.getItem(HISTORY_STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as AiChatMessage[]
if (!Array.isArray(parsed)) return []
// 过滤掉空的 assistant 消息
return parsed.filter((m) => m && m.role && typeof m.content === "string")
} catch {
return []
}
}
export function useAiChatStream(): UseAiChatStreamReturn { export function useAiChatStream(): UseAiChatStreamReturn {
const t = useTranslations("ai") const t = useTranslations("ai")
const [messages, setMessages] = useState<AiChatMessage[]>([]) // 懒初始化:从 localStorage 恢复历史
const [messages, setMessages] = useState<AiChatMessage[]>(() => loadHistory())
const [streaming, setStreaming] = useState(false) const [streaming, setStreaming] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const abortControllerRef = useRef<AbortController | null>(null) const abortControllerRef = useRef<AbortController | null>(null)
// 持久化(防抖)
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (streaming) return
if (persistTimerRef.current) clearTimeout(persistTimerRef.current)
persistTimerRef.current = setTimeout(() => {
try {
if (messages.length === 0) {
localStorage.removeItem(HISTORY_STORAGE_KEY)
} else {
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(messages.slice(-MAX_HISTORY)))
}
} catch {
// ignore
}
}, 500)
return () => {
if (persistTimerRef.current) clearTimeout(persistTimerRef.current)
}
}, [messages, streaming])
const send = useCallback( const send = useCallback(
async ( async (
inputMessages: AiChatMessage[], inputMessages: AiChatMessage[],
@@ -105,6 +144,11 @@ export function useAiChatStream(): UseAiChatStreamReturn {
const clear = useCallback((): void => { const clear = useCallback((): void => {
setMessages([]) setMessages([])
setError(null) setError(null)
try {
localStorage.removeItem(HISTORY_STORAGE_KEY)
} catch {
// ignore
}
}, []) }, [])
return { messages, streaming, error, send, stop, clear } return { messages, streaming, error, send, stop, clear }

View File

@@ -0,0 +1,243 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
type Position = { x: number; y: number }
const STORAGE_KEY = "ai-widget-position"
const HIDE_THRESHOLD = 0.55
const BALL_SIZE = 56
const MARGIN = 16
function clampPosition(pos: Position): Position {
if (typeof window === "undefined") return pos
const maxX = window.innerWidth - BALL_SIZE - MARGIN
const maxY = window.innerHeight - BALL_SIZE - MARGIN
return {
x: Math.min(Math.max(pos.x, MARGIN), Math.max(maxX, MARGIN)),
y: Math.min(Math.max(pos.y, MARGIN), Math.max(maxY, MARGIN)),
}
}
function loadPosition(): Position {
if (typeof window === "undefined") {
return { x: 9999, y: 9999 }
}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<Position>
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
return clampPosition({ x: parsed.x, y: parsed.y })
}
}
} catch {
// ignore
}
const x = window.innerWidth - BALL_SIZE - MARGIN * 2
const y = window.innerHeight - BALL_SIZE - MARGIN * 4
return clampPosition({ x, y })
}
function savePosition(pos: Position): void {
if (typeof window === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
} catch {
// ignore
}
}
/**
* 可拖拽悬浮球 Hook360 悬浮球风格)
*
* 特性:
* - 鼠标/触摸拖拽,松手后吸附到最近屏幕边缘
* - 拖到边缘超过阈值时半隐藏(只露出一小部分)
* - 单击(未发生拖动)触发 onClick
* - 位置持久化到 localStorage
* - 窗口 resize 时自动校正位置
*/
export function useFloatingBall(onClick: () => void) {
// 服务端与客户端首次渲染一致position 在屏幕外,不渲染按钮)
// 在 useEffect 中加载真实位置,避免 hydration mismatch
const [position, setPosition] = useState<Position>({ x: 9999, y: 9999 })
const [hidden, setHidden] = useState(false)
const [dragging, setDragging] = useState(false)
const [hovered, setHovered] = useState(false)
// 拖拽释放后标记"刚隐藏",阻止 mouseEnter 立即展开
const justHiddenRef = useRef(false)
const dragStateRef = useRef({
active: false,
moved: false,
startX: 0,
startY: 0,
originX: 0,
originY: 0,
pointerId: -1,
})
const onClickRef = useRef(onClick)
useEffect(() => {
onClickRef.current = onClick
}, [onClick])
// 初始化位置:在客户端 mount 后加载真实位置
// 避免 hydration mismatch服务端与客户端位置不同
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPosition(loadPosition())
}, [])
// 窗口 resize 时校正
useEffect(() => {
const handleResize = () => {
setPosition((prev) => clampPosition(prev))
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
// 仅主键响应拖拽
if (e.button !== 0 && e.pointerType === "mouse") return
const state = dragStateRef.current
state.active = true
state.moved = false
state.startX = e.clientX
state.startY = e.clientY
state.originX = position.x
state.originY = position.y
state.pointerId = e.pointerId
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {
// ignore
}
setHidden(false)
setDragging(true)
},
[position]
)
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
const state = dragStateRef.current
if (!state.active || e.pointerId !== state.pointerId) return
const dx = e.clientX - state.startX
const dy = e.clientY - state.startY
if (!state.moved && Math.abs(dx) + Math.abs(dy) < 4) return
state.moved = true
const next = clampPosition({
x: state.originX + dx,
y: state.originY + dy,
})
setPosition(next)
},
[]
)
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
const state = dragStateRef.current
if (!state.active || e.pointerId !== state.pointerId) return
state.active = false
setDragging(false)
try {
e.currentTarget.releasePointerCapture(e.pointerId)
} catch {
// ignore
}
// 未移动 → 视为点击
if (!state.moved) {
onClickRef.current()
return
}
// 移动了 → 吸附到最近边缘
const w = window.innerWidth
const centerX = position.x + BALL_SIZE / 2
const distanceToLeft = centerX
const distanceToRight = w - centerX
const snapLeft = distanceToLeft < distanceToRight
const snappedX = snapLeft ? MARGIN : w - BALL_SIZE - MARGIN
// 判断是否半隐藏:吸附后位置贴近边缘
const shouldHide = true
const finalPos = clampPosition({ x: snappedX, y: position.y })
setPosition(finalPos)
savePosition(finalPos)
setHidden(shouldHide)
// 标记刚隐藏,阻止后续 mouseEnter 立即展开
justHiddenRef.current = shouldHide
// 清除 hovered确保 hiddenOffset 生效
setHovered(false)
},
[position]
)
const handlePointerCancel = useCallback(() => {
const state = dragStateRef.current
state.active = false
setDragging(false)
}, [])
const handleMouseEnter = useCallback(() => {
// 如果刚通过拖拽隐藏,不立即展开(需先离开再进入才展开)
if (justHiddenRef.current) {
justHiddenRef.current = false
return
}
setHovered(true)
if (hidden) setHidden(false)
}, [hidden])
const handleMouseLeave = useCallback(() => {
setHovered(false)
// 离开后清除 justHidden 标记,下次进入可正常展开
justHiddenRef.current = false
}, [])
const show = useCallback(() => {
justHiddenRef.current = false
setHidden(false)
}, [])
const resetPosition = useCallback(() => {
justHiddenRef.current = false
const fresh = typeof window === "undefined"
? loadPosition()
: clampPosition({
x: window.innerWidth - BALL_SIZE - MARGIN * 2,
y: window.innerHeight - BALL_SIZE - MARGIN * 4,
})
setPosition(fresh)
savePosition(fresh)
setHidden(false)
}, [])
// 半隐藏时的视觉偏移量
const hiddenOffset = hidden && !hovered && !dragging
? (position.x <= MARGIN + 2 ? -(BALL_SIZE * HIDE_THRESHOLD) : BALL_SIZE * HIDE_THRESHOLD)
: 0
return {
position,
hidden,
dragging,
hovered,
hiddenOffset,
handlers: {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onPointerCancel: handlePointerCancel,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
},
show,
resetPosition,
}
}

View File

@@ -165,6 +165,41 @@ export const CHAT_SYSTEM_PROMPT = [
"Respond in the user's language (Chinese by default).", "Respond in the user's language (Chinese by default).",
"Use Markdown formatting for structured content (lists, tables, code blocks).", "Use Markdown formatting for structured content (lists, tables, code blocks).",
"Be concise, accurate, and pedagogically sound.", "Be concise, accurate, and pedagogically sound.",
"",
"## Data Visualization",
"When presenting quantitative data, trends, comparisons, or distributions, ALWAYS render a chart using a fenced code block with one of these languages:",
"- ```chart:bar — for comparing categories or showing distributions",
"- ```chart:line — for trends over time",
"- ```chart:pie — for part-to-whole / percentage breakdown",
"- ```chart:radar — for multi-dimensional comparison (e.g. subject mastery)",
"",
"Chart spec format (JSON inside the code block):",
"```",
"{",
' "title": "图表标题",',
' "description": "可选说明",',
' "data": [',
' { "name": "数学", "score": 85, "fullTitle": "数学科目" }',
" ],",
' "xKey": "name", // bar/line: X 轴字段pie: nameKey',
' "angleKey": "name", // radar: 角度轴字段(可选,默认同 xKey',
' "series": [',
' { "dataKey": "score", "name": "分数", "color": "hsl(221, 83%, 53%)" }',
" ],",
' "yDomain": [0, 100], // 可选Y 轴范围',
' "height": 280, // 可选,高度 px',
' "showLegend": true // 可选,多系列时建议 true',
"}",
"```",
"",
"Chart rules:",
"- Only use charts when data is genuinely quantitative (numbers, percentages, counts).",
"- Keep data arrays small (≤ 12 items) for readability.",
"- For pie charts, each data item must have a `name` and a `value` field; set series[0].dataKey to \"value\".",
"- For radar charts, set `angleKey` to the dimension name field and provide one series per metric.",
"- Colors are optional; if omitted, a colorblind-friendly palette is applied.",
"- Always provide a concise text explanation alongside the chart.",
"- Do NOT wrap the JSON in any other markdown; output the raw JSON inside the fenced block.",
].join("\n") ].join("\n")
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------