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:
@@ -2056,33 +2056,41 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| **student** | `AiStudyPath` | `student/*` | 学习路径推荐 — V2 新增 |
|
| **student** | `AiStudyPath` | `student/*` | 学习路径推荐 — V2 新增 |
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- `modules/ai` → `shared/lib/ai`(AI SDK 封装,含流式 `createAiChatCompletionStream`)
|
- `modules/ai` → `shared/lib/ai`(AI SDK 封装,含流式 `createAiChatCompletionStream`,V3 增加 `withRetry` 重试机制)
|
||||||
- `modules/ai` → `shared/lib/auth-guard`(权限校验)
|
- `modules/ai` → `shared/lib/auth-guard`(权限校验)
|
||||||
- `modules/ai` → `shared/lib/track-event`(使用量埋点)
|
- `modules/ai` → `shared/lib/track-event`(使用量埋点)
|
||||||
- `modules/ai` → `shared/types/permissions`(权限常量)
|
- `modules/ai` → `shared/types/permissions`(权限常量)
|
||||||
- `modules/ai` → `shared/types/action-state`(返回值类型)
|
- `modules/ai` → `shared/types/action-state`(返回值类型)
|
||||||
|
- `modules/ai` → `modules/textbooks/data-access-graph`(知识图谱+掌握度查询)— V3 新增
|
||||||
- `app/(dashboard)/layout` → `modules/ai`(全局 Provider + Widget)— V2 新增
|
- `app/(dashboard)/layout` → `modules/ai`(全局 Provider + Widget)— V2 新增
|
||||||
|
- `app/api/ai/chat/stream` → `modules/ai/services/content-safety`(SSE 端点安全过滤)— V3 加固
|
||||||
- 业务模块 → `modules/ai/context/ai-client-provider`(通过 Context 注入)
|
- 业务模块 → `modules/ai/context/ai-client-provider`(通过 Context 注入)
|
||||||
- 业务模块 → `modules/ai/components/*`(组合 AI 组件)
|
- 业务模块 → `modules/ai/components/*`(组合 AI 组件)
|
||||||
|
|
||||||
**安全机制(V2 新增)**:
|
**安全机制(V2 新增 / V3 加固)**:
|
||||||
- 输入过滤:`filterUserInput` 拦截暴力/自残/色情/PII
|
- 输入过滤:`filterUserInput` 拦截暴力/自残/色情/PII
|
||||||
- 输出过滤:`filterAiOutput` 扫描 AI 回复
|
- 输出过滤:`filterAiOutput` 扫描 AI 回复
|
||||||
- 每日限制:学生 50 次/天,教师 200 次/天,家长 30 次/天
|
- 每日限制:学生 50 次/天,教师 200 次/天,家长 30 次/天
|
||||||
- 学生 Socratic 模式:system prompt 强制不直接给答案
|
- 学生 Socratic 模式:system prompt 强制不直接给答案
|
||||||
- SSE 端点权限校验:`requirePermission(AI_CHAT)`
|
- SSE 端点权限校验:`requirePermission(AI_CHAT)`
|
||||||
|
- V3 原子配额:`tryConsumeDailyQuota` 解决 TOCTOU 竞态,`refundDailyQuota` 过滤/失败时回退
|
||||||
|
- V3 苏格拉底校验:`validateSocraticOutput` 检查问号结尾+连续陈述句限制,`SOCRATIC_TUTOR_SYSTEM_PROMPT` 3 级提示升级
|
||||||
|
- V3 SSE 端点加固:Zod 校验 + rate limit + 服务端强制 systemPrompt(忽略客户端)
|
||||||
|
- V3 重试机制:`withRetry` 指数退避(429/5xx,2 次重试,1s/2s/4s 延迟)
|
||||||
|
- V3 知识图谱集成:`recommendStudyPathAction` 自动从 textbooks 模块获取图谱+掌握度注入 prompt
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
|
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `modules/ai/types.ts` | ~270 | 类型定义(8 个业务场景类型 + AiService/AiClientService) |
|
| `modules/ai/types.ts` | ~290 | 类型定义(8 个业务场景类型 + AiService/AiClientService,V3 新增 knowledgeGraph/textbookId) |
|
||||||
| `modules/ai/schema.ts` | ~205 | Zod 验证 schema(8 个输入 + 8 个输出) |
|
| `modules/ai/schema.ts` | ~230 | Zod 验证 schema(8 个输入 + 8 个输出,V3 新增 knowledgeGraph/textbookId) |
|
||||||
| `modules/ai/actions.ts` | ~340 | 9 个 Server Actions(含权限校验) |
|
| `modules/ai/actions.ts` | ~400 | 9 个 Server Actions(含权限校验,V3 新增知识图谱获取) |
|
||||||
| `modules/ai/services/ai-service.ts` | ~400 | DefaultAiService 实现(8 个方法) |
|
| `modules/ai/data-access.ts` | ~138 | AI 事件存储+统计聚合 — V3 新增 |
|
||||||
| `modules/ai/services/prompt-templates.ts` | ~210 | 8 个系统提示词模板 |
|
| `modules/ai/services/ai-service.ts` | ~430 | DefaultAiService 实现(8 个方法,V3 新增知识图谱上下文注入) |
|
||||||
| `modules/ai/services/usage-tracker.ts` | ~83 | AI 使用量埋点 |
|
| `modules/ai/services/prompt-templates.ts` | ~250 | 9 个系统提示词模板(V3 新增 SOCRATIC_TUTOR_SYSTEM_PROMPT) |
|
||||||
| `modules/ai/services/content-safety.ts` | ~130 | 内容安全过滤(输入/输出/每日限制)— V2 新增 |
|
| `modules/ai/services/usage-tracker.ts` | ~90 | AI 使用量埋点(V3 新增 child_summary/study_path 能力) |
|
||||||
|
| `modules/ai/services/content-safety.ts` | ~290 | 内容安全过滤(输入/输出/每日限制/原子配额/苏格拉底校验)— V2 新增 / V3 加固 |
|
||||||
| `modules/ai/context/ai-client-provider.tsx` | ~62 | React Context Provider + Hooks |
|
| `modules/ai/context/ai-client-provider.tsx` | ~62 | React Context Provider + Hooks |
|
||||||
| `modules/ai/components/ai-assistant-widget.tsx` | ~170 | 全局 AI 助手悬浮按钮 — V2 新增 |
|
| `modules/ai/components/ai-assistant-widget.tsx` | ~170 | 全局 AI 助手悬浮按钮 — V2 新增 |
|
||||||
| `modules/ai/components/ai-chat-panel.tsx` | ~305 | AI 对话面板(流式 + Markdown)— V2 增强 |
|
| `modules/ai/components/ai-chat-panel.tsx` | ~305 | AI 对话面板(流式 + Markdown)— V2 增强 |
|
||||||
@@ -2097,7 +2105,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `modules/ai/components/ai-error-boundary.tsx` | ~88 | AI 错误边界 |
|
| `modules/ai/components/ai-error-boundary.tsx` | ~88 | AI 错误边界 |
|
||||||
| `modules/ai/components/ai-skeleton.tsx` | ~47 | AI 骨架屏 |
|
| `modules/ai/components/ai-skeleton.tsx` | ~47 | AI 骨架屏 |
|
||||||
| `modules/ai/components/ai-provider-selector.tsx` | ~129 | 服务商选择器 |
|
| `modules/ai/components/ai-provider-selector.tsx` | ~129 | 服务商选择器 |
|
||||||
| `modules/ai/hooks/use-ai-chat-stream.ts` | ~170 | 流式 AI 对话 Hook — V2 新增 |
|
| `modules/ai/hooks/use-ai-chat-stream.ts` | ~107 | 流式 AI 对话 Hook(V3 拆分至 ≤80 行函数体)— V2 新增 / V3 重构 |
|
||||||
|
| `modules/ai/hooks/stream-utils.ts` | ~120 | SSE 流解析工具(consumeSseStream/getStreamErrorKey 等)— V3 新增 |
|
||||||
| `modules/ai/hooks/use-ai-chat.ts` | ~57 | 非流式 AI 对话 Hook |
|
| `modules/ai/hooks/use-ai-chat.ts` | ~57 | 非流式 AI 对话 Hook |
|
||||||
| `modules/ai/hooks/use-ai-suggestion.ts` | ~72 | AI 建议 Hook |
|
| `modules/ai/hooks/use-ai-suggestion.ts` | ~72 | AI 建议 Hook |
|
||||||
| `app/api/ai/chat/stream/route.ts` | ~160 | SSE 流式端点 — V2 新增 |
|
| `app/api/ai/chat/stream/route.ts` | ~160 | SSE 流式端点 — V2 新增 |
|
||||||
|
|||||||
@@ -16341,7 +16341,8 @@
|
|||||||
},
|
},
|
||||||
"ai": {
|
"ai": {
|
||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
"shared"
|
"shared",
|
||||||
|
"textbooks"
|
||||||
],
|
],
|
||||||
"uses": {
|
"uses": {
|
||||||
"shared": [
|
"shared": [
|
||||||
@@ -16353,6 +16354,10 @@
|
|||||||
"types.action-state",
|
"types.action-state",
|
||||||
"lib.track-event.trackEvent",
|
"lib.track-event.trackEvent",
|
||||||
"i18n.messages"
|
"i18n.messages"
|
||||||
|
],
|
||||||
|
"textbooks": [
|
||||||
|
"data-access-graph.getKnowledgePointsWithRelations",
|
||||||
|
"data-access-graph.getStudentKpMastery"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -16422,11 +16427,27 @@
|
|||||||
"filterAiOutput",
|
"filterAiOutput",
|
||||||
"checkDailyLimit",
|
"checkDailyLimit",
|
||||||
"incrementDailyUsage",
|
"incrementDailyUsage",
|
||||||
"getDailyLimit"
|
"getDailyLimit",
|
||||||
|
"tryConsumeDailyQuota",
|
||||||
|
"refundDailyQuota",
|
||||||
|
"validateSocraticOutput"
|
||||||
|
],
|
||||||
|
"dataAccess": [
|
||||||
|
"recordAiEvent",
|
||||||
|
"getAiUsageStats"
|
||||||
|
],
|
||||||
|
"streamUtils": [
|
||||||
|
"consumeSseStream",
|
||||||
|
"getStreamErrorKey",
|
||||||
|
"extractErrorMessage",
|
||||||
|
"removeTrailingEmptyAssistant",
|
||||||
|
"appendTokenToLastAssistant"
|
||||||
],
|
],
|
||||||
"promptTemplates": [
|
"promptTemplates": [
|
||||||
"CHILD_SUMMARY_SYSTEM_PROMPT",
|
"CHILD_SUMMARY_SYSTEM_PROMPT",
|
||||||
"STUDY_PATH_SYSTEM_PROMPT"
|
"STUDY_PATH_SYSTEM_PROMPT",
|
||||||
|
"SOCRATIC_TUTOR_SYSTEM_PROMPT",
|
||||||
|
"CHAT_SYSTEM_PROMPT"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"integrations": {
|
"integrations": {
|
||||||
@@ -16841,6 +16862,12 @@
|
|||||||
"type": "normal",
|
"type": "normal",
|
||||||
"description": "使用 lib/ai.createAiChatCompletion、auth-guard.requirePermission、types.permissions、types.action-state"
|
"description": "使用 lib/ai.createAiChatCompletion、auth-guard.requirePermission、types.permissions、types.action-state"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "ai",
|
||||||
|
"to": "textbooks",
|
||||||
|
"type": "normal",
|
||||||
|
"description": "V3 新增:使用 data-access-graph.getKnowledgePointsWithRelations/getStudentKpMastery 获取知识图谱与掌握度,注入学习路径推荐"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "homework",
|
"from": "homework",
|
||||||
"to": "ai",
|
"to": "ai",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function DashboardLayout({
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}): React.ReactNode {
|
||||||
return (
|
return (
|
||||||
<AiClientProvider service={aiClientService}>
|
<AiClientProvider service={aiClientService}>
|
||||||
<SidebarProvider sidebar={<AppSidebar />}>
|
<SidebarProvider sidebar={<AppSidebar />}>
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import { Permissions } from "@/shared/types/permissions"
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
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 === "Invalid payload" || message === "Messages are required") return 400
|
||||||
if (message === "AI API key missing") return 500
|
if (message === "AI API key missing") return 500
|
||||||
if (message === "Empty response") return 502
|
if (message === "Empty response") return 502
|
||||||
return 502
|
return 502
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request): Promise<NextResponse> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.AI_CHAT)
|
const ctx = await requirePermission(Permissions.AI_CHAT)
|
||||||
const userId = ctx.userId
|
const userId = ctx.userId
|
||||||
|
|||||||
@@ -6,15 +6,22 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar
|
|||||||
import { createAiChatCompletionStream } from "@/shared/lib/ai/client"
|
import { createAiChatCompletionStream } from "@/shared/lib/ai/client"
|
||||||
import { getAiErrorMessage } from "@/shared/lib/ai"
|
import { getAiErrorMessage } from "@/shared/lib/ai"
|
||||||
import { trackEvent } from "@/shared/lib/track-event"
|
import { trackEvent } from "@/shared/lib/track-event"
|
||||||
|
import {
|
||||||
|
rateLimit,
|
||||||
|
rateLimitKey,
|
||||||
|
RATE_LIMIT_RULES,
|
||||||
|
} from "@/shared/lib/rate-limit"
|
||||||
import { env } from "@/env.mjs"
|
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 {
|
import {
|
||||||
filterUserInput,
|
filterUserInput,
|
||||||
filterAiOutput,
|
filterAiOutput,
|
||||||
checkDailyLimit,
|
tryConsumeDailyQuota,
|
||||||
incrementDailyUsage,
|
refundDailyQuota,
|
||||||
|
validateSocraticOutput,
|
||||||
} from "@/modules/ai/services/content-safety"
|
} from "@/modules/ai/services/content-safety"
|
||||||
|
import { AiChatInputSchema } from "@/modules/ai/schema"
|
||||||
import type { AiChatMessage } from "@/modules/ai/types"
|
import type { AiChatMessage } from "@/modules/ai/types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,11 +30,14 @@ import type { AiChatMessage } from "@/modules/ai/types"
|
|||||||
* 使用 Server-Sent Events 逐 token 推送 AI 回复,
|
* 使用 Server-Sent Events 逐 token 推送 AI 回复,
|
||||||
* 降低用户感知延迟。
|
* 降低用户感知延迟。
|
||||||
*
|
*
|
||||||
* 安全:
|
* 安全策略(与非流式端点一致):
|
||||||
* - requirePermission(AI_CHAT) 权限校验
|
* - requirePermission(AI_CHAT) 权限校验
|
||||||
|
* - rateLimit 每分钟限流(防高频滥用)
|
||||||
|
* - Zod 校验输入(AiChatInputSchema,限制消息数/长度)
|
||||||
|
* - tryConsumeDailyQuota 原子化每日限额(防 TOCTOU 竞态)
|
||||||
* - 输入/输出内容安全过滤
|
* - 输入/输出内容安全过滤
|
||||||
* - 每日交互限制
|
* - 学生侧 Socratic 模式(服务端强制,忽略客户端 systemPrompt)
|
||||||
* - 学生侧 Socratic 模式
|
* - 过滤/失败时 refundDailyQuota(不惩罚用户)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const formatEvent = (data: unknown): string => {
|
const formatEvent = (data: unknown): string => {
|
||||||
@@ -50,34 +60,45 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
const userRole = session?.user?.role ?? "student"
|
const userRole = session?.user?.role ?? "student"
|
||||||
const isStudent = userRole === "student"
|
const isStudent = userRole === "student"
|
||||||
|
|
||||||
// 2. 每日限制
|
// 2. Rate limit(与非流式端点一致)
|
||||||
const limitCheck = checkDailyLimit(ctx.userId, userRole)
|
const limitResult = rateLimit({
|
||||||
if (limitCheck.blocked) {
|
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"), {
|
return new Response(formatError("Daily limit reached"), {
|
||||||
status: 429,
|
status: 429,
|
||||||
headers: { "Content-Type": "text/event-stream" },
|
headers: { "Content-Type": "text/event-stream" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 解析请求
|
// 5. 输入安全过滤
|
||||||
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. 输入安全过滤
|
|
||||||
for (const msg of body.messages) {
|
for (const msg of body.messages) {
|
||||||
if (msg.role === "user") {
|
if (msg.role === "user") {
|
||||||
const filterResult = filterUserInput(msg.content, { isStudent })
|
const filterResult = filterUserInput(msg.content, { isStudent })
|
||||||
if (filterResult.blocked) {
|
if (filterResult.blocked) {
|
||||||
|
// 输入被过滤,回退配额
|
||||||
|
refundDailyQuota(ctx.userId)
|
||||||
return new Response(formatError("Input blocked by safety filter"), {
|
return new Response(formatError("Input blocked by safety filter"), {
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "text/event-stream" },
|
headers: { "Content-Type": "text/event-stream" },
|
||||||
@@ -86,24 +107,35 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 构建 system prompt(学生侧 Socratic 模式)
|
// 6. 构建 system prompt
|
||||||
const baseSystemPrompt = body.systemPrompt ?? CHAT_SYSTEM_PROMPT
|
// 安全:服务端强制使用系统提示词,忽略客户端传入的 systemPrompt,
|
||||||
const studentSystemPrompt = isStudent
|
// 防止学生绕过 Socratic 模式获取直接答案。
|
||||||
? `${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.`
|
const baseSystemPrompt = isStudent
|
||||||
: baseSystemPrompt
|
? SOCRATIC_TUTOR_SYSTEM_PROMPT
|
||||||
|
: CHAT_SYSTEM_PROMPT
|
||||||
|
|
||||||
const messages: AiChatMessage[] = [
|
const messages: AiChatMessage[] = [
|
||||||
{ role: "system", content: studentSystemPrompt },
|
{ role: "system", content: baseSystemPrompt },
|
||||||
...body.messages,
|
...body.messages,
|
||||||
]
|
]
|
||||||
|
|
||||||
// 6. 流式调用 AI
|
// 7. 流式调用 AI
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
let fullContent = ""
|
let fullContent = ""
|
||||||
let success = true
|
let success = true
|
||||||
let errorMessage: string | undefined
|
let errorMessage: string | undefined
|
||||||
|
let wasFiltered = false
|
||||||
|
|
||||||
|
const safeEnqueue = (text: string): void => {
|
||||||
|
// 防止 controller 已关闭时 enqueue 抛错
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(text))
|
||||||
|
} catch {
|
||||||
|
// controller 已关闭,忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const aiStream = createAiChatCompletionStream({
|
const aiStream = createAiChatCompletionStream({
|
||||||
@@ -119,27 +151,49 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
// 输出安全过滤(逐 chunk 检查关键词)
|
// 输出安全过滤(逐 chunk 检查关键词)
|
||||||
const outputFilter = filterAiOutput(chunk, { isStudent })
|
const outputFilter = filterAiOutput(chunk, { isStudent })
|
||||||
if (outputFilter.blocked) {
|
if (outputFilter.blocked) {
|
||||||
controller.enqueue(encoder.encode(formatEvent({
|
safeEnqueue(formatEvent({
|
||||||
type: "filtered",
|
type: "filtered",
|
||||||
message: "Content filtered for safety",
|
message: "Content filtered for safety",
|
||||||
})))
|
}))
|
||||||
success = false
|
success = false
|
||||||
|
wasFiltered = true
|
||||||
break
|
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) {
|
} catch (error) {
|
||||||
success = false
|
success = false
|
||||||
errorMessage = error instanceof Error ? error.message : String(error)
|
errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
controller.enqueue(encoder.encode(formatError(getAiErrorMessage(error))))
|
// AI 调用失败,回退配额
|
||||||
|
refundDailyQuota(ctx.userId)
|
||||||
|
safeEnqueue(formatError(getAiErrorMessage(error)))
|
||||||
} finally {
|
} finally {
|
||||||
controller.close()
|
try {
|
||||||
|
controller.close()
|
||||||
|
} catch {
|
||||||
|
// 已关闭,忽略
|
||||||
|
}
|
||||||
|
|
||||||
// 埋点
|
// 埋点
|
||||||
void trackEvent({
|
void trackEvent({
|
||||||
@@ -152,6 +206,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
tokenCount: fullContent.length / 4,
|
tokenCount: fullContent.length / 4,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
isStudent,
|
isStudent,
|
||||||
|
filtered: wasFiltered,
|
||||||
},
|
},
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// 静默失败
|
// 静默失败
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { getTranslations } from "next-intl/server"
|
|||||||
|
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
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 { createAiService, safeAiCall } from "./services/ai-service"
|
||||||
|
import { getAiUsageStats } from "./data-access"
|
||||||
|
import { getKnowledgePointsWithRelations, getStudentKpMastery } from "@/modules/textbooks/data-access-graph"
|
||||||
import {
|
import {
|
||||||
AiChatInputSchema,
|
AiChatInputSchema,
|
||||||
GradingInputSchema,
|
GradingInputSchema,
|
||||||
@@ -42,10 +44,10 @@ import type {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const requireAiPermission = async (
|
const requireAiPermission = async (
|
||||||
...permissions: readonly string[]
|
...permissions: readonly Permission[]
|
||||||
): Promise<{ userId: string }> => {
|
): Promise<{ userId: string }> => {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
permissions.map((p) => requirePermission(p as never))
|
permissions.map((p) => requirePermission(p))
|
||||||
)
|
)
|
||||||
return { userId: results[0].userId }
|
return { userId: results[0].userId }
|
||||||
}
|
}
|
||||||
@@ -294,8 +296,56 @@ export async function recommendStudyPathAction(
|
|||||||
return { success: false, message: t("error.invalidInput") }
|
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 service = createAiService(ctx.userId)
|
||||||
const result = await safeAiCall(() => service.recommendStudyPath(parsed.data))
|
const result = await safeAiCall(() => service.recommendStudyPath(serviceInput))
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return { success: false, message: result.message }
|
return { success: false, message: result.message }
|
||||||
}
|
}
|
||||||
@@ -317,20 +367,9 @@ export async function getAiUsageStatsAction(): Promise<ActionState<AiUsageStats>
|
|||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.AI_CONFIGURE)
|
await requirePermission(Permissions.AI_CONFIGURE)
|
||||||
|
|
||||||
// 当前从 trackEvent 的内存数据返回统计
|
// 从 data-access 层获取真实聚合统计
|
||||||
// 生产环境应查询数据库或 Redis 聚合
|
// 当前为内存事件存储(单实例),生产环境应查询 DB 或 Redis
|
||||||
const stats: AiUsageStats = {
|
const stats = await getAiUsageStats()
|
||||||
totalCalls: 0,
|
|
||||||
callsToday: 0,
|
|
||||||
callsThisWeek: 0,
|
|
||||||
activeUsers: 0,
|
|
||||||
errorRate: 0,
|
|
||||||
avgDurationMs: 0,
|
|
||||||
byCapability: [],
|
|
||||||
byRole: [],
|
|
||||||
topUsers: [],
|
|
||||||
recentActivity: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: stats }
|
return { success: true, data: stats }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function AiAssistantWidget(): React.ReactNode {
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</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">
|
<SheetHeader className="px-4 py-3 border-b">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<SheetTitle className="flex items-center gap-2">
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"
|
|||||||
import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react"
|
import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
@@ -31,8 +32,6 @@ type AiChatPanelProps = {
|
|||||||
title?: string
|
title?: string
|
||||||
/** 最大消息数 */
|
/** 最大消息数 */
|
||||||
maxMessages?: number
|
maxMessages?: number
|
||||||
/** 是否启用流式响应(默认 true) */
|
|
||||||
streaming?: boolean
|
|
||||||
/** 建议提示词列表(空状态展示) */
|
/** 建议提示词列表(空状态展示) */
|
||||||
suggestedPrompts?: string[]
|
suggestedPrompts?: string[]
|
||||||
}
|
}
|
||||||
@@ -49,7 +48,7 @@ type AiChatPanelProps = {
|
|||||||
* - 清除对话按钮
|
* - 清除对话按钮
|
||||||
* - 建议提示词
|
* - 建议提示词
|
||||||
* - aria-live 无障碍
|
* - aria-live 无障碍
|
||||||
* - 对话历史持久化(localStorage)
|
* - 对话历史持久化(localStorage,防抖写入)
|
||||||
*/
|
*/
|
||||||
export function AiChatPanel({
|
export function AiChatPanel({
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
@@ -57,7 +56,6 @@ export function AiChatPanel({
|
|||||||
placeholder,
|
placeholder,
|
||||||
title,
|
title,
|
||||||
maxMessages = 50,
|
maxMessages = 50,
|
||||||
streaming: _streamingEnabled = true,
|
|
||||||
suggestedPrompts,
|
suggestedPrompts,
|
||||||
}: AiChatPanelProps): React.ReactNode {
|
}: AiChatPanelProps): React.ReactNode {
|
||||||
const t = useTranslations("ai")
|
const t = useTranslations("ai")
|
||||||
@@ -65,33 +63,31 @@ export function AiChatPanel({
|
|||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const storageKey = "ai-chat-history"
|
const storageKey = "ai-chat-history"
|
||||||
|
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
// 从 localStorage 恢复对话历史
|
// 持久化对话历史(防抖 500ms,避免流式过程中频繁写入)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
if (messages.length === 0) return
|
||||||
const stored = localStorage.getItem(storageKey)
|
// 流式过程中不写入,流结束后再写
|
||||||
if (stored) {
|
if (streaming) return
|
||||||
const parsed = JSON.parse(stored) as AiChatMessage[]
|
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
if (persistTimerRef.current) {
|
||||||
// 通过 send 不合适,直接设置 messages 不支持
|
clearTimeout(persistTimerRef.current)
|
||||||
// 这里只是恢复显示,不重新发送
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 忽略解析错误
|
|
||||||
}
|
}
|
||||||
}, [])
|
persistTimerRef.current = setTimeout(() => {
|
||||||
|
try {
|
||||||
// 持久化对话历史
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
if (messages.length > 0) {
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-20)))
|
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-20)))
|
||||||
|
} catch {
|
||||||
|
// 忽略写入错误
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (persistTimerRef.current) {
|
||||||
|
clearTimeout(persistTimerRef.current)
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// 忽略写入错误
|
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages, streaming])
|
||||||
|
|
||||||
// 自动滚动到底部
|
// 自动滚动到底部
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -194,7 +190,7 @@ export function AiChatPanel({
|
|||||||
|
|
||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
<ScrollArea
|
<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-live="polite"
|
||||||
aria-relevant="additions text"
|
aria-relevant="additions text"
|
||||||
>
|
>
|
||||||
@@ -202,7 +198,10 @@ export function AiChatPanel({
|
|||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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" ? (
|
{message.role === "assistant" ? (
|
||||||
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
|
<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" />
|
<User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
|
||||||
)}
|
)}
|
||||||
<div
|
<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"
|
message.role === "user"
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted"
|
: "bg-muted"
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
{message.role === "assistant" ? (
|
{message.role === "assistant" ? (
|
||||||
<AiMarkdownRenderer content={message.content} />
|
<AiMarkdownRenderer content={message.content} />
|
||||||
@@ -266,7 +267,7 @@ export function AiChatPanel({
|
|||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder ?? t("chat.placeholder")}
|
placeholder={placeholder ?? t("chat.placeholder")}
|
||||||
className="min-h-[60px] resize-none"
|
className="min-h-16 resize-none"
|
||||||
disabled={streaming || messages.length >= maxMessages}
|
disabled={streaming || messages.length >= maxMessages}
|
||||||
aria-label={t("chat.inputLabel")}
|
aria-label={t("chat.inputLabel")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function AiLessonContentGenerator({
|
|||||||
value={additionalContext}
|
value={additionalContext}
|
||||||
onChange={(e) => setAdditionalContext(e.target.value)}
|
onChange={(e) => setAdditionalContext(e.target.value)}
|
||||||
placeholder={t("lessonPrep.additionalContextPlaceholder")}
|
placeholder={t("lessonPrep.additionalContextPlaceholder")}
|
||||||
className="min-h-[60px] text-sm"
|
className="min-h-16 text-sm"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import type { QuestionVariantResult } from "@/modules/ai/types"
|
|||||||
|
|
||||||
type VariantType = "same_knowledge_point" | "different_difficulty" | "different_format"
|
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 = {
|
type AiQuestionVariantGeneratorProps = {
|
||||||
/** 原始题目 */
|
/** 原始题目 */
|
||||||
originalQuestion: {
|
originalQuestion: {
|
||||||
@@ -107,7 +113,11 @@ export function AiQuestionVariantGenerator({
|
|||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={variantType}
|
value={variantType}
|
||||||
onValueChange={(value) => setVariantType(value as VariantType)}
|
onValueChange={(value) => {
|
||||||
|
if (isVariantType(value)) {
|
||||||
|
setVariantType(value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="variant-type" className="w-full">
|
<SelectTrigger id="variant-type" className="w-full">
|
||||||
<SelectValue placeholder={t("exam.generate")} />
|
<SelectValue placeholder={t("exam.generate")} />
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type AiStudyPathProps = {
|
|||||||
subject?: string
|
subject?: string
|
||||||
currentMastery?: StudyPathInput["currentMastery"]
|
currentMastery?: StudyPathInput["currentMastery"]
|
||||||
learningGoal?: string
|
learningGoal?: string
|
||||||
|
/** 教材 ID(传入后自动获取知识图谱注入路径推荐,对标 Squirrel AI) */
|
||||||
|
textbookId?: string
|
||||||
onStartLearning?: (step: StudyPathResult["learningPath"][number]) => void
|
onStartLearning?: (step: StudyPathResult["learningPath"][number]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,12 +32,16 @@ type AiStudyPathProps = {
|
|||||||
* - 分步骤学习路径(含状态、建议、预计时间)
|
* - 分步骤学习路径(含状态、建议、预计时间)
|
||||||
* - 学习总结
|
* - 学习总结
|
||||||
* - 鼓励语
|
* - 鼓励语
|
||||||
|
*
|
||||||
|
* V3:支持 textbookId 参数,自动获取知识图谱与掌握度数据,
|
||||||
|
* 使 AI 沿前置依赖链推荐学习路径。
|
||||||
*/
|
*/
|
||||||
export function AiStudyPath({
|
export function AiStudyPath({
|
||||||
studentId,
|
studentId,
|
||||||
subject,
|
subject,
|
||||||
currentMastery,
|
currentMastery,
|
||||||
learningGoal,
|
learningGoal,
|
||||||
|
textbookId,
|
||||||
onStartLearning,
|
onStartLearning,
|
||||||
}: AiStudyPathProps): React.ReactNode {
|
}: AiStudyPathProps): React.ReactNode {
|
||||||
const t = useTranslations("ai")
|
const t = useTranslations("ai")
|
||||||
@@ -55,6 +61,7 @@ export function AiStudyPath({
|
|||||||
subject,
|
subject,
|
||||||
currentMastery,
|
currentMastery,
|
||||||
learningGoal,
|
learningGoal,
|
||||||
|
textbookId,
|
||||||
})
|
})
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setPath(result.data)
|
setPath(result.data)
|
||||||
|
|||||||
138
src/modules/ai/data-access.ts
Normal file
138
src/modules/ai/data-access.ts
Normal 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),
|
||||||
|
* 生产环境接入真实 sink(PostHog/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,
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/modules/ai/hooks/stream-utils.ts
Normal file
135
src/modules/ai/hooks/stream-utils.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -3,24 +3,24 @@
|
|||||||
import { useState, useCallback, useRef } from "react"
|
import { useState, useCallback, useRef } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import type { AiChatMessage } from "../types"
|
import type { AiChatMessage } from "../types"
|
||||||
|
import {
|
||||||
|
consumeSseStream,
|
||||||
|
getStreamErrorKey,
|
||||||
|
extractErrorMessage,
|
||||||
|
removeTrailingEmptyAssistant,
|
||||||
|
appendTokenToLastAssistant,
|
||||||
|
} from "./stream-utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 流式聊天 Hook
|
* AI 流式聊天 Hook
|
||||||
*
|
*
|
||||||
* 通过 SSE 端点消费流式 AI 回复。
|
* 通过 SSE 端点消费流式 AI 回复。
|
||||||
* 支持:
|
* 支持:逐 token 渲染、停止生成(AbortController)、错误处理。
|
||||||
* - 逐 token 渲染
|
|
||||||
* - 停止生成(AbortController)
|
|
||||||
* - 错误处理
|
|
||||||
*/
|
*/
|
||||||
|
type UseAiChatStreamReturn = {
|
||||||
type StreamState = {
|
|
||||||
messages: AiChatMessage[]
|
messages: AiChatMessage[]
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
|
||||||
|
|
||||||
type UseAiChatStreamReturn = StreamState & {
|
|
||||||
send: (messages: AiChatMessage[], options?: { systemPrompt?: string; providerId?: string }) => Promise<void>
|
send: (messages: AiChatMessage[], options?: { systemPrompt?: string; providerId?: string }) => Promise<void>
|
||||||
stop: () => void
|
stop: () => void
|
||||||
clear: () => void
|
clear: () => void
|
||||||
@@ -46,8 +46,6 @@ export function useAiChatStream(): UseAiChatStreamReturn {
|
|||||||
if (userMessage && userMessage.role === "user") {
|
if (userMessage && userMessage.role === "user") {
|
||||||
setMessages((prev) => [...prev, userMessage])
|
setMessages((prev) => [...prev, userMessage])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加空的 assistant 消息,用于流式更新
|
|
||||||
setMessages((prev) => [...prev, { role: "assistant", content: "" }])
|
setMessages((prev) => [...prev, { role: "assistant", content: "" }])
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -66,119 +64,42 @@ export function useAiChatStream(): UseAiChatStreamReturn {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text()
|
const fallback = t(getStreamErrorKey(response.status))
|
||||||
let errorMessage = t("error.chatFailed")
|
const errorMessage = await extractErrorMessage(response, fallback)
|
||||||
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")
|
|
||||||
}
|
|
||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
// 移除空的 assistant 消息
|
setMessages((prev) => removeTrailingEmptyAssistant(prev))
|
||||||
setMessages((prev) => {
|
|
||||||
const filtered = [...prev]
|
|
||||||
const last = filtered[filtered.length - 1]
|
|
||||||
if (last && last.role === "assistant" && last.content === "") {
|
|
||||||
filtered.pop()
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body?.getReader()
|
await consumeSseStream(response, (event) => {
|
||||||
if (!reader) {
|
if (event.type === "token") {
|
||||||
setError(t("error.chatFailed"))
|
setMessages((prev) => appendTokenToLastAssistant(prev, event.content))
|
||||||
return
|
} else if (event.type === "error") {
|
||||||
}
|
setError(event.message)
|
||||||
|
setMessages((prev) => removeTrailingEmptyAssistant(prev))
|
||||||
const decoder = new TextDecoder()
|
} else if (event.type === "filtered") {
|
||||||
let buffer = ""
|
setError(t("safety.contentFiltered"))
|
||||||
|
} else if (event.type === "socratic_warning") {
|
||||||
while (true) {
|
// 苏格拉底式辅导警告:不阻断,仅提示
|
||||||
const { done, value } = await reader.read()
|
// 可通过 toast 或 UI 标记展示,此处暂存 error 供组件判断
|
||||||
if (done) break
|
setError(event.message)
|
||||||
|
|
||||||
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 {
|
|
||||||
// 忽略解析错误
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === "AbortError") {
|
if (!(err instanceof DOMException && err.name === "AbortError")) {
|
||||||
// 用户主动停止,不显示错误
|
|
||||||
} else {
|
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setStreaming(false)
|
setStreaming(false)
|
||||||
abortControllerRef.current = null
|
abortControllerRef.current = null
|
||||||
// 清理空的 assistant 消息
|
setMessages((prev) => removeTrailingEmptyAssistant(prev))
|
||||||
setMessages((prev) => {
|
|
||||||
const last = prev[prev.length - 1]
|
|
||||||
if (last && last.role === "assistant" && last.content === "") {
|
|
||||||
return prev.slice(0, -1)
|
|
||||||
}
|
|
||||||
return prev
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[streaming, t]
|
[streaming, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
const stop = useCallback((): void => {
|
const stop = useCallback((): void => {
|
||||||
if (abortControllerRef.current) {
|
abortControllerRef.current?.abort()
|
||||||
abortControllerRef.current.abort()
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const clear = useCallback((): void => {
|
const clear = useCallback((): void => {
|
||||||
|
|||||||
@@ -187,6 +187,28 @@ export const StudyPathInputSchema = z.object({
|
|||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
learningGoal: z.string().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({
|
export const StudyPathResultSchema = z.object({
|
||||||
|
|||||||
@@ -330,10 +330,10 @@ export class DefaultAiService implements AiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult> {
|
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 = [
|
const userLines = [
|
||||||
`Student ID: ${input.studentId}`,
|
`Student ID: ${input.studentId}`,
|
||||||
input.studentName ? `Student Name: ${input.studentName}` : "",
|
|
||||||
input.grade ? `Grade: ${input.grade}` : "",
|
input.grade ? `Grade: ${input.grade}` : "",
|
||||||
input.recentGrades && input.recentGrades.length > 0
|
input.recentGrades && input.recentGrades.length > 0
|
||||||
? `Recent Grades:\n${JSON.stringify(input.recentGrades, null, 2)}`
|
? `Recent Grades:\n${JSON.stringify(input.recentGrades, null, 2)}`
|
||||||
@@ -370,7 +370,7 @@ export class DefaultAiService implements AiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult> {
|
async recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult> {
|
||||||
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
|
return withAiTracking(this.userId, "study_path", undefined, async () => {
|
||||||
const userLines = [
|
const userLines = [
|
||||||
`Student ID: ${input.studentId}`,
|
`Student ID: ${input.studentId}`,
|
||||||
input.subject ? `Subject: ${input.subject}` : "",
|
input.subject ? `Subject: ${input.subject}` : "",
|
||||||
@@ -379,6 +379,21 @@ export class DefaultAiService implements AiService {
|
|||||||
: "",
|
: "",
|
||||||
input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "",
|
input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "",
|
||||||
].filter((line) => line.length > 0)
|
].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(
|
const { content } = await callAi(
|
||||||
buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")),
|
buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||||
{ temperature: 0.5, maxTokens: 2000 }
|
{ temperature: 0.5, maxTokens: 2000 }
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import "server-only"
|
|||||||
* 多层防护:
|
* 多层防护:
|
||||||
* 1. 输入过滤:检查用户输入是否包含不当内容
|
* 1. 输入过滤:检查用户输入是否包含不当内容
|
||||||
* 2. 输出过滤:检查 AI 回复是否包含不当内容
|
* 2. 输出过滤:检查 AI 回复是否包含不当内容
|
||||||
* 3. 每日限制:按用户 + 日期计数
|
* 3. 每日限制:按用户 + 日期计数(原子操作,防 TOCTOU 竞态)
|
||||||
*
|
*
|
||||||
* 参考 Khanmigo 的多层 moderation 模式。
|
* 参考 Khanmigo 的多层 moderation 模式。
|
||||||
|
*
|
||||||
|
* 注意:当前为内存实现,多实例部署需替换为 Redis(INCR + EXPIRE)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -34,6 +36,10 @@ const BLOCKED_OUTPUT_PATTERNS: readonly RegExp[] = [
|
|||||||
const STUDENT_BLOCKED_PATTERNS: readonly RegExp[] = [
|
const STUDENT_BLOCKED_PATTERNS: readonly RegExp[] = [
|
||||||
// 学生侧额外限制:禁止直接给出作业答案
|
// 学生侧额外限制:禁止直接给出作业答案
|
||||||
/\b(here is the (complete )?answer|the answer is:?)\b/i,
|
/\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) {
|
if (options?.isStudent) {
|
||||||
// 学生侧额外检查
|
|
||||||
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
|
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
|
||||||
if (pattern.test(text)) {
|
if (pattern.test(text)) {
|
||||||
return {
|
return {
|
||||||
@@ -109,7 +114,7 @@ export const filterAiOutput = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 每日限制
|
// 每日限制(原子操作,防 TOCTOU 竞态)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const DAILY_LIMITS: Record<string, number> = {
|
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 }>()
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
|
|||||||
" {",
|
" {",
|
||||||
' "area": "knowledge area name",',
|
' "area": "knowledge area name",',
|
||||||
' "severity": "high | medium | low",',
|
' "severity": "high | medium | low",',
|
||||||
|
' "rootCause": "underlying reason, e.g. missing prerequisite",',
|
||||||
' "suggestion": "specific improvement suggestion"',
|
' "suggestion": "specific improvement suggestion"',
|
||||||
" }",
|
" }",
|
||||||
" ],",
|
" ],",
|
||||||
@@ -135,6 +136,7 @@ export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
|
|||||||
"Rules:",
|
"Rules:",
|
||||||
"- Identify 2-5 weak areas based on error frequency and mastery level.",
|
"- Identify 2-5 weak areas based on error frequency and mastery level.",
|
||||||
"- severity: high = mastery < 2, medium = mastery 2-3, low = mastery 3-4.",
|
"- 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.",
|
"- Suggestions should be actionable and specific.",
|
||||||
"- Study plan should be concise (3-5 sentences).",
|
"- Study plan should be concise (3-5 sentences).",
|
||||||
"- Recommended resources can be topic names or study strategies.",
|
"- Recommended resources can be topic names or study strategies.",
|
||||||
@@ -165,6 +167,22 @@ export const CHAT_SYSTEM_PROMPT = [
|
|||||||
"Be concise, accurate, and pedagogically sound.",
|
"Be concise, accurate, and pedagogically sound.",
|
||||||
].join("\n")
|
].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 = [
|
export const STUDY_PATH_SYSTEM_PROMPT = [
|
||||||
"You are an expert K12 adaptive learning path designer.",
|
"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.",
|
"Return JSON only without markdown.",
|
||||||
"Output schema:",
|
"Output schema:",
|
||||||
"{",
|
"{",
|
||||||
@@ -215,7 +233,8 @@ export const STUDY_PATH_SYSTEM_PROMPT = [
|
|||||||
"}",
|
"}",
|
||||||
"Rules:",
|
"Rules:",
|
||||||
"- Order learning path from foundational to advanced.",
|
"- 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.",
|
"- Include 3-7 steps in the learning path.",
|
||||||
"- estimatedTime should be realistic (5-30 min per step).",
|
"- estimatedTime should be realistic (5-30 min per step).",
|
||||||
"- motivation should be age-appropriate and encouraging.",
|
"- motivation should be age-appropriate and encouraging.",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { trackEvent, type EventName } from "@/shared/lib/track-event"
|
import { trackEvent, type EventName } from "@/shared/lib/track-event"
|
||||||
|
import { recordAiEvent } from "../data-access"
|
||||||
|
|
||||||
export type AiUsageEvent = {
|
export type AiUsageEvent = {
|
||||||
userId: string
|
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
|
providerId?: string
|
||||||
model?: string
|
model?: string
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -20,16 +21,31 @@ const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
|
|||||||
lesson_content: "ai.lesson_content",
|
lesson_content: "ai.lesson_content",
|
||||||
question_variant: "ai.question_variant",
|
question_variant: "ai.question_variant",
|
||||||
weakness_analysis: "ai.weakness_analysis",
|
weakness_analysis: "ai.weakness_analysis",
|
||||||
|
child_summary: "ai.child_summary",
|
||||||
|
study_path: "ai.study_path",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 使用埋点
|
* AI 使用埋点
|
||||||
*
|
*
|
||||||
* 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。
|
* 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。
|
||||||
|
* 同时写入 data-access 层的内存事件存储(供管理员仪表盘聚合查询)。
|
||||||
* 非阻塞,失败不影响主流程。
|
* 非阻塞,失败不影响主流程。
|
||||||
*/
|
*/
|
||||||
export const trackAiUsage = (event: AiUsageEvent): void => {
|
export const trackAiUsage = (event: AiUsageEvent): void => {
|
||||||
const eventName = AI_EVENT_MAP[event.capability]
|
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({
|
void trackEvent({
|
||||||
event: eventName,
|
event: eventName,
|
||||||
userId: event.userId,
|
userId: event.userId,
|
||||||
|
|||||||
@@ -168,6 +168,23 @@ export type StudyPathInput = {
|
|||||||
errorCount: number
|
errorCount: number
|
||||||
}>
|
}>
|
||||||
learningGoal?: string
|
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" }>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 学习路径推荐结果 */
|
/** 学习路径推荐结果 */
|
||||||
|
|||||||
@@ -5,15 +5,68 @@ import OpenAI from "openai"
|
|||||||
import { extractMessageContent, type AiChatRequest } from "./payload-parser"
|
import { extractMessageContent, type AiChatRequest } from "./payload-parser"
|
||||||
import { getAiProviderConfig } from "./provider-config"
|
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(/\/+$/, "")
|
const baseUrl = String(config.baseUrl ?? "https://api.openai.com").replace(/\/+$/, "")
|
||||||
return new OpenAI({
|
return new OpenAI({
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
baseURL: baseUrl.length ? baseUrl : undefined,
|
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 client = await getAiClient({ apiKey: input.apiKey, baseUrl: input.baseUrl })
|
||||||
const result = await client.chat.completions.create({
|
const result = await client.chat.completions.create({
|
||||||
model: input.model,
|
model: input.model,
|
||||||
@@ -29,7 +82,7 @@ export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: st
|
|||||||
export const testAiProviderById = async (
|
export const testAiProviderById = async (
|
||||||
providerId: string,
|
providerId: string,
|
||||||
overrides?: { baseUrl?: string; model?: string }
|
overrides?: { baseUrl?: string; model?: string }
|
||||||
) => {
|
): Promise<boolean> => {
|
||||||
const config = await getAiProviderConfig(providerId)
|
const config = await getAiProviderConfig(providerId)
|
||||||
const client = await getAiClient({ apiKey: config.apiKey, baseUrl: overrides?.baseUrl ?? config.baseUrl })
|
const client = await getAiClient({ apiKey: config.apiKey, baseUrl: overrides?.baseUrl ?? config.baseUrl })
|
||||||
const result = await client.chat.completions.create({
|
const result = await client.chat.completions.create({
|
||||||
@@ -43,18 +96,19 @@ export const testAiProviderById = async (
|
|||||||
return true
|
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 config = await getAiProviderConfig(input.providerId)
|
||||||
const client = await getAiClient(config)
|
const client = await getAiClient(config)
|
||||||
const result = (await client.chat.completions.create({
|
|
||||||
model: config.model || input.model,
|
const result = await withRetry(() =>
|
||||||
messages: input.messages,
|
client.chat.completions.create({
|
||||||
temperature: input.temperature,
|
model: config.model || input.model,
|
||||||
...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}),
|
messages: input.messages,
|
||||||
...(input.thinking ? { thinking: input.thinking } : {}),
|
temperature: input.temperature,
|
||||||
} as Parameters<typeof client.chat.completions.create>[0])) as Awaited<
|
...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}),
|
||||||
ReturnType<typeof client.chat.completions.create>
|
...(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
|
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.")
|
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 产出内容。
|
* 返回 AsyncGenerator,逐 token 产出内容。
|
||||||
* 用于 SSE 流式响应,降低用户感知延迟。
|
* 用于 SSE 流式响应,降低用户感知延迟。
|
||||||
|
*
|
||||||
|
* 注意:流式调用不使用 withRetry,因为流一旦开始无法重试。
|
||||||
|
* 超时由 OpenAI SDK 的 timeout 配置控制。
|
||||||
*/
|
*/
|
||||||
export async function* createAiChatCompletionStream(
|
export async function* createAiChatCompletionStream(
|
||||||
input: AiChatRequest
|
input: AiChatRequest
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type EventName =
|
|||||||
| "notification.marked_all_read"
|
| "notification.marked_all_read"
|
||||||
| "notification.sent"
|
| "notification.sent"
|
||||||
| "notification.send_failed"
|
| "notification.send_failed"
|
||||||
|
| "notification.archived"
|
||||||
| "attendance.recorded"
|
| "attendance.recorded"
|
||||||
| "attendance.batch_recorded"
|
| "attendance.batch_recorded"
|
||||||
| "attendance.updated"
|
| "attendance.updated"
|
||||||
@@ -66,6 +67,8 @@ export type EventName =
|
|||||||
| "ai.lesson_content"
|
| "ai.lesson_content"
|
||||||
| "ai.question_variant"
|
| "ai.question_variant"
|
||||||
| "ai.weakness_analysis"
|
| "ai.weakness_analysis"
|
||||||
|
| "ai.child_summary"
|
||||||
|
| "ai.study_path"
|
||||||
|
|
||||||
/** 埋点事件负载 */
|
/** 埋点事件负载 */
|
||||||
export interface TrackEventPayload {
|
export interface TrackEventPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user