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 { usePathname } from "next/navigation"
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 {
@@ -11,10 +11,10 @@ import {
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/shared/components/ui/sheet"
import { AiChatPanel } from "./ai-chat-panel"
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
* - 上下文感知:根据当前路由自动推断用户场景
* - 流式响应 + Markdown 渲染
*
* 使用:
* 在 dashboard layout 中引入即可全局生效。
@@ -45,6 +47,10 @@ export function AiAssistantWidget(): React.ReactNode {
const pathname = usePathname()
const aiClient = useAiClientOptional()
const [open, setOpen] = useState(false)
const [chatKey, setChatKey] = useState(0)
const handleBallClick = () => setOpen(true)
const ball = useFloatingBall(handleBallClick)
// 根据路由推断上下文
const contextConfig = useMemo<AiContextConfig>(() => {
@@ -56,55 +62,161 @@ export function AiAssistantWidget(): React.ReactNode {
return null
}
const { position, hidden, dragging, hovered, hiddenOffset, handlers, show, resetPosition } = ball
// 首次渲染时 position 为占位值(屏幕外),避免闪烁
const isReady = position.x < 9999
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
<>
{/* 悬浮球 */}
{isReady ? (
<button
type="button"
size="icon"
className="fixed bottom-6 right-6 z-50 h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-shadow"
aria-label={t("widget.open")}
aria-label={hidden ? t("widget.show") : t("widget.open")}
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"
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" />
<span className="absolute -top-1 -right-1 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="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
<Bot className="h-6 w-6 drop-shadow-sm" />
<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-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500 ring-2 ring-background" />
</span>
</Button>
</SheetTrigger>
<SheetContent className="w-full sm:max-w-md overflow-y-auto p-0">
<SheetHeader className="px-4 py-3 border-b">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
{t("widget.title")}
</SheetTitle>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => setOpen(false)}
aria-label={t("widget.close")}
>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("widget.contextAware")}</p>
</SheetHeader>
<div className="p-4">
<AiChatPanel
systemPrompt={contextConfig.systemPrompt}
contextMessage={contextConfig.contextMessage}
suggestedPrompts={contextConfig.suggestedPrompts}
maxMessages={30}
{hidden ? (
<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">
<ChevronLeft
className="h-4 w-4"
style={{
transform: position.x <= 16 ? "rotate(0deg)" : "rotate(180deg)",
}}
/>
</span>
) : null}
</button>
) : null}
{/* 半隐藏时的提示条 */}
{hidden && isReady ? (
<button
type="button"
aria-label={t("widget.show")}
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"
style={{
left: `${position.x + (hiddenOffset > 0 ? 0 : BALL_SIZE * 0.45)}px`,
top: `${position.y}px`,
width: "14px",
height: "56px",
cursor: "pointer",
}}
onClick={show}
>
<ChevronLeft
className="h-3 w-3"
style={{
transform: position.x <= 16 ? "rotate(0deg)" : "rotate(180deg)",
}}
/>
</div>
</SheetContent>
</Sheet>
</button>
) : null}
{/* 侧边抽屉 */}
<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 上下文
*/

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
/** 建议提示词列表(空状态展示) */
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>

View File

@@ -9,6 +9,7 @@ import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils"
import { AiChartRenderer, type AiChartType } from "./ai-chart-renderer"
type AiMarkdownRendererProps = {
content: string
@@ -18,12 +19,22 @@ type AiMarkdownRendererProps = {
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支持
* - GFM表格、删除线、任务列表
* - 代码块语法高亮
* - 图表渲染(```chart:bar|line|pie|radar + JSON
* - 复制按钮
*
* 安全react-markdown 默认不执行 HTML防止 XSS。
@@ -73,6 +84,19 @@ function AiMarkdownRendererImpl({
</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 (
<code className={codeClass} {...props}>
{children}

View File

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

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useCallback, useRef } from "react"
import { useState, useCallback, useRef, useEffect } from "react"
import { useTranslations } from "next-intl"
import type { AiChatMessage } from "../types"
import {
@@ -11,11 +11,14 @@ import {
appendTokenToLastAssistant,
} from "./stream-utils"
const HISTORY_STORAGE_KEY = "ai-chat-history"
const MAX_HISTORY = 20
/**
* AI 流式聊天 Hook
*
* 通过 SSE 端点消费流式 AI 回复。
* 支持:逐 token 渲染、停止生成AbortController、错误处理。
* 支持:逐 token 渲染、停止生成AbortController、错误处理、历史持久化恢复
*/
type UseAiChatStreamReturn = {
messages: AiChatMessage[]
@@ -26,13 +29,49 @@ type UseAiChatStreamReturn = {
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 {
const t = useTranslations("ai")
const [messages, setMessages] = useState<AiChatMessage[]>([])
// 懒初始化:从 localStorage 恢复历史
const [messages, setMessages] = useState<AiChatMessage[]>(() => loadHistory())
const [streaming, setStreaming] = useState(false)
const [error, setError] = useState<string | 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(
async (
inputMessages: AiChatMessage[],
@@ -105,6 +144,11 @@ export function useAiChatStream(): UseAiChatStreamReturn {
const clear = useCallback((): void => {
setMessages([])
setError(null)
try {
localStorage.removeItem(HISTORY_STORAGE_KEY)
} catch {
// ignore
}
}, [])
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).",
"Use Markdown formatting for structured content (lists, tables, code blocks).",
"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")
// ---------------------------------------------------------------------------