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:
@@ -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 上下文
|
||||
*/
|
||||
|
||||
329
src/modules/ai/components/ai-chart-renderer.tsx
Normal file
329
src/modules/ai/components/ai-chart-renderer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
243
src/modules/ai/hooks/use-floating-ball.ts
Normal file
243
src/modules/ai/hooks/use-floating-ball.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可拖拽悬浮球 Hook(360 悬浮球风格)
|
||||
*
|
||||
* 特性:
|
||||
* - 鼠标/触摸拖拽,松手后吸附到最近屏幕边缘
|
||||
* - 拖到边缘超过阈值时半隐藏(只露出一小部分)
|
||||
* - 单击(未发生拖动)触发 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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user