feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates
parent: - Add parent-student-attendance-detail component auth: - Add actions, data-access, schema, services, types onboarding: - Add parent-children-form and hooks directory files: - Add actions, schema, hooks directory notifications: - Add schema and schema test adaptive-practice: - Add answer-input, answer-result, practice-result-view, practice-starter-with-nav - Add question-card, question-content, lib and services directories ai: - Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
WeaknessAnalysisInputSchema,
|
||||
ChildSummaryInputSchema,
|
||||
StudyPathInputSchema,
|
||||
ExplainErrorInputSchema,
|
||||
} from "./schema"
|
||||
import type {
|
||||
AiChatMessage,
|
||||
@@ -37,6 +38,8 @@ import type {
|
||||
StudyPathInput,
|
||||
StudyPathResult,
|
||||
AiUsageStats,
|
||||
ExplainErrorInput,
|
||||
ExplainErrorResult,
|
||||
} from "./types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -327,16 +330,15 @@ export async function recommendStudyPathAction(
|
||||
|
||||
// 同步填充 currentMastery(若未传入)
|
||||
if (!serviceInput.currentMastery || serviceInput.currentMastery.length === 0) {
|
||||
serviceInput.currentMastery = kps
|
||||
.filter((kp) => masteryMap.has(kp.id))
|
||||
.map((kp) => {
|
||||
const m = masteryMap.get(kp.id)!
|
||||
return {
|
||||
knowledgePoint: kp.name,
|
||||
masteryLevel: Math.round((m.masteryLevel / 100) * 5),
|
||||
errorCount: m.totalQuestions - m.correctQuestions,
|
||||
}
|
||||
})
|
||||
serviceInput.currentMastery = kps.flatMap((kp) => {
|
||||
const m = masteryMap.get(kp.id)
|
||||
if (!m) return []
|
||||
return [{
|
||||
knowledgePoint: kp.name,
|
||||
masteryLevel: Math.round((m.masteryLevel / 100) * 5),
|
||||
errorCount: m.totalQuestions - m.correctQuestions,
|
||||
}]
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -376,6 +378,38 @@ export async function getAiUsageStatsAction(): Promise<ActionState<AiUsageStats>
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
return { success: false, message: t("error.chatFailed") }
|
||||
return { success: false, message: t("error.statsFailed") }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 错题 AI 解释(P2-1 新增)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function explainErrorAction(
|
||||
input: ExplainErrorInput
|
||||
): Promise<ActionState<ExplainErrorResult>> {
|
||||
const t = await getTranslations("ai")
|
||||
try {
|
||||
const ctx = await requireAiPermission(
|
||||
Permissions.AI_CHAT,
|
||||
Permissions.ERROR_BOOK_READ
|
||||
)
|
||||
const parsed = ExplainErrorInputSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: t("error.invalidInput") }
|
||||
}
|
||||
|
||||
const service = createAiService(ctx.userId)
|
||||
const result = await safeAiCall(() => service.explainError(parsed.data))
|
||||
if (!result.ok) {
|
||||
return { success: false, message: result.message }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
return { success: false, message: t("error.analysisFailed") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,11 +229,11 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
|
||||
contextMessage: "Current page: Homework grading view",
|
||||
contextMessage: t("chat.contextMessage.teacherGrading"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.teacher.0"),
|
||||
"What are common mistakes in this type of question?",
|
||||
"How should I give constructive feedback?",
|
||||
t("chat.suggestedPrompts.context.teacherGrading.0"),
|
||||
t("chat.suggestedPrompts.context.teacherGrading.1"),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -243,11 +243,11 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
|
||||
contextMessage: "Current page: Lesson plan editor",
|
||||
contextMessage: t("chat.contextMessage.teacherLesson"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.teacher.1"),
|
||||
"Suggest a hook for this lesson",
|
||||
"What are some differentiation strategies?",
|
||||
t("chat.suggestedPrompts.context.teacherLesson.0"),
|
||||
t("chat.suggestedPrompts.context.teacherLesson.1"),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -257,11 +257,11 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
|
||||
contextMessage: "Current page: Exam builder",
|
||||
contextMessage: t("chat.contextMessage.teacherExam"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.teacher.2"),
|
||||
"Generate a question on this topic",
|
||||
"Analyze the difficulty distribution",
|
||||
t("chat.suggestedPrompts.context.teacherExam.0"),
|
||||
t("chat.suggestedPrompts.context.teacherExam.1"),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -271,7 +271,7 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
|
||||
contextMessage: "Current page: Error book (student view)",
|
||||
contextMessage: t("chat.contextMessage.studentErrorBook"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.student.0"),
|
||||
t("chat.suggestedPrompts.student.1"),
|
||||
@@ -285,11 +285,11 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
|
||||
contextMessage: "Current page: Student homework view",
|
||||
contextMessage: t("chat.contextMessage.studentHomework"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.student.0"),
|
||||
"Give me a hint, not the answer",
|
||||
"Help me understand this concept",
|
||||
t("chat.suggestedPrompts.context.studentHomework.0"),
|
||||
t("chat.suggestedPrompts.context.studentHomework.1"),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -299,7 +299,7 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
|
||||
contextMessage: "Current page: Parent dashboard",
|
||||
contextMessage: t("chat.contextMessage.parent"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.parent.0"),
|
||||
t("chat.suggestedPrompts.parent.1"),
|
||||
@@ -312,7 +312,7 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
|
||||
contextMessage: "Current page: Admin dashboard",
|
||||
contextMessage: t("chat.contextMessage.admin"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.admin.0"),
|
||||
t("chat.suggestedPrompts.admin.1"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
type ChartConfig,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { AiChartSpecSchema } from "../schema"
|
||||
|
||||
/**
|
||||
* AI 图表渲染器
|
||||
@@ -111,14 +113,15 @@ interface AiChartRendererProps {
|
||||
|
||||
/**
|
||||
* 解析 JSON 规格,失败时返回 null
|
||||
*
|
||||
* 使用 Zod schema 校验,避免 as 断言。
|
||||
*/
|
||||
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
|
||||
const parsed: unknown = JSON.parse(spec)
|
||||
const result = AiChartSpecSchema.safeParse(parsed)
|
||||
if (!result.success) return null
|
||||
return result.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -139,12 +142,13 @@ export function AiChartRenderer({
|
||||
spec,
|
||||
className,
|
||||
}: AiChartRendererProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
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">
|
||||
图表数据格式错误,无法渲染
|
||||
{t("chart.parseError")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { Component, type ReactNode } from "react"
|
||||
import { AlertCircle, RefreshCw } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
/**
|
||||
* AI 专用 Error Boundary
|
||||
*
|
||||
* 薄包装:委托给共享 SectionErrorBoundary,使用 ai 命名空间。
|
||||
* 保留同名导出以兼容现有 import。
|
||||
*/
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import type { ReactNode } from "react"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
|
||||
type AiErrorBoundaryProps = {
|
||||
children: ReactNode
|
||||
@@ -15,74 +18,14 @@ type AiErrorBoundaryProps = {
|
||||
onError?: (error: Error, info: unknown) => void
|
||||
}
|
||||
|
||||
type AiErrorBoundaryState = {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 专用 Error Boundary
|
||||
*
|
||||
* 包裹所有 AI 数据区块,防止单个 AI 调用失败导致整页崩溃。
|
||||
* 提供重试按钮与友好的错误提示。
|
||||
*/
|
||||
export class AiErrorBoundary extends Component<
|
||||
AiErrorBoundaryProps,
|
||||
AiErrorBoundaryState
|
||||
> {
|
||||
constructor(props: AiErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): AiErrorBoundaryState {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: unknown): void {
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, info)
|
||||
}
|
||||
}
|
||||
|
||||
private handleReset = (): void => {
|
||||
this.setState({ error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error, this.handleReset)
|
||||
}
|
||||
return <DefaultAiErrorFallback error={this.state.error} onReset={this.handleReset} />
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function DefaultAiErrorFallback({
|
||||
error,
|
||||
onReset,
|
||||
}: {
|
||||
error: Error
|
||||
onReset: () => void
|
||||
}): ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
export function AiErrorBoundary({
|
||||
children,
|
||||
fallback,
|
||||
onError,
|
||||
}: AiErrorBoundaryProps): ReactNode {
|
||||
return (
|
||||
<Card className="border-destructive/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{t("error.boundaryTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("error.boundaryDescription")}</p>
|
||||
<p className="text-xs text-muted-foreground/70 font-mono">{error.message}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onReset}>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SectionErrorBoundary namespace="ai" fallback={fallback} onError={onError}>
|
||||
{children}
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,28 @@ const CHART_TYPES: Record<string, AiChartType> = {
|
||||
"chart:radar": "radar",
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫:判断字符串是否为合法的 AiChartType
|
||||
*/
|
||||
function isAiChartType(value: string): value is AiChartType {
|
||||
return value === "bar" || value === "line" || value === "pie" || value === "radar"
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 language 标识中解析图表类型
|
||||
*/
|
||||
function resolveChartType(lang: string): AiChartType | undefined {
|
||||
// 优先查表(兼容 "chart:bar" 形式)
|
||||
const fromTable = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`]
|
||||
if (fromTable) return fromTable
|
||||
// 兼容 "chart:bar" 前缀形式
|
||||
if (lang.startsWith(CHART_LANG_PREFIX)) {
|
||||
const suffix = lang.slice(CHART_LANG_PREFIX.length)
|
||||
return isAiChartType(suffix) ? suffix : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Markdown 渲染器
|
||||
*
|
||||
@@ -87,12 +109,9 @@ function AiMarkdownRendererImpl({
|
||||
|
||||
// 检测图表代码块: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)
|
||||
const chartType = resolveChartType(lang)
|
||||
|
||||
if (chartType && (chartType === "bar" || chartType === "line" || chartType === "pie" || chartType === "radar")) {
|
||||
if (chartType) {
|
||||
const raw = String(children).replace(/\n$/, "")
|
||||
return <AiChartRenderer type={chartType} spec={raw} />
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function AiProviderSelector({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("provider.label")}</FormLabel>
|
||||
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading}>
|
||||
<Select value={typeof field.value === "string" ? field.value : ""} onValueChange={field.onChange} disabled={loading}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Sparkles, Check, RefreshCw } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { AiSuggestionSkeleton } from "./ai-skeleton"
|
||||
import { useAiClient } from "../context/ai-client-provider"
|
||||
import type { SimilarQuestionResult } from "../types"
|
||||
|
||||
type AiSuggestionCardProps = {
|
||||
/** 原始题目文本 */
|
||||
questionText: string
|
||||
/** 题目类型 */
|
||||
questionType: string
|
||||
/** 学科 */
|
||||
subject?: string
|
||||
/** 知识点 ID 列表 */
|
||||
knowledgePointIds?: string[]
|
||||
/** 需要生成的题目数量 */
|
||||
count?: number
|
||||
/** 选中题目后的回调 */
|
||||
onSelectQuestion?: (question: SimilarQuestionResult) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 相似题建议卡片
|
||||
*
|
||||
* 可复用组件,展示 AI 生成的相似练习题。
|
||||
* 用于错题本、作业练习等场景。
|
||||
*/
|
||||
export function AiSuggestionCard({
|
||||
questionText,
|
||||
questionType,
|
||||
subject,
|
||||
knowledgePointIds,
|
||||
count = 3,
|
||||
onSelectQuestion,
|
||||
}: AiSuggestionCardProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
const aiClient = useAiClient()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [questions, setQuestions] = useState<SimilarQuestionResult[]>([])
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await aiClient.suggestSimilarQuestions({
|
||||
questionText,
|
||||
questionType,
|
||||
subject,
|
||||
knowledgePointIds,
|
||||
count,
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setQuestions(result.data)
|
||||
setHasLoaded(true)
|
||||
toast.success(t("suggestion.loaded"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("suggestion.error"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("suggestion.error"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (question: SimilarQuestionResult): void => {
|
||||
onSelectQuestion?.(question)
|
||||
toast.success(t("suggestion.selected"))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <AiSuggestionSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("suggestion.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{hasLoaded && questions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("suggestion.empty")}</p>
|
||||
) : questions.length > 0 ? (
|
||||
<>
|
||||
{questions.map((question, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-md border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm flex-1">{question.text}</p>
|
||||
{question.difficulty ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{t("suggestion.difficulty")}: {question.difficulty}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{question.options && question.options.length > 0 ? (
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
{question.options.map((opt, optIndex) => (
|
||||
<li key={optIndex}>
|
||||
<span className="font-medium">{opt.id}.</span> {opt.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{question.explanation ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{question.explanation}
|
||||
</p>
|
||||
) : null}
|
||||
{onSelectQuestion ? (
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSelect(question)}
|
||||
>
|
||||
<Check className="mr-1 h-3.5 w-3.5" />
|
||||
{t("suggestion.select")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
{t("suggestion.regenerate")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
className="w-full"
|
||||
>
|
||||
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
||||
{t("suggestion.generate")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Activity, Users, AlertTriangle, Clock } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
@@ -33,7 +33,7 @@ export function AiUsageDashboard(): React.ReactNode {
|
||||
const [stats, setStats] = useState<AiUsageStats | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const loadStats = async (): Promise<void> => {
|
||||
const loadStats = useCallback(async (): Promise<void> => {
|
||||
if (!aiClient.getAiUsageStats) return
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -41,19 +41,18 @@ export function AiUsageDashboard(): React.ReactNode {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data)
|
||||
} else {
|
||||
toast.error(result.message ?? t("error.chatFailed"))
|
||||
toast.error(result.message ?? t("error.statsFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error.chatFailed"))
|
||||
toast.error(t("error.statsFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [aiClient, t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadStats()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [loadStats])
|
||||
|
||||
const statCards = stats
|
||||
? [
|
||||
|
||||
54
src/modules/ai/context/create-ai-client-service.ts
Normal file
54
src/modules/ai/context/create-ai-client-service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import "server-only"
|
||||
|
||||
import {
|
||||
aiChatAction,
|
||||
suggestSimilarQuestionsAction,
|
||||
suggestGradingAction,
|
||||
generateLessonContentAction,
|
||||
generateQuestionVariantAction,
|
||||
analyzeWeaknessAction,
|
||||
generateChildSummaryAction,
|
||||
recommendStudyPathAction,
|
||||
getAiUsageStatsAction,
|
||||
explainErrorAction,
|
||||
} from "../actions"
|
||||
import type { AiClientService } from "../types"
|
||||
|
||||
/**
|
||||
* 创建完整的 AI 客户端服务(含全部 10 个 Action)
|
||||
*
|
||||
* 用于全局 layout 或需要全部 AI 能力的页面。
|
||||
* 通过 React Context 注入,客户端组件通过 useAiClient() 消费。
|
||||
*/
|
||||
export function createFullAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
generateChildSummary: generateChildSummaryAction,
|
||||
recommendStudyPath: recommendStudyPathAction,
|
||||
getAiUsageStats: getAiUsageStatsAction,
|
||||
explainError: explainErrorAction,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建核心 AI 客户端服务(仅 6 个常用 Action)
|
||||
*
|
||||
* 用于只需要 AI 业务能力(不含家长摘要/学习路径/统计/错题解释)的页面。
|
||||
* 可选字段(generateChildSummary/recommendStudyPath/getAiUsageStats/explainError)不注入,
|
||||
* 调用方组件需自行处理 undefined 情况。
|
||||
*/
|
||||
export function createCoreAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { useAiClient } from "../context/ai-client-provider"
|
||||
import type { AiChatMessage, AiChatResult } from "../types"
|
||||
|
||||
/**
|
||||
* AI 聊天 Hook
|
||||
*
|
||||
* 封装 AI 聊天逻辑,与 UI 分离。
|
||||
* 通过 useAiClient() 获取 Server Action 引用。
|
||||
*/
|
||||
export function useAiChat(): {
|
||||
messages: AiChatMessage[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
send: (messages: AiChatMessage[], providerId?: string) => Promise<AiChatResult | null>
|
||||
clear: () => void
|
||||
} {
|
||||
const aiClient = useAiClient()
|
||||
const [messages, setMessages] = useState<AiChatMessage[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const send = useCallback(
|
||||
async (input: AiChatMessage[], providerId?: string): Promise<AiChatResult | null> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await aiClient.chat({ messages: input, providerId })
|
||||
if (result.success && result.data) {
|
||||
const assistantContent = result.data.content
|
||||
setMessages((prev) => [...prev, ...input, {
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
}])
|
||||
return result.data
|
||||
}
|
||||
setError(result.message ?? "AI request failed")
|
||||
return null
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[aiClient]
|
||||
)
|
||||
|
||||
const clear = useCallback((): void => {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return { messages, loading, error, send, clear }
|
||||
}
|
||||
130
src/modules/ai/hooks/use-drag-position.ts
Normal file
130
src/modules/ai/hooks/use-drag-position.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import type { Position } from "./use-position-persistence"
|
||||
import { clampPosition } from "./use-position-persistence"
|
||||
|
||||
type DragState = {
|
||||
active: boolean
|
||||
moved: boolean
|
||||
startX: number
|
||||
startY: number
|
||||
originX: number
|
||||
originY: number
|
||||
pointerId: number
|
||||
}
|
||||
|
||||
type DragCallbacks = {
|
||||
/** 拖拽开始时触发(pointer down 后) */
|
||||
onDragStart: () => void
|
||||
/** 拖拽释放时触发,moved 表示是否发生了实际移动 */
|
||||
onRelease: (moved: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽位置 Hook
|
||||
*
|
||||
* 处理 pointer 事件,跟踪拖拽状态与位置变化。
|
||||
* 不处理边缘吸附、持久化等业务逻辑,通过回调委托给调用方。
|
||||
*/
|
||||
export function useDragPosition(
|
||||
position: Position,
|
||||
setPosition: React.Dispatch<React.SetStateAction<Position>>,
|
||||
callbacks: DragCallbacks
|
||||
): {
|
||||
dragging: boolean
|
||||
handlers: {
|
||||
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||
onPointerMove: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||
onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||
onPointerCancel: () => void
|
||||
}
|
||||
} {
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const dragStateRef = useRef<DragState>({
|
||||
active: false,
|
||||
moved: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
pointerId: -1,
|
||||
})
|
||||
const callbacksRef = useRef(callbacks)
|
||||
useEffect(() => {
|
||||
callbacksRef.current = callbacks
|
||||
}, [callbacks])
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>): void => {
|
||||
// 仅主键响应拖拽
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return
|
||||
const s = dragStateRef.current
|
||||
s.active = true
|
||||
s.moved = false
|
||||
s.startX = e.clientX
|
||||
s.startY = e.clientY
|
||||
s.originX = position.x
|
||||
s.originY = position.y
|
||||
s.pointerId = e.pointerId
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setDragging(true)
|
||||
callbacksRef.current.onDragStart()
|
||||
},
|
||||
[position]
|
||||
)
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>): void => {
|
||||
const s = dragStateRef.current
|
||||
if (!s.active || e.pointerId !== s.pointerId) return
|
||||
const dx = e.clientX - s.startX
|
||||
const dy = e.clientY - s.startY
|
||||
// 阈值过滤微抖动
|
||||
if (!s.moved && Math.abs(dx) + Math.abs(dy) < 4) return
|
||||
s.moved = true
|
||||
const next = clampPosition({
|
||||
x: s.originX + dx,
|
||||
y: s.originY + dy,
|
||||
})
|
||||
setPosition(next)
|
||||
},
|
||||
[setPosition]
|
||||
)
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>): void => {
|
||||
const s = dragStateRef.current
|
||||
if (!s.active || e.pointerId !== s.pointerId) return
|
||||
s.active = false
|
||||
setDragging(false)
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
callbacksRef.current.onRelease(s.moved)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const onPointerCancel = useCallback((): void => {
|
||||
dragStateRef.current.active = false
|
||||
setDragging(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
dragging,
|
||||
handlers: {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,50 +2,43 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
type Position = { x: number; y: number }
|
||||
import {
|
||||
BALL_SIZE,
|
||||
HIDE_THRESHOLD,
|
||||
MARGIN,
|
||||
type Position,
|
||||
clampPosition,
|
||||
getDefaultPosition,
|
||||
savePosition,
|
||||
usePositionPersistence,
|
||||
} from "./use-position-persistence"
|
||||
import { useDragPosition } from "./use-drag-position"
|
||||
|
||||
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)),
|
||||
}
|
||||
/**
|
||||
* 计算吸附到最近边缘后的 X 坐标
|
||||
*/
|
||||
function snapToEdge(x: number): number {
|
||||
if (typeof window === "undefined") return x
|
||||
const w = window.innerWidth
|
||||
const centerX = x + BALL_SIZE / 2
|
||||
const distanceToLeft = centerX
|
||||
const distanceToRight = w - centerX
|
||||
return distanceToLeft < distanceToRight ? MARGIN : w - BALL_SIZE - 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
|
||||
}
|
||||
/**
|
||||
* 计算半隐藏时的视觉偏移量
|
||||
*/
|
||||
function calculateHiddenOffset(
|
||||
position: Position,
|
||||
hidden: boolean,
|
||||
hovered: boolean,
|
||||
dragging: boolean
|
||||
): number {
|
||||
if (!hidden || hovered || dragging) return 0
|
||||
return position.x <= MARGIN + 2
|
||||
? -(BALL_SIZE * HIDE_THRESHOLD)
|
||||
: BALL_SIZE * HIDE_THRESHOLD
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,135 +50,69 @@ function savePosition(pos: Position): void {
|
||||
* - 单击(未发生拖动)触发 onClick
|
||||
* - 位置持久化到 localStorage
|
||||
* - 窗口 resize 时自动校正位置
|
||||
*
|
||||
* V3:拆分为 use-position-persistence + use-drag-position + 本 hook 组合
|
||||
*/
|
||||
export function useFloatingBall(onClick: () => void) {
|
||||
// 服务端与客户端首次渲染一致(position 在屏幕外,不渲染按钮)
|
||||
// 在 useEffect 中加载真实位置,避免 hydration mismatch
|
||||
const [position, setPosition] = useState<Position>({ x: 9999, y: 9999 })
|
||||
export function useFloatingBall(onClick: () => void): {
|
||||
position: Position
|
||||
hidden: boolean
|
||||
dragging: boolean
|
||||
hovered: boolean
|
||||
hiddenOffset: number
|
||||
handlers: {
|
||||
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||
onPointerMove: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||
onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||
onPointerCancel: () => void
|
||||
onMouseEnter: () => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
show: () => void
|
||||
resetPosition: () => void
|
||||
} {
|
||||
const { position, setPosition } = usePositionPersistence()
|
||||
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())
|
||||
const handleDragStart = useCallback((): void => {
|
||||
setHidden(false)
|
||||
}, [])
|
||||
|
||||
// 窗口 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
|
||||
}
|
||||
|
||||
const handleRelease = useCallback(
|
||||
(moved: boolean): void => {
|
||||
// 未移动 → 视为点击
|
||||
if (!state.moved) {
|
||||
if (!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 snappedX = snapToEdge(position.x)
|
||||
const finalPos = clampPosition({ x: snappedX, y: position.y })
|
||||
setPosition(finalPos)
|
||||
savePosition(finalPos)
|
||||
setHidden(shouldHide)
|
||||
setHidden(true)
|
||||
// 标记刚隐藏,阻止后续 mouseEnter 立即展开
|
||||
justHiddenRef.current = shouldHide
|
||||
justHiddenRef.current = true
|
||||
// 清除 hovered,确保 hiddenOffset 生效
|
||||
setHovered(false)
|
||||
},
|
||||
[position]
|
||||
[position, setPosition]
|
||||
)
|
||||
|
||||
const handlePointerCancel = useCallback(() => {
|
||||
const state = dragStateRef.current
|
||||
state.active = false
|
||||
setDragging(false)
|
||||
}, [])
|
||||
const { dragging, handlers: dragHandlers } = useDragPosition(
|
||||
position,
|
||||
setPosition,
|
||||
{ onDragStart: handleDragStart, onRelease: handleRelease }
|
||||
)
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
const handleMouseEnter = useCallback((): void => {
|
||||
// 如果刚通过拖拽隐藏,不立即展开(需先离开再进入才展开)
|
||||
if (justHiddenRef.current) {
|
||||
justHiddenRef.current = false
|
||||
@@ -195,33 +122,26 @@ export function useFloatingBall(onClick: () => void) {
|
||||
if (hidden) setHidden(false)
|
||||
}, [hidden])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
setHovered(false)
|
||||
// 离开后清除 justHidden 标记,下次进入可正常展开
|
||||
justHiddenRef.current = false
|
||||
}, [])
|
||||
|
||||
const show = useCallback(() => {
|
||||
const show = useCallback((): void => {
|
||||
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
|
||||
const resetPosition = useCallback((): void => {
|
||||
justHiddenRef.current = false
|
||||
const fresh = getDefaultPosition()
|
||||
setPosition(fresh)
|
||||
savePosition(fresh)
|
||||
setHidden(false)
|
||||
}, [setPosition])
|
||||
|
||||
const hiddenOffset = calculateHiddenOffset(position, hidden, hovered, dragging)
|
||||
|
||||
return {
|
||||
position,
|
||||
@@ -230,10 +150,7 @@ export function useFloatingBall(onClick: () => void) {
|
||||
hovered,
|
||||
hiddenOffset,
|
||||
handlers: {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerUp,
|
||||
onPointerCancel: handlePointerCancel,
|
||||
...dragHandlers,
|
||||
onMouseEnter: handleMouseEnter,
|
||||
onMouseLeave: handleMouseLeave,
|
||||
},
|
||||
|
||||
99
src/modules/ai/hooks/use-position-persistence.ts
Normal file
99
src/modules/ai/hooks/use-position-persistence.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export type Position = { x: number; y: number }
|
||||
|
||||
export const STORAGE_KEY = "ai-widget-position"
|
||||
export const HIDE_THRESHOLD = 0.55
|
||||
export const BALL_SIZE = 56
|
||||
export const MARGIN = 16
|
||||
|
||||
/**
|
||||
* 将位置限制在视口内
|
||||
*/
|
||||
export 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)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载位置,失败时返回默认右下角位置
|
||||
*/
|
||||
export 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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化位置到 localStorage
|
||||
*/
|
||||
export function savePosition(pos: Position): void {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认位置(右下角)
|
||||
*/
|
||||
export function getDefaultPosition(): Position {
|
||||
if (typeof window === "undefined") return { x: 9999, y: 9999 }
|
||||
return clampPosition({
|
||||
x: window.innerWidth - BALL_SIZE - MARGIN * 2,
|
||||
y: window.innerHeight - BALL_SIZE - MARGIN * 4,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置持久化 Hook
|
||||
*
|
||||
* 管理 position 状态,mount 时从 localStorage 加载,resize 时校正。
|
||||
* 服务端与客户端首次渲染一致(position 在屏幕外),避免 hydration mismatch。
|
||||
*/
|
||||
export function usePositionPersistence(): {
|
||||
position: Position
|
||||
setPosition: React.Dispatch<React.SetStateAction<Position>>
|
||||
} {
|
||||
const [position, setPosition] = useState<Position>({ x: 9999, y: 9999 })
|
||||
|
||||
// 初始化位置:在客户端 mount 后加载真实位置
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPosition(loadPosition())
|
||||
}, [])
|
||||
|
||||
// 窗口 resize 时校正
|
||||
useEffect(() => {
|
||||
const handleResize = (): void => {
|
||||
setPosition((prev) => clampPosition(prev))
|
||||
}
|
||||
window.addEventListener("resize", handleResize)
|
||||
return () => window.removeEventListener("resize", handleResize)
|
||||
}, [])
|
||||
|
||||
return { position, setPosition }
|
||||
}
|
||||
@@ -225,3 +225,50 @@ export const StudyPathResultSchema = z.object({
|
||||
summary: z.string().min(1),
|
||||
motivation: z.string().min(1),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 错题 AI 解释校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ExplainErrorInputSchema = z.object({
|
||||
questionText: z.string().min(1).max(4000),
|
||||
questionType: z.string().min(1),
|
||||
studentAnswer: z.string().min(1).max(8000),
|
||||
correctAnswer: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const ExplainErrorResultSchema = z.object({
|
||||
errorAnalysis: z.string().min(1),
|
||||
correctApproach: z.string().min(1),
|
||||
keyConcepts: z.array(z.string().min(1)),
|
||||
preventionTips: z.array(z.string().min(1)),
|
||||
practiceSuggestion: z.string().min(1),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 图表规格校验(用于 ai-chart-renderer.tsx 解析 AI 返回的图表 JSON)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AiChartSeriesSchema = z.object({
|
||||
dataKey: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
color: z.string().optional(),
|
||||
fillOpacity: z.number().optional(),
|
||||
strokeWidth: z.number().optional(),
|
||||
strokeDasharray: z.string().optional(),
|
||||
})
|
||||
|
||||
export const AiChartSpecSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(["bar", "line", "pie", "radar"]).optional(),
|
||||
data: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
|
||||
xKey: z.string().optional(),
|
||||
angleKey: z.string().optional(),
|
||||
series: z.array(AiChartSeriesSchema),
|
||||
yDomain: z.tuple([z.number(), z.number()]).optional(),
|
||||
height: z.number().optional(),
|
||||
showLegend: z.boolean().optional(),
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
|
||||
CHILD_SUMMARY_SYSTEM_PROMPT,
|
||||
STUDY_PATH_SYSTEM_PROMPT,
|
||||
EXPLAIN_ERROR_SYSTEM_PROMPT,
|
||||
} from "./prompt-templates"
|
||||
import { withAiTracking } from "./usage-tracker"
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
WeaknessAnalysisResultSchema,
|
||||
ChildSummaryResultSchema,
|
||||
StudyPathResultSchema,
|
||||
ExplainErrorResultSchema,
|
||||
} from "../schema"
|
||||
import type {
|
||||
AiChatMessage,
|
||||
@@ -41,6 +43,8 @@ import type {
|
||||
ChildSummaryResult,
|
||||
StudyPathInput,
|
||||
StudyPathResult,
|
||||
ExplainErrorInput,
|
||||
ExplainErrorResult,
|
||||
} from "../types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -144,9 +148,10 @@ const callAi = async (
|
||||
...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}),
|
||||
...(options?.providerId ? { providerId: options.providerId } : {}),
|
||||
})
|
||||
// 从 unknown 类型安全提取 total_tokens(避免 as 断言)
|
||||
const tokenUsage =
|
||||
result.usage && typeof result.usage === "object" && "total_tokens" in result.usage
|
||||
? Number((result.usage as unknown as Record<string, unknown>).total_tokens ?? 0)
|
||||
? Number(result.usage.total_tokens ?? 0)
|
||||
: undefined
|
||||
return { content: result.content, tokenUsage }
|
||||
}
|
||||
@@ -169,7 +174,11 @@ export class DefaultAiService implements AiService {
|
||||
...options,
|
||||
temperature: options?.temperature ?? 0.7,
|
||||
})
|
||||
return { result: { content, usage: null }, tokenUsage }
|
||||
// usage 字段返回 token 用量对象(unknown 类型),便于调用方按需类型缩小
|
||||
return {
|
||||
result: { content, usage: tokenUsage !== undefined ? { total_tokens: tokenUsage } : null },
|
||||
tokenUsage,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,9 +201,10 @@ export class DefaultAiService implements AiService {
|
||||
{ temperature: 0.5, maxTokens: 3000 }
|
||||
)
|
||||
const parsed = extractJson(content)
|
||||
// 安全提取 questions 字段(使用 in 操作符类型缩小,无需 as 断言)
|
||||
const list =
|
||||
parsed && typeof parsed === "object" && "questions" in parsed
|
||||
? (parsed as Record<string, unknown>).questions
|
||||
? parsed.questions
|
||||
: parsed
|
||||
const validated = SimilarQuestionListSchema.safeParse(list)
|
||||
if (!validated.success) return { result: [] }
|
||||
@@ -413,6 +423,39 @@ export class DefaultAiService implements AiService {
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
|
||||
async explainError(input: ExplainErrorInput): Promise<ExplainErrorResult> {
|
||||
return withAiTracking(this.userId, "explain_error", undefined, async () => {
|
||||
const userLines = [
|
||||
`Question Type: ${input.questionType}`,
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
input.knowledgePointIds?.length
|
||||
? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
|
||||
: "",
|
||||
`Question:\n${input.questionText}`,
|
||||
`Student Answer:\n${input.studentAnswer}`,
|
||||
input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
|
||||
].filter((line) => line.length > 0)
|
||||
const { content } = await callAi(
|
||||
buildChatMessages(EXPLAIN_ERROR_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||
{ temperature: 0.4, maxTokens: 2000 }
|
||||
)
|
||||
const parsed = extractJson(content)
|
||||
const validated = ExplainErrorResultSchema.safeParse(parsed)
|
||||
if (!validated.success) {
|
||||
return {
|
||||
result: {
|
||||
errorAnalysis: "Unable to analyze the error at this time.",
|
||||
correctApproach: "Please consult your teacher for help.",
|
||||
keyConcepts: [],
|
||||
preventionTips: [],
|
||||
practiceSuggestion: "Review the relevant chapter and try again.",
|
||||
},
|
||||
}
|
||||
}
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -275,3 +275,26 @@ export const STUDY_PATH_SYSTEM_PROMPT = [
|
||||
"- motivation should be age-appropriate and encouraging.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
export const EXPLAIN_ERROR_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 tutor specializing in helping students understand their mistakes.",
|
||||
"Analyze the student's error and provide a clear, encouraging explanation.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "errorAnalysis": "detailed analysis of why the student made this error",',
|
||||
' "correctApproach": "step-by-step correct solution approach",',
|
||||
' "keyConcepts": ["list of key concepts the student needs to review"],',
|
||||
' "preventionTips": ["tips to avoid similar mistakes in the future"],',
|
||||
' "practiceSuggestion": "specific practice recommendation"',
|
||||
"}",
|
||||
"Rules:",
|
||||
"- Use age-appropriate language for K12 students.",
|
||||
"- Be encouraging and constructive, never dismissive.",
|
||||
"- errorAnalysis should identify the specific misconception, not just say 'wrong'.",
|
||||
"- correctApproach should be step-by-step and easy to follow.",
|
||||
"- keyConcepts should list 2-5 fundamental concepts.",
|
||||
"- preventionTips should be actionable and specific.",
|
||||
"- practiceSuggestion should recommend a specific type of practice problem.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
@@ -5,7 +5,7 @@ import { recordAiEvent } from "../data-access"
|
||||
|
||||
export type AiUsageEvent = {
|
||||
userId: string
|
||||
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path"
|
||||
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path" | "explain_error"
|
||||
providerId?: string
|
||||
model?: string
|
||||
success: boolean
|
||||
@@ -23,6 +23,7 @@ const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
|
||||
weakness_analysis: "ai.weakness_analysis",
|
||||
child_summary: "ai.child_summary",
|
||||
study_path: "ai.study_path",
|
||||
explain_error: "ai.explain_error",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -221,6 +221,36 @@ export type AiUsageStats = {
|
||||
}>
|
||||
}
|
||||
|
||||
/** 错题 AI 解释输入 */
|
||||
export type ExplainErrorInput = {
|
||||
/** 题目文本 */
|
||||
questionText: string
|
||||
/** 题目类型 */
|
||||
questionType: string
|
||||
/** 学生错误答案 */
|
||||
studentAnswer: string
|
||||
/** 正确答案 */
|
||||
correctAnswer?: string
|
||||
/** 学科 */
|
||||
subject?: string
|
||||
/** 知识点 ID 列表 */
|
||||
knowledgePointIds?: string[]
|
||||
}
|
||||
|
||||
/** 错题 AI 解释结果 */
|
||||
export type ExplainErrorResult = {
|
||||
/** 错误原因分析 */
|
||||
errorAnalysis: string
|
||||
/** 正确解题思路 */
|
||||
correctApproach: string
|
||||
/** 关键知识点 */
|
||||
keyConcepts: string[]
|
||||
/** 类似错误防范建议 */
|
||||
preventionTips: string[]
|
||||
/** 练习建议 */
|
||||
practiceSuggestion: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 能力配置(角色驱动)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -236,6 +266,7 @@ export type AiCapability =
|
||||
| "study-path"
|
||||
| "child-summary"
|
||||
| "usage-stats"
|
||||
| "explain-error"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 服务接口
|
||||
@@ -257,6 +288,7 @@ export interface AiService {
|
||||
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
|
||||
generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult>
|
||||
recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult>
|
||||
explainError(input: ExplainErrorInput): Promise<ExplainErrorResult>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,6 +322,9 @@ export interface AiClientService {
|
||||
input: StudyPathInput
|
||||
) => Promise<ActionState<StudyPathResult>>
|
||||
getAiUsageStats?: () => Promise<ActionState<AiUsageStats>>
|
||||
explainError?: (
|
||||
input: ExplainErrorInput
|
||||
) => Promise<ActionState<ExplainErrorResult>>
|
||||
/** 预留埋点接口 */
|
||||
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user