diff --git a/src/modules/ai/components/ai-assistant-widget.tsx b/src/modules/ai/components/ai-assistant-widget.tsx index 07015b1..347e925 100644 --- a/src/modules/ai/components/ai-assistant-widget.tsx +++ b/src/modules/ai/components/ai-assistant-widget.tsx @@ -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(() => { @@ -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 ( - - - - - - -
- - - {t("widget.title")} - - -
-

{t("widget.contextAware")}

-
-
- + + + ) : null} + + ) : null} + + {/* 半隐藏时的提示条 */} + {hidden && isReady ? ( +
-
-
+ + ) : null} + + {/* 侧边抽屉 */} + + + +
+ + + + +
+ {t("widget.title")} + + + {t("widget.online")} + +
+
+
+ + + +
+
+
+
+ +
+
+
+ ) } +const BALL_SIZE = 56 + /** * 根据路由推断 AI 上下文 */ diff --git a/src/modules/ai/components/ai-chart-renderer.tsx b/src/modules/ai/components/ai-chart-renderer.tsx new file mode 100644 index 0000000..e5a61f0 --- /dev/null +++ b/src/modules/ai/components/ai-chart-renderer.tsx @@ -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> + 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 ( +
+ 图表数据格式错误,无法渲染 +
+ ) + } + + 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 ( +
+ {parsed.title ? ( +
+

{parsed.title}

+ {parsed.description ? ( +

{parsed.description}

+ ) : null} +
+ ) : null} + + {renderChart(type, parsed, series)} + +
+ ) +} + +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 ( + + + + + } /> + {spec.showLegend ? : null} + {series.map((s) => ( + + ))} + + ) +} + +function renderLineChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode { + const xKey = spec.xKey ?? "name" + return ( + + + + + } + /> + {spec.showLegend ? : null} + {series.map((s) => ( + + ))} + + ) +} + +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 ( + + } /> + {spec.showLegend ? : null} + + `${entry.name ?? ""}: ${entry.value ?? ""}` + } + labelLine={false} + > + {spec.data.map((_, index) => ( + + ))} + + + ) +} + +function renderRadarChart(spec: AiChartSpec, series: AiChartSeries[]): React.ReactNode { + const angleKey = spec.angleKey ?? spec.xKey ?? "name" + return ( + + + + `${value}%`} + tick={{ fontSize: 10 }} + axisLine={false} + /> + } /> + {spec.showLegend ? : null} + {series.map((s) => ( + + ))} + + ) +} diff --git a/src/modules/ai/components/ai-chat-panel.tsx b/src/modules/ai/components/ai-chat-panel.tsx index faba287..bc51db6 100644 --- a/src/modules/ai/components/ai-chat-panel.tsx +++ b/src/modules/ai/components/ai-chat-panel.tsx @@ -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(null) - const storageKey = "ai-chat-history" - const persistTimerRef = useRef | 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 ( +
+ {error ? ( +
+ {error} +
+ ) : null} + + {messages.length > 0 ? ( +
+
+ {messages.map((message, index) => ( +
+ {message.role === "assistant" ? ( + + + + ) : ( + + + + )} +
+ {message.role === "assistant" ? ( + + ) : ( +

{message.content}

+ )} +
+
+ ))} + {streaming ? ( +
+ + + +
+ + + + + +
+
+ ) : null} +
+
+ ) : ( +
+ + + +

{t("widget.welcome")}

+

{t("widget.welcomeDesc")}

+
+ {defaultSuggestedPrompts.map((prompt, index) => ( + + ))} +
+
+ )} + +
+
+