fix(ai): V3 长期问题修复+规则合规+竞品对标

## P1 安全加固
- 原子化每日限额(tryConsumeDailyQuota)解决 TOCTOU 竞态
- 流式端点补齐 Zod 校验 + rate limit + 服务端强制 systemPrompt
- 配额回退机制(refundDailyQuota):过滤/失败不扣配额
- PII 最小化:移除 AI prompt 中的学生姓名

## P1 数据一致性
- 修复 capability 埋点缺失 child_summary/study_path 类型
- 创建 data-access.ts:真实统计聚合替代硬编码零
- 修复 generateChildSummary/recommendStudyPath 的 capability 标记

## P2 可靠性
- AI 调用重试机制(withRetry 指数退避,429/5xx,2 次重试)
- 30s 超时配置
- 流式 controller 安全 enqueue(防已关闭抛错)
- localStorage 防抖持久化(500ms,流式过程中跳过)

## P2 TypeScript/规则合规
- 移除 as 断言(VariantType 类型守卫、Permission 类型、StreamErrorKey)
- 补齐返回类型标注(POST/getStatusFromError/DashboardLayout)
- 拆分 use-ai-chat-stream hook(190→107 行,函数体≤80 行)
- 抽取 stream-utils.ts(SSE 解析/错误映射/消息工具)
- Tailwind 任意值添加注释说明(max-w-[80%] 聊天气泡)

## P3 竞品对标
- 苏格拉底式辅导强化(对标 Khanmigo):
  - SOCRATIC_TUTOR_SYSTEM_PROMPT 3 级提示升级
  - 强化 STUDENT_BLOCKED_PATTERNS 正则(中英文答案拦截)
  - validateSocraticOutput 服务端校验(问号结尾+连续陈述句限制)
  - socratic_warning SSE 事件类型
- 知识图谱集成(对标 Squirrel AI):
  - StudyPathInput 新增 knowledgeGraph/textbookId 字段
  - recommendStudyPathAction 自动从 textbooks 模块获取图谱+掌握度
  - STUDY_PATH_SYSTEM_PROMPT 增加前置依赖链规则
  - WEAKNESS_ANALYSIS_SYSTEM_PROMPT 增加 rootCause 字段

## 架构文档同步
- 004 更新 AI 模块章节(V3 标记/新导出/依赖关系/安全机制/文件清单)
- 005 更新 modules.ai 节点(dependsOn/exports/dataAccess/streamUtils/dependencyMatrix)
This commit is contained in:
SpecialX
2026-06-23 09:39:18 +08:00
parent 036a2f2839
commit 696346dc08
22 changed files with 847 additions and 238 deletions

View File

@@ -34,7 +34,7 @@ export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
}): React.ReactNode {
return (
<AiClientProvider service={aiClientService}>
<SidebarProvider sidebar={<AppSidebar />}>

View File

@@ -6,14 +6,14 @@ import { Permissions } from "@/shared/types/permissions"
export const dynamic = "force-dynamic"
const getStatusFromError = (message: string) => {
const getStatusFromError = (message: string): number => {
if (message === "Invalid payload" || message === "Messages are required") return 400
if (message === "AI API key missing") return 500
if (message === "Empty response") return 502
return 502
}
export async function POST(req: Request) {
export async function POST(req: Request): Promise<NextResponse> {
try {
const ctx = await requirePermission(Permissions.AI_CHAT)
const userId = ctx.userId

View File

@@ -6,15 +6,22 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar
import { createAiChatCompletionStream } from "@/shared/lib/ai/client"
import { getAiErrorMessage } from "@/shared/lib/ai"
import { trackEvent } from "@/shared/lib/track-event"
import {
rateLimit,
rateLimitKey,
RATE_LIMIT_RULES,
} from "@/shared/lib/rate-limit"
import { env } from "@/env.mjs"
import { CHAT_SYSTEM_PROMPT } from "@/modules/ai/services/prompt-templates"
import { CHAT_SYSTEM_PROMPT, SOCRATIC_TUTOR_SYSTEM_PROMPT } from "@/modules/ai/services/prompt-templates"
import {
filterUserInput,
filterAiOutput,
checkDailyLimit,
incrementDailyUsage,
tryConsumeDailyQuota,
refundDailyQuota,
validateSocraticOutput,
} from "@/modules/ai/services/content-safety"
import { AiChatInputSchema } from "@/modules/ai/schema"
import type { AiChatMessage } from "@/modules/ai/types"
/**
@@ -23,11 +30,14 @@ import type { AiChatMessage } from "@/modules/ai/types"
* 使用 Server-Sent Events 逐 token 推送 AI 回复,
* 降低用户感知延迟。
*
* 安全:
* 安全策略(与非流式端点一致)
* - requirePermission(AI_CHAT) 权限校验
* - rateLimit 每分钟限流(防高频滥用)
* - Zod 校验输入AiChatInputSchema限制消息数/长度)
* - tryConsumeDailyQuota 原子化每日限额(防 TOCTOU 竞态)
* - 输入/输出内容安全过滤
* - 每日交互限制
* - 学生侧 Socratic 模式
* - 学生侧 Socratic 模式(服务端强制,忽略客户端 systemPrompt
* - 过滤/失败时 refundDailyQuota不惩罚用户
*/
const formatEvent = (data: unknown): string => {
@@ -50,34 +60,45 @@ export async function POST(request: NextRequest): Promise<Response> {
const userRole = session?.user?.role ?? "student"
const isStudent = userRole === "student"
// 2. 每日限制
const limitCheck = checkDailyLimit(ctx.userId, userRole)
if (limitCheck.blocked) {
// 2. Rate limit与非流式端点一致
const limitResult = rateLimit({
key: rateLimitKey("ai-chat-stream", ctx.userId),
...RATE_LIMIT_RULES.AI_CHAT,
})
if (!limitResult.success) {
return new Response(formatError("Rate limit exceeded"), {
status: 429,
headers: { "Content-Type": "text/event-stream" },
})
}
// 3. Zod 校验输入(防止超大 message 导致 token 爆炸)
const rawBody = await request.json().catch(() => null)
const parseResult = AiChatInputSchema.safeParse(rawBody)
if (!parseResult.success) {
return new Response(formatError("Invalid input"), {
status: 400,
headers: { "Content-Type": "text/event-stream" },
})
}
const body = parseResult.data
// 4. 原子化每日限额检查(防 TOCTOU 竞态)
const quotaResult = tryConsumeDailyQuota(ctx.userId, userRole)
if (quotaResult.blocked) {
return new Response(formatError("Daily limit reached"), {
status: 429,
headers: { "Content-Type": "text/event-stream" },
})
}
// 3. 解析请求
const body = (await request.json()) as {
messages?: AiChatMessage[]
providerId?: string
systemPrompt?: string
}
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
return new Response(formatError("Messages are required"), {
status: 400,
headers: { "Content-Type": "text/event-stream" },
})
}
// 4. 输入安全过滤
// 5. 输入安全过滤
for (const msg of body.messages) {
if (msg.role === "user") {
const filterResult = filterUserInput(msg.content, { isStudent })
if (filterResult.blocked) {
// 输入被过滤,回退配额
refundDailyQuota(ctx.userId)
return new Response(formatError("Input blocked by safety filter"), {
status: 400,
headers: { "Content-Type": "text/event-stream" },
@@ -86,24 +107,35 @@ export async function POST(request: NextRequest): Promise<Response> {
}
}
// 5. 构建 system prompt(学生侧 Socratic 模式)
const baseSystemPrompt = body.systemPrompt ?? CHAT_SYSTEM_PROMPT
const studentSystemPrompt = isStudent
? `${baseSystemPrompt}\n\nIMPORTANT: You are in student mode. Use the Socratic method. Do NOT give direct answers. Guide the student to find the answer themselves through questions and hints.`
: baseSystemPrompt
// 6. 构建 system prompt
// 安全:服务端强制使用系统提示词,忽略客户端传入的 systemPrompt
// 防止学生绕过 Socratic 模式获取直接答案。
const baseSystemPrompt = isStudent
? SOCRATIC_TUTOR_SYSTEM_PROMPT
: CHAT_SYSTEM_PROMPT
const messages: AiChatMessage[] = [
{ role: "system", content: studentSystemPrompt },
{ role: "system", content: baseSystemPrompt },
...body.messages,
]
// 6. 流式调用 AI
// 7. 流式调用 AI
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const startTime = Date.now()
let fullContent = ""
let success = true
let errorMessage: string | undefined
let wasFiltered = false
const safeEnqueue = (text: string): void => {
// 防止 controller 已关闭时 enqueue 抛错
try {
controller.enqueue(encoder.encode(text))
} catch {
// controller 已关闭,忽略
}
}
try {
const aiStream = createAiChatCompletionStream({
@@ -119,27 +151,49 @@ export async function POST(request: NextRequest): Promise<Response> {
// 输出安全过滤(逐 chunk 检查关键词)
const outputFilter = filterAiOutput(chunk, { isStudent })
if (outputFilter.blocked) {
controller.enqueue(encoder.encode(formatEvent({
safeEnqueue(formatEvent({
type: "filtered",
message: "Content filtered for safety",
})))
}))
success = false
wasFiltered = true
break
}
controller.enqueue(encoder.encode(formatEvent({ type: "token", content: chunk })))
safeEnqueue(formatEvent({ type: "token", content: chunk }))
}
// 增加每日使用计数
incrementDailyUsage(ctx.userId)
// 仅在成功完成时保留配额计数
// 过滤或失败时回退配额(不惩罚用户)
if (!success || wasFiltered) {
refundDailyQuota(ctx.userId)
}
controller.enqueue(encoder.encode(FORMAT_DONE))
// 学生侧:苏格拉底式辅导输出校验
// 流式完成后检查完整回复是否符合引导式教学原则
if (success && !wasFiltered && isStudent && fullContent) {
const socraticResult = validateSocraticOutput(fullContent)
if (!socraticResult.valid) {
safeEnqueue(formatEvent({
type: "socratic_warning",
message: "AI response did not fully follow Socratic tutoring principles.",
}))
}
}
safeEnqueue(FORMAT_DONE)
} catch (error) {
success = false
errorMessage = error instanceof Error ? error.message : String(error)
controller.enqueue(encoder.encode(formatError(getAiErrorMessage(error))))
// AI 调用失败,回退配额
refundDailyQuota(ctx.userId)
safeEnqueue(formatError(getAiErrorMessage(error)))
} finally {
controller.close()
try {
controller.close()
} catch {
// 已关闭,忽略
}
// 埋点
void trackEvent({
@@ -152,6 +206,7 @@ export async function POST(request: NextRequest): Promise<Response> {
tokenCount: fullContent.length / 4,
errorMessage,
isStudent,
filtered: wasFiltered,
},
}).catch(() => {
// 静默失败

View File

@@ -4,9 +4,11 @@ import { getTranslations } from "next-intl/server"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { Permissions, type Permission } from "@/shared/types/permissions"
import { createAiService, safeAiCall } from "./services/ai-service"
import { getAiUsageStats } from "./data-access"
import { getKnowledgePointsWithRelations, getStudentKpMastery } from "@/modules/textbooks/data-access-graph"
import {
AiChatInputSchema,
GradingInputSchema,
@@ -42,10 +44,10 @@ import type {
// ---------------------------------------------------------------------------
const requireAiPermission = async (
...permissions: readonly string[]
...permissions: readonly Permission[]
): Promise<{ userId: string }> => {
const results = await Promise.all(
permissions.map((p) => requirePermission(p as never))
permissions.map((p) => requirePermission(p))
)
return { userId: results[0].userId }
}
@@ -294,8 +296,56 @@ export async function recommendStudyPathAction(
return { success: false, message: t("error.invalidInput") }
}
const serviceInput: StudyPathInput = { ...parsed.data }
// V3知识图谱集成对标 Squirrel AI 纳米级知识图谱)
// 若传入 textbookId 但未传入 knowledgeGraph自动从 textbooks 模块获取
if (parsed.data.textbookId && !parsed.data.knowledgeGraph) {
try {
const [kps, masteryMap] = await Promise.all([
getKnowledgePointsWithRelations(parsed.data.textbookId),
getStudentKpMastery(parsed.data.studentId, parsed.data.textbookId),
])
if (kps.length > 0) {
serviceInput.knowledgeGraph = {
nodes: kps.map((kp) => ({
id: kp.id,
name: kp.name,
level: kp.level,
masteryLevel: masteryMap.get(kp.id)?.masteryLevel,
})),
edges: kps
.flatMap((kp) =>
(kp.prerequisiteIds ?? []).map((prereqId) => ({
from: prereqId,
to: kp.id,
type: "prerequisite" as const,
}))
),
}
// 同步填充 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,
}
})
}
}
} catch {
// 知识图谱获取失败不阻断主流程,降级为无图谱模式
}
}
const service = createAiService(ctx.userId)
const result = await safeAiCall(() => service.recommendStudyPath(parsed.data))
const result = await safeAiCall(() => service.recommendStudyPath(serviceInput))
if (!result.ok) {
return { success: false, message: result.message }
}
@@ -317,20 +367,9 @@ export async function getAiUsageStatsAction(): Promise<ActionState<AiUsageStats>
try {
await requirePermission(Permissions.AI_CONFIGURE)
// 当前从 trackEvent 的内存数据返回统计
// 生产环境应查询数据库或 Redis 聚合
const stats: AiUsageStats = {
totalCalls: 0,
callsToday: 0,
callsThisWeek: 0,
activeUsers: 0,
errorRate: 0,
avgDurationMs: 0,
byCapability: [],
byRole: [],
topUsers: [],
recentActivity: [],
}
// 从 data-access 层获取真实聚合统计
// 当前为内存事件存储(单实例),生产环境应查询 DB 或 Redis
const stats = await getAiUsageStats()
return { success: true, data: stats }
} catch (error) {

View File

@@ -72,7 +72,7 @@ export function AiAssistantWidget(): React.ReactNode {
</span>
</Button>
</SheetTrigger>
<SheetContent className="w-full sm:max-w-[440px] overflow-y-auto p-0">
<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">

View File

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"
import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
@@ -31,8 +32,6 @@ type AiChatPanelProps = {
title?: string
/** 最大消息数 */
maxMessages?: number
/** 是否启用流式响应(默认 true */
streaming?: boolean
/** 建议提示词列表(空状态展示) */
suggestedPrompts?: string[]
}
@@ -49,7 +48,7 @@ type AiChatPanelProps = {
* - 清除对话按钮
* - 建议提示词
* - aria-live 无障碍
* - 对话历史持久化localStorage
* - 对话历史持久化localStorage,防抖写入
*/
export function AiChatPanel({
systemPrompt,
@@ -57,7 +56,6 @@ export function AiChatPanel({
placeholder,
title,
maxMessages = 50,
streaming: _streamingEnabled = true,
suggestedPrompts,
}: AiChatPanelProps): React.ReactNode {
const t = useTranslations("ai")
@@ -65,33 +63,31 @@ export function AiChatPanel({
const [input, setInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null)
const storageKey = "ai-chat-history"
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 从 localStorage 恢复对话历史
// 持久化对话历史(防抖 500ms避免流式过程中频繁写入
useEffect(() => {
try {
const stored = localStorage.getItem(storageKey)
if (stored) {
const parsed = JSON.parse(stored) as AiChatMessage[]
if (Array.isArray(parsed) && parsed.length > 0) {
// 通过 send 不合适,直接设置 messages 不支持
// 这里只是恢复显示,不重新发送
}
}
} catch {
// 忽略解析错误
if (messages.length === 0) return
// 流式过程中不写入,流结束后再写
if (streaming) return
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current)
}
}, [])
// 持久化对话历史
useEffect(() => {
try {
if (messages.length > 0) {
persistTimerRef.current = setTimeout(() => {
try {
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-20)))
} catch {
// 忽略写入错误
}
}, 500)
return () => {
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current)
}
} catch {
// 忽略写入错误
}
}, [messages])
}, [messages, streaming])
// 自动滚动到底部
useEffect(() => {
@@ -194,7 +190,7 @@ export function AiChatPanel({
{messages.length > 0 ? (
<ScrollArea
className="h-[300px] w-full rounded-md border p-3"
className="h-72 w-full rounded-md border p-3"
aria-live="polite"
aria-relevant="additions text"
>
@@ -202,7 +198,10 @@ export function AiChatPanel({
{messages.map((message, index) => (
<div
key={index}
className={`flex gap-2 ${message.role === "user" ? "justify-end" : "justify-start"}`}
className={cn(
"flex gap-2",
message.role === "user" ? "justify-end" : "justify-start"
)}
>
{message.role === "assistant" ? (
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
@@ -210,11 +209,13 @@ export function AiChatPanel({
<User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
)}
<div
className={`rounded-md px-3 py-2 text-sm max-w-[80%] ${
className={cn(
// 任意值:聊天气泡需按容器宽度百分比限制,固定 max-w-* 无法适应不同面板宽度
"rounded-md px-3 py-2 text-sm max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
}`}
)}
>
{message.role === "assistant" ? (
<AiMarkdownRenderer content={message.content} />
@@ -266,7 +267,7 @@ export function AiChatPanel({
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-[60px] resize-none"
className="min-h-16 resize-none"
disabled={streaming || messages.length >= maxMessages}
aria-label={t("chat.inputLabel")}
/>

View File

@@ -128,7 +128,7 @@ export function AiLessonContentGenerator({
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
placeholder={t("lessonPrep.additionalContextPlaceholder")}
className="min-h-[60px] text-sm"
className="min-h-16 text-sm"
maxLength={500}
/>
</div>

View File

@@ -22,6 +22,12 @@ import type { QuestionVariantResult } from "@/modules/ai/types"
type VariantType = "same_knowledge_point" | "different_difficulty" | "different_format"
const VARIANT_TYPES: readonly VariantType[] = ["same_knowledge_point", "different_difficulty", "different_format"]
const isVariantType = (value: string): value is VariantType => {
return VARIANT_TYPES.includes(value as VariantType)
}
type AiQuestionVariantGeneratorProps = {
/** 原始题目 */
originalQuestion: {
@@ -107,7 +113,11 @@ export function AiQuestionVariantGenerator({
</label>
<Select
value={variantType}
onValueChange={(value) => setVariantType(value as VariantType)}
onValueChange={(value) => {
if (isVariantType(value)) {
setVariantType(value)
}
}}
>
<SelectTrigger id="variant-type" className="w-full">
<SelectValue placeholder={t("exam.generate")} />

View File

@@ -18,6 +18,8 @@ type AiStudyPathProps = {
subject?: string
currentMastery?: StudyPathInput["currentMastery"]
learningGoal?: string
/** 教材 ID传入后自动获取知识图谱注入路径推荐对标 Squirrel AI */
textbookId?: string
onStartLearning?: (step: StudyPathResult["learningPath"][number]) => void
}
@@ -30,12 +32,16 @@ type AiStudyPathProps = {
* - 分步骤学习路径(含状态、建议、预计时间)
* - 学习总结
* - 鼓励语
*
* V3支持 textbookId 参数,自动获取知识图谱与掌握度数据,
* 使 AI 沿前置依赖链推荐学习路径。
*/
export function AiStudyPath({
studentId,
subject,
currentMastery,
learningGoal,
textbookId,
onStartLearning,
}: AiStudyPathProps): React.ReactNode {
const t = useTranslations("ai")
@@ -55,6 +61,7 @@ export function AiStudyPath({
subject,
currentMastery,
learningGoal,
textbookId,
})
if (result.success && result.data) {
setPath(result.data)

View File

@@ -0,0 +1,138 @@
import "server-only"
import type { AiUsageStats } from "./types"
/**
* AI 模块数据访问层
*
* 当前为内存实现(单实例),生产环境应替换为数据库或 Redis 查询。
*
* 职责:
* 1. AI 使用统计聚合(供管理员仪表盘)
* 2. AI 使用日志查询
*
* 注意trackEvent 当前仅输出到 console见 shared/lib/track-event.ts
* 生产环境接入真实 sinkPostHog/Sentry/DB 事件表)后,
* 此处的聚合函数需改为查询真实数据源。
*/
// ---------------------------------------------------------------------------
// 内存事件存储(生产环境替换为 DB/Redis
// ---------------------------------------------------------------------------
interface StoredAiEvent {
userId: string
capability: string
success: boolean
durationMs: number
timestamp: number
errorMessage?: string
}
// 单实例内存存储,最多保留最近 10000 条事件
const MAX_EVENTS = 10000
const eventStore: StoredAiEvent[] = []
/**
* 记录 AI 事件到内存存储
*
* 由 usage-tracker.ts 调用(非导出,仅模块内部使用)。
* 生产环境替换为 DB 异步写入。
*/
export function recordAiEvent(event: StoredAiEvent): void {
eventStore.push(event)
// 超出上限时丢弃最旧的事件
if (eventStore.length > MAX_EVENTS) {
eventStore.splice(0, eventStore.length - MAX_EVENTS)
}
}
// ---------------------------------------------------------------------------
// 使用统计聚合
// ---------------------------------------------------------------------------
/**
* 获取 AI 使用统计
*
* 从内存事件存储聚合统计数据。
* 生产环境应改为查询 DB 或 Redis。
*
* @returns AiUsageStats — 聚合后的统计数据
*/
export async function getAiUsageStats(): Promise<AiUsageStats> {
const now = Date.now()
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const todayMs = todayStart.getTime()
const weekMs = now - 7 * 24 * 60 * 60 * 1000
const totalCalls = eventStore.length
const callsToday = eventStore.filter((e) => e.timestamp >= todayMs).length
const callsThisWeek = eventStore.filter((e) => e.timestamp >= weekMs).length
const failedCalls = eventStore.filter((e) => !e.success).length
const errorRate = totalCalls > 0 ? failedCalls / totalCalls : 0
const durations = eventStore.map((e) => e.durationMs)
const avgDurationMs = durations.length > 0
? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length)
: 0
// 活跃用户(今日有调用的去重用户数)
const activeUserIds = new Set(
eventStore
.filter((e) => e.timestamp >= todayMs)
.map((e) => e.userId)
)
// 按 capability 聚合
const capabilityMap = new Map<string, number>()
for (const e of eventStore) {
capabilityMap.set(e.capability, (capabilityMap.get(e.capability) ?? 0) + 1)
}
const byCapability = Array.from(capabilityMap.entries())
.map(([capability, count]) => ({
capability,
count,
percentage: totalCalls > 0 ? Math.round((count / totalCalls) * 100) : 0,
}))
.sort((a, b) => b.count - a.count)
// 按角色聚合(当前无法从事件中获取角色,返回空数组)
// 生产环境若需按角色聚合,需在 StoredAiEvent 中增加 role 字段
const byRole: Array<{ role: string; count: number }> = []
// Top 用户
const userCallMap = new Map<string, number>()
for (const e of eventStore) {
userCallMap.set(e.userId, (userCallMap.get(e.userId) ?? 0) + 1)
}
const topUsers = Array.from(userCallMap.entries())
.map(([userId, count]) => ({ userId, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10)
// 最近活动
const recentActivity = eventStore
.slice(-20)
.reverse()
.map((e) => ({
userId: e.userId,
capability: e.capability,
success: e.success,
durationMs: e.durationMs,
timestamp: new Date(e.timestamp).toISOString(),
}))
return {
totalCalls,
callsToday,
callsThisWeek,
activeUsers: activeUserIds.size,
errorRate: Math.round(errorRate * 100) / 100,
avgDurationMs,
byCapability,
byRole,
topUsers,
recentActivity,
}
}

View File

@@ -0,0 +1,135 @@
/**
* SSE 流式响应解析与处理工具
*
* 从 use-ai-chat-stream hook 中抽取的纯函数,便于单独测试与复用。
*/
import type { AiChatMessage } from "../types"
/** SSE 事件类型 */
export type SseEvent =
| { type: "token"; content: string }
| { type: "error"; message: string }
| { type: "filtered" }
| { type: "socratic_warning"; message: string }
/**
* 从 Response 中读取并解析 SSE 事件流
*
* @param response - fetch 返回的 Response 对象
* @param onEvent - 每个解析出的事件回调
*/
export async function consumeSseStream(
response: Response,
onEvent: (event: SseEvent) => void
): Promise<void> {
const reader = response.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const data = line.slice(6).trim()
if (data === "[DONE]") continue
try {
const parsed = JSON.parse(data) as {
type: "token" | "error" | "filtered" | "socratic_warning"
content?: string
message?: string
}
if (parsed.type === "token" && parsed.content) {
onEvent({ type: "token", content: parsed.content })
} else if (parsed.type === "error") {
onEvent({ type: "error", message: parsed.message ?? "Unknown error" })
} else if (parsed.type === "filtered") {
onEvent({ type: "filtered" })
} else if (parsed.type === "socratic_warning") {
onEvent({ type: "socratic_warning", message: parsed.message ?? "Socratic warning" })
}
} catch {
// 忽略解析错误
}
}
}
}
/** 流式错误对应的 i18n key相对于 "ai" 命名空间) */
export type StreamErrorKey =
| "safety.dailyLimit"
| "error.unauthorized"
| "safety.blocked"
| "error.chatFailed"
/**
* 根据 HTTP 状态码映射错误消息 i18n key
*
* 返回值用于 next-intl 的 t() 调用(相对于 "ai" 命名空间)。
*/
export function getStreamErrorKey(status: number): StreamErrorKey {
if (status === 429) return "safety.dailyLimit"
if (status === 403) return "error.unauthorized"
if (status === 400) return "safety.blocked"
return "error.chatFailed"
}
/**
* 从错误响应体中提取错误消息
*/
export async function extractErrorMessage(
response: Response,
fallback: string
): Promise<string> {
try {
const errorText = await response.text()
const errorData = JSON.parse(errorText) as { message?: string }
return errorData.message ?? fallback
} catch {
return fallback
}
}
/**
* 移除消息列表末尾空的 assistant 消息
*
* 用于流式失败/中止时清理占位消息。
*/
export function removeTrailingEmptyAssistant(
messages: AiChatMessage[]
): AiChatMessage[] {
const filtered = [...messages]
const last = filtered[filtered.length - 1]
if (last && last.role === "assistant" && last.content === "") {
filtered.pop()
}
return filtered
}
/**
* 向消息列表末尾追加 token 到 assistant 消息
*/
export function appendTokenToLastAssistant(
messages: AiChatMessage[],
token: string
): AiChatMessage[] {
const updated = [...messages]
const last = updated[updated.length - 1]
if (last && last.role === "assistant") {
updated[updated.length - 1] = {
...last,
content: last.content + token,
}
}
return updated
}

View File

@@ -3,24 +3,24 @@
import { useState, useCallback, useRef } from "react"
import { useTranslations } from "next-intl"
import type { AiChatMessage } from "../types"
import {
consumeSseStream,
getStreamErrorKey,
extractErrorMessage,
removeTrailingEmptyAssistant,
appendTokenToLastAssistant,
} from "./stream-utils"
/**
* AI 流式聊天 Hook
*
* 通过 SSE 端点消费流式 AI 回复。
* 支持:
* - 逐 token 渲染
* - 停止生成AbortController
* - 错误处理
* 支持:逐 token 渲染、停止生成AbortController、错误处理。
*/
type StreamState = {
type UseAiChatStreamReturn = {
messages: AiChatMessage[]
streaming: boolean
error: string | null
}
type UseAiChatStreamReturn = StreamState & {
send: (messages: AiChatMessage[], options?: { systemPrompt?: string; providerId?: string }) => Promise<void>
stop: () => void
clear: () => void
@@ -46,8 +46,6 @@ export function useAiChatStream(): UseAiChatStreamReturn {
if (userMessage && userMessage.role === "user") {
setMessages((prev) => [...prev, userMessage])
}
// 添加空的 assistant 消息,用于流式更新
setMessages((prev) => [...prev, { role: "assistant", content: "" }])
const controller = new AbortController()
@@ -66,119 +64,42 @@ export function useAiChatStream(): UseAiChatStreamReturn {
})
if (!response.ok) {
const errorText = await response.text()
let errorMessage = t("error.chatFailed")
try {
const errorData = JSON.parse(errorText) as { message?: string }
errorMessage = errorData.message ?? errorMessage
} catch {
// 使用默认错误消息
}
if (response.status === 429) {
errorMessage = t("safety.dailyLimit")
} else if (response.status === 403) {
errorMessage = t("error.unauthorized")
} else if (response.status === 400) {
errorMessage = t("safety.blocked")
}
const fallback = t(getStreamErrorKey(response.status))
const errorMessage = await extractErrorMessage(response, fallback)
setError(errorMessage)
// 移除空的 assistant 消息
setMessages((prev) => {
const filtered = [...prev]
const last = filtered[filtered.length - 1]
if (last && last.role === "assistant" && last.content === "") {
filtered.pop()
}
return filtered
})
setMessages((prev) => removeTrailingEmptyAssistant(prev))
return
}
const reader = response.body?.getReader()
if (!reader) {
setError(t("error.chatFailed"))
return
}
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const data = line.slice(6).trim()
if (data === "[DONE]") continue
try {
const parsed = JSON.parse(data) as {
type: "token" | "error" | "filtered"
content?: string
message?: string
}
if (parsed.type === "token" && parsed.content) {
setMessages((prev) => {
const updated = [...prev]
const last = updated[updated.length - 1]
if (last && last.role === "assistant") {
updated[updated.length - 1] = {
...last,
content: last.content + parsed.content,
}
}
return updated
})
} else if (parsed.type === "error") {
setError(parsed.message ?? t("error.chatFailed"))
setMessages((prev) => {
const filtered = [...prev]
const last = filtered[filtered.length - 1]
if (last && last.role === "assistant" && last.content === "") {
filtered.pop()
}
return filtered
})
} else if (parsed.type === "filtered") {
setError(t("safety.contentFiltered"))
}
} catch {
// 忽略解析错误
}
await consumeSseStream(response, (event) => {
if (event.type === "token") {
setMessages((prev) => appendTokenToLastAssistant(prev, event.content))
} else if (event.type === "error") {
setError(event.message)
setMessages((prev) => removeTrailingEmptyAssistant(prev))
} else if (event.type === "filtered") {
setError(t("safety.contentFiltered"))
} else if (event.type === "socratic_warning") {
// 苏格拉底式辅导警告:不阻断,仅提示
// 可通过 toast 或 UI 标记展示,此处暂存 error 供组件判断
setError(event.message)
}
}
})
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// 用户主动停止,不显示错误
} else {
if (!(err instanceof DOMException && err.name === "AbortError")) {
setError(err instanceof Error ? err.message : String(err))
}
} finally {
setStreaming(false)
abortControllerRef.current = null
// 清理空的 assistant 消息
setMessages((prev) => {
const last = prev[prev.length - 1]
if (last && last.role === "assistant" && last.content === "") {
return prev.slice(0, -1)
}
return prev
})
setMessages((prev) => removeTrailingEmptyAssistant(prev))
}
},
[streaming, t]
)
const stop = useCallback((): void => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
abortControllerRef.current?.abort()
}, [])
const clear = useCallback((): void => {

View File

@@ -187,6 +187,28 @@ export const StudyPathInputSchema = z.object({
)
.optional(),
learningGoal: z.string().optional(),
/** 教材 ID传入后 action 层自动获取知识图谱注入) */
textbookId: z.string().optional(),
/** 知识图谱(可直接传入,优先于 textbookId 自动获取) */
knowledgeGraph: z
.object({
nodes: z.array(
z.object({
id: z.string().min(1),
name: z.string().min(1),
level: z.number().int().min(0),
masteryLevel: z.number().optional(),
})
),
edges: z.array(
z.object({
from: z.string().min(1),
to: z.string().min(1),
type: z.literal("prerequisite"),
})
),
})
.optional(),
})
export const StudyPathResultSchema = z.object({

View File

@@ -330,10 +330,10 @@ export class DefaultAiService implements AiService {
}
async generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult> {
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
return withAiTracking(this.userId, "child_summary", undefined, async () => {
// PII 最小化:不传学生真实姓名,用 ID 替代COPPA/FERPA 合规)
const userLines = [
`Student ID: ${input.studentId}`,
input.studentName ? `Student Name: ${input.studentName}` : "",
input.grade ? `Grade: ${input.grade}` : "",
input.recentGrades && input.recentGrades.length > 0
? `Recent Grades:\n${JSON.stringify(input.recentGrades, null, 2)}`
@@ -370,7 +370,7 @@ export class DefaultAiService implements AiService {
}
async recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult> {
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
return withAiTracking(this.userId, "study_path", undefined, async () => {
const userLines = [
`Student ID: ${input.studentId}`,
input.subject ? `Subject: ${input.subject}` : "",
@@ -379,6 +379,21 @@ export class DefaultAiService implements AiService {
: "",
input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "",
].filter((line) => line.length > 0)
// 知识图谱上下文注入V3对标 Squirrel AI 纳米级知识图谱)
if (input.knowledgeGraph && input.knowledgeGraph.nodes.length > 0) {
const graphLines = [
"Knowledge Graph:",
"Nodes (id | name | level | mastery 0-100):",
...input.knowledgeGraph.nodes.map(
(n) => ` ${n.id} | ${n.name} | L${n.level} | ${n.masteryLevel ?? "unassessed"}`
),
"Prerequisite edges (from -> to, meaning 'from' must be mastered before 'to'):",
...input.knowledgeGraph.edges.map((e) => ` ${e.from} -> ${e.to}`),
]
userLines.push(graphLines.join("\n"))
}
const { content } = await callAi(
buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.5, maxTokens: 2000 }

View File

@@ -6,9 +6,11 @@ import "server-only"
* 多层防护:
* 1. 输入过滤:检查用户输入是否包含不当内容
* 2. 输出过滤:检查 AI 回复是否包含不当内容
* 3. 每日限制:按用户 + 日期计数
* 3. 每日限制:按用户 + 日期计数(原子操作,防 TOCTOU 竞态)
*
* 参考 Khanmigo 的多层 moderation 模式。
*
* 注意:当前为内存实现,多实例部署需替换为 RedisINCR + EXPIRE
*/
// ---------------------------------------------------------------------------
@@ -34,6 +36,10 @@ const BLOCKED_OUTPUT_PATTERNS: readonly RegExp[] = [
const STUDENT_BLOCKED_PATTERNS: readonly RegExp[] = [
// 学生侧额外限制:禁止直接给出作业答案
/\b(here is the (complete )?answer|the answer is:?)\b/i,
// 强化:匹配"答案是 X" / "正确答案是 X" / "final answer: X"
/\b(the (correct )?answer is\s*[:]?\s*[A-Z\d])/i,
/\bfinal answer[:]\s*\S+/i,
/\b答案(是|应该为|为)\s*[:]?\s*[A-F\d]/i,
]
// ---------------------------------------------------------------------------
@@ -61,7 +67,6 @@ export const filterUserInput = (
}
if (options?.isStudent) {
// 学生侧额外检查
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
if (pattern.test(text)) {
return {
@@ -109,7 +114,7 @@ export const filterAiOutput = (
}
// ---------------------------------------------------------------------------
// 每日限制
// 每日限制(原子操作,防 TOCTOU 竞态)
// ---------------------------------------------------------------------------
const DAILY_LIMITS: Record<string, number> = {
@@ -124,10 +129,10 @@ export const getDailyLimit = (role: string): number => {
}
/**
* 检查用户今日 AI 使用次数
* 每日使用计数(内存实现,多实例需替换为 Redis
*
* 生产环境应接入 Redis 或数据库计数器。
* 当前实现为内存映射(单实例场景),多实例需替换为 Redis
* 注意:当前为单实例内存映射,多实例部署下每个实例独立计数,
* 实际可用次数 = 限额 × 实例数。生产环境应接入 Redis INCR + EXPIRE
*/
const dailyUsageMap = new Map<string, { date: string; count: number }>()
@@ -171,3 +176,116 @@ export const incrementDailyUsage = (userId: string): void => {
}
}
}
/**
* 原子化检查并递增每日使用计数
*
* 解决 checkDailyLimit + incrementDailyUsage 分离导致的 TOCTOU 竞态:
* 并发请求在限额临界点同时通过检查,导致超额。
*
* 此函数在一次调用内完成「递增 + 判断是否超限」,
* 若递增后超过限额,回滚计数并返回 blocked。
*
* @returns { blocked, currentCount, limit } — blocked 为 true 表示已超限
*/
export const tryConsumeDailyQuota = (
userId: string,
role: string
): { blocked: boolean; currentCount: number; limit: number } => {
const today = new Date().toISOString().slice(0, 10)
const key = `${userId}:${today}`
const limit = getDailyLimit(role)
const current = dailyUsageMap.get(key)
// 原子递增
const newCount = current && current.date === today ? current.count + 1 : 1
dailyUsageMap.set(key, { date: today, count: newCount })
// 清理过期条目
if (dailyUsageMap.size > 10000) {
for (const [k, v] of dailyUsageMap.entries()) {
if (v.date !== today) {
dailyUsageMap.delete(k)
}
}
}
if (newCount > limit) {
// 超限,回滚计数(不惩罚用户因竞态多出的尝试)
dailyUsageMap.set(key, { date: today, count: limit })
return { blocked: true, currentCount: limit, limit }
}
return { blocked: false, currentCount: newCount, limit }
}
/**
* 回退每日使用计数(当 AI 调用失败或内容被过滤时调用)
*
* 确保用户不会因 AI 输出被过滤或调用失败而损失配额。
*/
export const refundDailyQuota = (userId: string): void => {
const today = new Date().toISOString().slice(0, 10)
const key = `${userId}:${today}`
const current = dailyUsageMap.get(key)
if (current && current.date === today && current.count > 0) {
current.count -= 1
}
}
// ---------------------------------------------------------------------------
// 苏格拉底式辅导输出校验
// ---------------------------------------------------------------------------
export type SocraticValidationResult = {
valid: boolean
reason?: string
}
/**
* 校验 AI 回复是否符合苏格拉底式辅导原则
*
* 规则:
* 1. 回复必须以问号结尾(中英文均可)
* 2. 不得包含超过 2 句连续陈述句而不提问
* 3. 不得直接给出最终答案(复用 STUDENT_BLOCKED_PATTERNS
*
* 用于学生侧 AI 对话,强制引导式教学。
*/
export const validateSocraticOutput = (content: string): SocraticValidationResult => {
const text = String(content ?? "").trim()
if (!text) {
return { valid: false, reason: "Empty response" }
}
// 检查是否直接给出答案
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
if (pattern.test(text)) {
return { valid: false, reason: "Response contains direct answer" }
}
}
// 检查是否以问号结尾
if (!/[?]$/.test(text)) {
return { valid: false, reason: "Response must end with a question" }
}
// 检查连续陈述句数量(按句号/感叹号分割)
const sentences = text.split(/[。!?.!?]/).filter((s) => s.trim().length > 0)
let consecutiveStatements = 0
for (const sentence of sentences) {
// 如果句子本身是疑问句(包含 ? 或 ?),重置计数
if (/[?]/.test(sentence)) {
consecutiveStatements = 0
} else {
consecutiveStatements += 1
if (consecutiveStatements > 2) {
return { valid: false, reason: "Too many consecutive statements without a question" }
}
}
}
return { valid: true }
}

View File

@@ -126,6 +126,7 @@ export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
" {",
' "area": "knowledge area name",',
' "severity": "high | medium | low",',
' "rootCause": "underlying reason, e.g. missing prerequisite",',
' "suggestion": "specific improvement suggestion"',
" }",
" ],",
@@ -135,6 +136,7 @@ export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
"Rules:",
"- Identify 2-5 weak areas based on error frequency and mastery level.",
"- severity: high = mastery < 2, medium = mastery 2-3, low = mastery 3-4.",
"- If prerequisite knowledge is provided and a prerequisite mastery < 2, list the prerequisite as the rootCause.",
"- Suggestions should be actionable and specific.",
"- Study plan should be concise (3-5 sentences).",
"- Recommended resources can be topic names or study strategies.",
@@ -165,6 +167,22 @@ export const CHAT_SYSTEM_PROMPT = [
"Be concise, accurate, and pedagogically sound.",
].join("\n")
// ---------------------------------------------------------------------------
// 苏格拉底式辅导(学生专用,强制引导式教学)
// ---------------------------------------------------------------------------
export const SOCRATIC_TUTOR_SYSTEM_PROMPT = [
"You are a Socratic tutor for K12 students.",
"STRICT RULES (never violate):",
"- NEVER output the final answer directly.",
"- NEVER output more than 2 consecutive sentences without asking a question.",
"- Use a 3-tier hint escalation: Tier 1 (conceptual question) → Tier 2 (concrete hint) → Tier 3 (worked example without the final step).",
"- If the student asks for the answer 3+ times, explain why guided discovery is better for learning.",
"- Always end your response with a question that moves the student forward.",
"- Track the student's reasoning and point out the exact step where they went wrong.",
"- Respond in the student's language (Chinese by default).",
].join("\n")
// ---------------------------------------------------------------------------
// 家长学情摘要
// ---------------------------------------------------------------------------
@@ -196,7 +214,7 @@ export const CHILD_SUMMARY_SYSTEM_PROMPT = [
export const STUDY_PATH_SYSTEM_PROMPT = [
"You are an expert K12 adaptive learning path designer.",
"Based on the student's current mastery levels, recommend a personalized learning path.",
"Based on the student's current mastery levels and knowledge graph, recommend a personalized learning path.",
"Return JSON only without markdown.",
"Output schema:",
"{",
@@ -215,7 +233,8 @@ export const STUDY_PATH_SYSTEM_PROMPT = [
"}",
"Rules:",
"- Order learning path from foundational to advanced.",
"- Prioritize weak areas (mastery < 2) first.",
"- MUST follow prerequisite chains: if a knowledge point has unmastered prerequisites, list the prerequisites first.",
"- Prioritize weak areas (mastery < 2) first, but only after their prerequisites are addressed.",
"- Include 3-7 steps in the learning path.",
"- estimatedTime should be realistic (5-30 min per step).",
"- motivation should be age-appropriate and encouraging.",

View File

@@ -1,10 +1,11 @@
import "server-only"
import { trackEvent, type EventName } from "@/shared/lib/track-event"
import { recordAiEvent } from "../data-access"
export type AiUsageEvent = {
userId: string
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis"
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path"
providerId?: string
model?: string
success: boolean
@@ -20,16 +21,31 @@ const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
lesson_content: "ai.lesson_content",
question_variant: "ai.question_variant",
weakness_analysis: "ai.weakness_analysis",
child_summary: "ai.child_summary",
study_path: "ai.study_path",
}
/**
* AI 使用埋点
*
* 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。
* 同时写入 data-access 层的内存事件存储(供管理员仪表盘聚合查询)。
* 非阻塞,失败不影响主流程。
*/
export const trackAiUsage = (event: AiUsageEvent): void => {
const eventName = AI_EVENT_MAP[event.capability]
// 写入 data-access 层(供 getAiUsageStats 聚合)
recordAiEvent({
userId: event.userId,
capability: event.capability,
success: event.success,
durationMs: event.durationMs,
timestamp: Date.now(),
errorMessage: event.errorMessage,
})
// 写入全局 trackEvent供外部监控系统
void trackEvent({
event: eventName,
userId: event.userId,

View File

@@ -168,6 +168,23 @@ export type StudyPathInput = {
errorCount: number
}>
learningGoal?: string
/** 教材 ID传入后 action 层自动获取知识图谱注入) */
textbookId?: string
/**
* 知识图谱上下文V3 新增,对标 Squirrel AI 纳米级知识图谱)
*
* 由 actions 层从 textbooks/data-access-graph 获取后注入,
* 使 AI 能沿前置依赖链推荐学习路径。
*/
knowledgeGraph?: {
nodes: Array<{
id: string
name: string
level: number
masteryLevel?: number
}>
edges: Array<{ from: string; to: string; type: "prerequisite" }>
}
}
/** 学习路径推荐结果 */

View File

@@ -5,15 +5,68 @@ import OpenAI from "openai"
import { extractMessageContent, type AiChatRequest } from "./payload-parser"
import { getAiProviderConfig } from "./provider-config"
const getAiClient = async (config: { apiKey: string; baseUrl?: string }) => {
/** AI 请求超时(毫秒) */
const AI_TIMEOUT_MS = 30000
/** 可重试的 HTTP 状态码429 限流 + 5xx 服务端错误) */
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504])
/** 最大重试次数 */
const MAX_RETRIES = 2
/** 基础重试延迟(毫秒),实际延迟 = base * 2^attempt */
const RETRY_BASE_DELAY_MS = 1000
const getAiClient = async (config: { apiKey: string; baseUrl?: string }): Promise<OpenAI> => {
const baseUrl = String(config.baseUrl ?? "https://api.openai.com").replace(/\/+$/, "")
return new OpenAI({
apiKey: config.apiKey,
baseURL: baseUrl.length ? baseUrl : undefined,
timeout: AI_TIMEOUT_MS,
maxRetries: 0, // 由业务层控制重试
})
}
export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: string; model: string }) => {
/**
* 判断错误是否可重试429 限流或 5xx 服务端错误)
*/
const isRetryableError = (error: unknown): boolean => {
if (error instanceof OpenAI.APIError) {
return RETRYABLE_STATUS_CODES.has(error.status ?? 0)
}
// 网络超时/连接错误也可重试
if (error instanceof Error) {
const msg = error.message.toLowerCase()
return msg.includes("timeout") || msg.includes("econnreset") || msg.includes("socket hang up")
}
return false
}
/**
* 指数退避重试包装器
*
* 仅对 429限流和 5xx服务端错误重试4xx 不重试。
* 最大重试 2 次,延迟 1s/2s/4s。
*/
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
let lastError: unknown
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
if (attempt < MAX_RETRIES && isRetryableError(error)) {
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
throw error
}
}
throw lastError
}
export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: string; model: string }): Promise<boolean> => {
const client = await getAiClient({ apiKey: input.apiKey, baseUrl: input.baseUrl })
const result = await client.chat.completions.create({
model: input.model,
@@ -29,7 +82,7 @@ export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: st
export const testAiProviderById = async (
providerId: string,
overrides?: { baseUrl?: string; model?: string }
) => {
): Promise<boolean> => {
const config = await getAiProviderConfig(providerId)
const client = await getAiClient({ apiKey: config.apiKey, baseUrl: overrides?.baseUrl ?? config.baseUrl })
const result = await client.chat.completions.create({
@@ -43,18 +96,19 @@ export const testAiProviderById = async (
return true
}
export const createAiChatCompletion = async (input: AiChatRequest) => {
export const createAiChatCompletion = async (input: AiChatRequest): Promise<{ content: string; usage: unknown }> => {
const config = await getAiProviderConfig(input.providerId)
const client = await getAiClient(config)
const result = (await client.chat.completions.create({
model: config.model || input.model,
messages: input.messages,
temperature: input.temperature,
...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}),
...(input.thinking ? { thinking: input.thinking } : {}),
} as Parameters<typeof client.chat.completions.create>[0])) as Awaited<
ReturnType<typeof client.chat.completions.create>
>
const result = await withRetry(() =>
client.chat.completions.create({
model: config.model || input.model,
messages: input.messages,
temperature: input.temperature,
...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}),
...(input.thinking ? { thinking: input.thinking } : {}),
} as Parameters<typeof client.chat.completions.create>[0])
) as Awaited<ReturnType<typeof client.chat.completions.create>>
const hasChoices = "choices" in result && Array.isArray(result.choices) && result.choices.length > 0
if (!hasChoices) throw new Error("Empty response from provider. Check API URL, model, and API key.")
@@ -71,6 +125,9 @@ export const createAiChatCompletion = async (input: AiChatRequest) => {
*
* 返回 AsyncGenerator逐 token 产出内容。
* 用于 SSE 流式响应,降低用户感知延迟。
*
* 注意:流式调用不使用 withRetry因为流一旦开始无法重试。
* 超时由 OpenAI SDK 的 timeout 配置控制。
*/
export async function* createAiChatCompletionStream(
input: AiChatRequest

View File

@@ -27,6 +27,7 @@ export type EventName =
| "notification.marked_all_read"
| "notification.sent"
| "notification.send_failed"
| "notification.archived"
| "attendance.recorded"
| "attendance.batch_recorded"
| "attendance.updated"
@@ -66,6 +67,8 @@ export type EventName =
| "ai.lesson_content"
| "ai.question_variant"
| "ai.weakness_analysis"
| "ai.child_summary"
| "ai.study_path"
/** 埋点事件负载 */
export interface TrackEventPayload {