feat(ai): V2 深度增强 — SSE 流式/全局助手/内容安全/多角色覆盖
对标 Khanmigo/Duolingo Max/Squirrel AI/Century Tech 实现: - SSE 流式响应:createAiChatCompletionStream AsyncGenerator + /api/ai/chat/stream SSE 端点 + useAiChatStream hook(AbortController 停止生成 + localStorage 持久化) - Markdown 渲染:AiMarkdownRenderer(react-markdown + remark-gfm + 代码块/表格/列表 + hover 复制按钮) - 全局 AI 助手:AiAssistantWidget 浮动按钮 + Sheet 侧抽屉 + usePathname 路由推断上下文(7 类场景系统提示)+ dashboard layout 全局注入 AiClientProvider - 内容安全:content-safety.ts 多层过滤(输入/输出安全过滤 + 每日限制 student 50/teacher 200/parent 30/admin 500 + 学生苏格拉底模式),COPPA/FERPA K12 合规 - 多角色 AI 覆盖:家长端 AiChildSummary(学情摘要)+ 管理员端 AiUsageDashboard(使用监控)+ 学生端 AiStudyPath(个性化学习路径) - i18n 修复:8 处错误键引用 + zh-CN/en ai.json 全面扩展 - 架构文档 004/005 同步更新
This commit is contained in:
@@ -430,6 +430,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- `cn()` / `formatDate()` / `formatFileSize()` — 通用工具
|
- `cn()` / `formatDate()` / `formatFileSize()` — 通用工具
|
||||||
- `getInitials(name)` / `formatDateForFile(d?)` — 通用工具(P1-c / P1-a 重构新增:从 parent/lib/utils.ts、grades/export-button.tsx 等多处重复实现抽取)
|
- `getInitials(name)` / `formatDateForFile(d?)` — 通用工具(P1-c / P1-a 重构新增:从 parent/lib/utils.ts、grades/export-button.tsx 等多处重复实现抽取)
|
||||||
- `downloadBase64File(base64, filename, mimeType?)` / `downloadBlob(blob, filename)` — 客户端文件下载(P1-c 重构新增:从 grades/export-button、users/user-import-dialog、audit/audit-log-export-button 三处重复实现抽取,位于 `lib/download.ts`)
|
- `downloadBase64File(base64, filename, mimeType?)` / `downloadBlob(blob, filename)` — 客户端文件下载(P1-c 重构新增:从 grades/export-button、users/user-import-dialog、audit/audit-log-export-button 三处重复实现抽取,位于 `lib/download.ts`)
|
||||||
|
- `handleActionError(e)` / `safeActionCall(action, options?)` / `safeJsonParse(json, msg)` / `safeParseDate(v, field)` / `safeParseNumber(v, field)` / `escapeLikePattern(input)` — Server Action 错误处理与客户端调用包装(2026-06-23 审计修复新增:位于 `lib/action-utils.ts`,统一所有 Server Action catch 块错误处理,避免内部错误消息暴露给客户端)
|
||||||
|
- `BusinessError` / `NotFoundError` / `ValidationError` — 错误类(2026-06-23 审计修复新增:位于 `lib/action-utils.ts`,已知业务错误基类,message 可安全返回客户端)
|
||||||
|
|
||||||
**共享组件导出**(P0-b / P1-a / P1-b / P1-c / P2-a / P2-b / P3-a / P3-b / P3-c / P3-d / 第二轮 P0-1/P0-2/P0-3/P1-1/P1-2/P1-3/P1-4 重构新增,按类别组织):
|
**共享组件导出**(P0-b / P1-a / P1-b / P1-c / P2-a / P2-b / P3-a / P3-b / P3-c / P3-d / 第二轮 P0-1/P0-2/P0-3/P1-1/P1-2/P1-3/P1-4 重构新增,按类别组织):
|
||||||
|
|
||||||
@@ -601,6 +603,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ V3-8:新增 `getHomeworkAssignmentsByExamId` + `getGradedSubmissionsByExamId`,供 exams 模块跨模块调用
|
- ✅ V3-8:新增 `getHomeworkAssignmentsByExamId` + `getGradedSubmissionsByExamId`,供 exams 模块跨模块调用
|
||||||
- ✅ V3-9:新增 `getStudentSubmissionResult` + `HomeworkSubmissionResult` + 路由 `/student/learning/assignments/[assignmentId]/result`,`homework-take-view.tsx` 提交后跳转结果页
|
- ✅ V3-9:新增 `getStudentSubmissionResult` + `HomeworkSubmissionResult` + 路由 `/student/learning/assignments/[assignmentId]/result`,`homework-take-view.tsx` 提交后跳转结果页
|
||||||
- ✅ V3-12:`homework-take-view.tsx` 移动端触摸目标尺寸优化
|
- ✅ V3-12:`homework-take-view.tsx` 移动端触摸目标尺寸优化
|
||||||
|
- ✅ 2026-06-23 审计修复:data-access 层 4 个查询函数新增 `scope?: DataScope` 参数(`getHomeworkAssignments`/`getHomeworkAssignmentById`/`getHomeworkSubmissions`/`getHomeworkAssignmentReviewList`),教师仅查看自己创建的作业,学生/家长仅查看相关作业;Server Action catch 块改用 `handleActionError` 统一错误处理
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
@@ -717,11 +720,12 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
**职责**:成绩分析(录入/查询/统计/导出/趋势对比分析)。
|
**职责**:成绩分析(录入/查询/统计/导出/趋势对比分析)。
|
||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`getGradeRecordsAction` / `createGradeRecordAction` / `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction` / `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction` / `assertClassInScope`(✅ P3 新增导出:班级 scope 校验工具,供 actions-analytics 复用)
|
- Actions:`getGradeRecordsAction` / `createGradeRecordAction` / `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction` / `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction` / `assertClassInScope`(✅ P3 新增导出:班级 scope 校验工具,供 actions-analytics 复用)/ `saveGradeDraftAction` / `getGradeDraftAction` / `deleteGradeDraftAction`(✅ v3-P2 新增:成绩录入草稿 Server Actions,分别使用 GRADE_RECORD_MANAGE/GRADE_RECORD_READ/GRADE_RECORD_MANAGE 权限)
|
||||||
- Data-access:`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend` / `PaginatedGradeRecords`(✅ P3 新增:分页结果接口 `{ records, total }`)
|
- Data-access:`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend` / `PaginatedGradeRecords`(✅ P3 新增:分页结果接口 `{ records, total }`)/ `saveGradeDraft` / `getGradeDraft` / `deleteGradeDraft`(✅ v3-P2 新增:成绩录入草稿 CRUD,upsert + 24 小时过期)/ `getExamOptionsForGrades` / `getSchoolWideGradeSummary`(✅ v3-P2 新增:考试选项查询 + 全校各年级成绩汇总,管理员视图按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总)
|
||||||
|
- Types(✅ v3-P2 新增):`SchoolWideGradeSummaryItem`(全校汇总按年级聚合项:gradeId/gradeName/schoolName/classCount/studentCount/averageScore/passRate/excellentRate/recordCount)/ `SchoolWideGradeSummary`(全校汇总:grades 数组 + totals 汇总对象)/ `GradeDraftData`(草稿数据接口:{ scores: Record<string, string>, timestamp: number },位于 data-access.ts)
|
||||||
- Lib(✅ P1-2 新增,✅ P3 更新签名):`toNumber` / `normalize` / `buildScopeClassFilter(scope, currentUserId?)`(P3 修复:`class_members` scope 内置 studentId 过滤,需传入 currentUserId 参数)
|
- Lib(✅ P1-2 新增,✅ P3 更新签名):`toNumber` / `normalize` / `buildScopeClassFilter(scope, currentUserId?)`(P3 修复:`class_members` scope 内置 studentId 过滤,需传入 currentUserId 参数)
|
||||||
- Stats-service(✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O,统计逻辑可独立测试)
|
- Stats-service(✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O,统计逻辑可独立测试)
|
||||||
- Components(✅ P1-5 新增):`WidgetBoundary`(Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)
|
- Components(✅ P1-5 新增):`WidgetBoundary`(Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)/ `SchoolWideSummaryCard`(✅ v3-P2 新增:管理员全校成绩汇总卡片,4 个统计卡片 + 各年级对比表格)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getClassNamesByIds/getActiveStudentIdsByClassId/getStudentActiveClassId/getClassesByGradeId)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds)
|
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getClassNamesByIds/getActiveStudentIdsByClassId/getStudentActiveClassId/getClassesByGradeId)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds)
|
||||||
@@ -754,20 +758,27 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ P3 修复(2026-06-22):~~组件直接 try/catch 调用 Server Action~~ 改用 `safeActionCall` 包装器(onError/onFinally 回调);`batch-grade-entry.tsx` localStorage 访问包裹 `typeof window !== "undefined"` 检查;区分"未录入"与"录入 0"
|
- ✅ P3 修复(2026-06-22):~~组件直接 try/catch 调用 Server Action~~ 改用 `safeActionCall` 包装器(onError/onFinally 回调);`batch-grade-entry.tsx` localStorage 访问包裹 `typeof window !== "undefined"` 检查;区分"未录入"与"录入 0"
|
||||||
- ✅ P3 修复(2026-06-22):~~`grade-trend-card.tsx` 排序未处理 NaN 日期、除法未检查 fullScore > 0~~ 新增日期有效性检查与 `fullScore > 0` 守卫
|
- ✅ P3 修复(2026-06-22):~~`grade-trend-card.tsx` 排序未处理 NaN 日期、除法未检查 fullScore > 0~~ 新增日期有效性检查与 `fullScore > 0` 守卫
|
||||||
- ✅ P3 修复(2026-06-22):~~页面文件缺少 scope 传递~~ teacher/grades/page.tsx 使用 DB 层分页;analytics/page.tsx 添加 EmptyState;entry/page.tsx 与 stats/page.tsx 过滤 class_taught scope 班级;student/grades/page.tsx 与 parent/grades/page.tsx 传递 `ctx.dataScope` 到 data-access
|
- ✅ P3 修复(2026-06-22):~~页面文件缺少 scope 传递~~ teacher/grades/page.tsx 使用 DB 层分页;analytics/page.tsx 添加 EmptyState;entry/page.tsx 与 stats/page.tsx 过滤 class_taught scope 班级;student/grades/page.tsx 与 parent/grades/page.tsx 传递 `ctx.dataScope` 到 data-access
|
||||||
|
- ✅ v3-P2 改进(2026-06-23):新增 `grade_drafts` 表(成绩录入草稿,userId+classId+subjectId+type 唯一键,content JSON 存储 scores,24 小时过期,schema.ts 第 1444-1469 行);新增 `saveGradeDraft`/`getGradeDraft`/`deleteGradeDraft` data-access 函数 + 对应 3 个 Server Actions,支持成绩批量录入草稿服务端持久化
|
||||||
|
- ✅ v3-P2 改进(2026-06-23):新增 `getExamOptionsForGrades` data-access 函数,获取指定班级/科目下有成绩记录的考试列表;`AnalyticsFilters` 组件新增 `exams`/`currentExamId`/`currentSemester` props,添加学期和考试筛选 ChipNav;teacher/grades/analytics/page.tsx 新增 semester/examId 搜索参数解析
|
||||||
|
- ✅ v3-P2 改进(2026-06-23):`getGradeTrend`/`getGradeDistribution`/`getSubjectComparison`/`getClassComparison` 均新增 `semester` 和 `examId` 可选参数,支持按学期和考试筛选分析
|
||||||
|
- ✅ v3-P2 改进(2026-06-23):新增 `getSchoolWideGradeSummary` data-access 函数 + `SchoolWideSummaryCard` 组件 + `SchoolWideGradeSummary`/`SchoolWideGradeSummaryItem` 类型,管理员全校成绩汇总视图(按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总);admin/school/grades/insights/page.tsx 顶部新增 SchoolWideSummaryCard
|
||||||
|
- ✅ v3-P2 改进(2026-06-23):parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCard
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `actions.ts` | 396 | 15 个 Server Action(含 Zod 校验,含 v2-P1-5 安全修复:assertClassInScope + 行级 scope 校验;P3 修复:handleActionError + safeJsonParse + scope 传递 + DB 层分页) |
|
| `actions.ts` | 396+ | 18 个 Server Action(含 Zod 校验,含 v2-P1-5 安全修复:assertClassInScope + 行级 scope 校验;P3 修复:handleActionError + safeJsonParse + scope 传递 + DB 层分页;v3-P2 新增:saveGradeDraftAction/getGradeDraftAction/deleteGradeDraftAction) |
|
||||||
| `actions-analytics.ts` | 170 | 5 个分析 Action(含 Zod 校验,P3 修复:handleActionError + assertClassInScope 校验) |
|
| `actions-analytics.ts` | 170 | 5 个分析 Action(含 Zod 校验,P3 修复:handleActionError + assertClassInScope 校验) |
|
||||||
| `data-access.ts` | 428 | 成绩 CRUD + 统计(含 v2-P2-9 修复:recorderName 批量查询;P3 修复:PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名) |
|
| `data-access.ts` | 428+ | 成绩 CRUD + 统计 + 草稿(含 v2-P2-9 修复:recorderName 批量查询;P3 修复:PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名;v3-P2 新增:saveGradeDraft/getGradeDraft/deleteGradeDraft + GradeDraftData 接口) |
|
||||||
| `data-access-analytics.ts` | 200 | 趋势/对比分析(P3 修复:getClassComparison 应用 buildScopeClassFilter) |
|
| `data-access-analytics.ts` | 200+ | 趋势/对比分析(P3 修复:getClassComparison 应用 buildScopeClassFilter;v3-P2 新增:getExamOptionsForGrades/getSchoolWideGradeSummary;getGradeTrend/getClassComparison/getSubjectComparison/getGradeDistribution 新增 semester/examId 可选参数) |
|
||||||
| `data-access-ranking.ts` | 83 | 排名查询(P3 修复:getRankingTrend 接受 scope 参数 + class_taught 校验) |
|
| `data-access-ranking.ts` | 83 | 排名查询(P3 修复:getRankingTrend 接受 scope 参数 + class_taught 校验) |
|
||||||
| `stats-service.ts` | 279 | 统计计算纯函数(P1-1 新增:8 个纯函数 + 2 个常量 + 2 个接口) |
|
| `stats-service.ts` | 279 | 统计计算纯函数(P1-1 新增:8 个纯函数 + 2 个常量 + 2 个接口) |
|
||||||
| `export.ts` | 189 | Excel 导出(v2-P1-5 修复:传递 currentUserId 到 data-access;P3 修复:适配 PaginatedGradeRecords 结构 + 传递 scope) |
|
| `export.ts` | 189 | Excel 导出(v2-P1-5 修复:传递 currentUserId 到 data-access;P3 修复:适配 PaginatedGradeRecords 结构 + 传递 scope) |
|
||||||
| `schema.ts` | 113 | Zod 校验(含 12 个查询 schema;P3 修复:score .max(1000) + records .max(500) + 补全查询字段) |
|
| `schema.ts` | 113+ | Zod 校验(含 12 个查询 schema;P3 修复:score .max(1000) + records .max(500) + 补全查询字段;v3-P2 新增:grade_drafts 表定义第 1444-1469 行) |
|
||||||
| `lib/grade-utils.ts` | 66 | 公共工具函数(toNumber/normalize/buildScopeClassFilter,v2-P2-2 修复:改用 classes data-access 子查询;P3 修复:buildScopeClassFilter 新增 currentUserId 参数) |
|
| `lib/grade-utils.ts` | 66 | 公共工具函数(toNumber/normalize/buildScopeClassFilter,v2-P2-2 修复:改用 classes data-access 子查询;P3 修复:buildScopeClassFilter 新增 currentUserId 参数) |
|
||||||
|
| `types.ts` | 168+ | 类型定义(v3-P2 新增:SchoolWideGradeSummaryItem/SchoolWideGradeSummary) |
|
||||||
| `components/widget-boundary.tsx` | 136 | Widget 边界组件(P1-5 新增,v2-P1-1 已在 3 个页面应用) |
|
| `components/widget-boundary.tsx` | 136 | Widget 边界组件(P1-5 新增,v2-P1-1 已在 3 个页面应用) |
|
||||||
|
| `components/school-wide-summary-card.tsx` | - | v3-P2 新增:管理员全校成绩汇总卡片(4 个统计卡片 + 各年级对比表格) |
|
||||||
| `components/grade-trend-card.tsx` | 69 | 趋势卡片(v2-P2-9 修复:a11y;v2-P1-4:i18n;P3 修复:NaN 日期检查 + fullScore > 0 守卫) |
|
| `components/grade-trend-card.tsx` | 69 | 趋势卡片(v2-P2-9 修复:a11y;v2-P1-4:i18n;P3 修复:NaN 日期检查 + fullScore > 0 守卫) |
|
||||||
| `components/grade-record-list.tsx` | 125 | 成绩记录列表(v2-P1-4:i18n;P3 修复:safeActionCall 包装删除操作) |
|
| `components/grade-record-list.tsx` | 125 | 成绩记录列表(v2-P1-4:i18n;P3 修复:safeActionCall 包装删除操作) |
|
||||||
| `components/grade-distribution-chart.tsx` | 100 | 分数分布图(v2-P1-4:i18n) |
|
| `components/grade-distribution-chart.tsx` | 100 | 分数分布图(v2-P1-4:i18n) |
|
||||||
@@ -775,16 +786,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `components/class-comparison-chart.tsx` | 58 | 班级对比图(v2-P1-4:i18n) |
|
| `components/class-comparison-chart.tsx` | 58 | 班级对比图(v2-P1-4:i18n) |
|
||||||
| `components/grade-trend-chart.tsx` | 59 | 趋势图(v2-P1-4:i18n) |
|
| `components/grade-trend-chart.tsx` | 59 | 趋势图(v2-P1-4:i18n) |
|
||||||
| `components/grade-record-form.tsx` | 177 | 录入表单(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n;P3 修复:safeActionCall 包装提交) |
|
| `components/grade-record-form.tsx` | 177 | 录入表单(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n;P3 修复:safeActionCall 包装提交) |
|
||||||
| `components/batch-grade-entry.tsx` | 435 | 批量录入(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n;P3 修复:safeActionCall + localStorage 安全检查 + 区分未录入与录入 0) |
|
| `components/batch-grade-entry.tsx` | 435+ | 批量录入(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n;P3 修复:safeActionCall + localStorage 安全检查 + 区分未录入与录入 0;v3-P2 新增:接入服务端草稿 saveGradeDraftAction/getGradeDraftAction/deleteGradeDraftAction) |
|
||||||
| `components/grade-filters.tsx` | 76 | 过滤器(v2-P1-4:i18n) |
|
| `components/grade-filters.tsx` | 76 | 过滤器(v2-P1-4:i18n) |
|
||||||
| `components/student-grade-summary.tsx` | 107 | 学生成绩摘要(v2-P1-4:i18n) |
|
| `components/student-grade-summary.tsx` | 107 | 学生成绩摘要(v2-P1-4:i18n) |
|
||||||
| `components/export-button.tsx` | 79 | 导出按钮(v2-P1-4:i18n;P3 修复:safeActionCall 包装导出操作) |
|
| `components/export-button.tsx` | 79 | 导出按钮(v2-P1-4:i18n;P3 修复:safeActionCall 包装导出操作) |
|
||||||
| `components/analytics-filters.tsx` | 86 | 分析过滤器(v2-P1-4:i18n) |
|
| `components/analytics-filters.tsx` | 86+ | 分析过滤器(v2-P1-4:i18n;v3-P2 新增:exams/currentExamId/currentSemester props,添加学期和考试筛选 ChipNav) |
|
||||||
| `components/stats-class-selector.tsx` | 40 | 统计班级选择器(v2-P1-4:i18n) |
|
| `components/stats-class-selector.tsx` | 40 | 统计班级选择器(v2-P1-4:i18n) |
|
||||||
| `components/grade-stats-card.tsx` | 74 | 统计卡片(v2-P1-4:i18n) |
|
| `components/grade-stats-card.tsx` | 74 | 统计卡片(v2-P1-4:i18n) |
|
||||||
| `components/class-grade-report.tsx` | 90 | 班级成绩报告(v2-P1-4:i18n) |
|
| `components/class-grade-report.tsx` | 90 | 班级成绩报告(v2-P1-4:i18n) |
|
||||||
| `components/grade-query-filters.tsx` | 96 | 查询过滤器(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n) |
|
| `components/grade-query-filters.tsx` | 96 | 查询过滤器(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n) |
|
||||||
| `types.ts` | 168 | 类型定义 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1362,6 +1372,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ⚠️ v4 保留:`/parent/leave` 为占位页,待后端实现请假审批流后接入
|
- ⚠️ v4 保留:`/parent/leave` 为占位页,待后端实现请假审批流后接入
|
||||||
- ⚠️ v4 保留:`ParentExportButton` 为占位,待后端实现成绩导出 Server Action 后接入
|
- ⚠️ v4 保留:`ParentExportButton` 为占位,待后端实现成绩导出 Server Action 后接入
|
||||||
- ⚠️ v4 保留:详情页 Attendance/Diagnostic Tab 为占位提示,待对应功能实现后填充
|
- ⚠️ v4 保留:详情页 Attendance/Diagnostic Tab 为占位提示,待对应功能实现后填充
|
||||||
|
- ✅ v3-P2 改进(2026-06-23):parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCard;parent/diagnostic/page.tsx 传入 `practiceHrefBase={null}` 隐藏练习按钮
|
||||||
- ✅ 职责单一,正确复用其他模块 data-access
|
- ✅ 职责单一,正确复用其他模块 data-access
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
@@ -1390,8 +1401,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| 路由 | 文件 | 说明 |
|
| 路由 | 文件 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `/parent/dashboard` | `dashboard/page.tsx` + `loading.tsx` | 家长仪表盘 |
|
| `/parent/dashboard` | `dashboard/page.tsx` + `loading.tsx` | 家长仪表盘 |
|
||||||
| `/parent/grades` | `grades/page.tsx` + `loading.tsx` | 多子女成绩聚合 |
|
| `/parent/grades` | `grades/page.tsx` + `loading.tsx` | 多子女成绩聚合(v3-P2:并行查询 getClassAverageTrend + GradeTrendCard) |
|
||||||
| `/parent/diagnostic` | `diagnostic/page.tsx` + `loading.tsx` + `error.tsx` | P2-5 新增:多子女学情诊断聚合 |
|
| `/parent/diagnostic` | `diagnostic/page.tsx` + `loading.tsx` + `error.tsx` | P2-5 新增:多子女学情诊断聚合(v3-P2:practiceHrefBase={null} 隐藏练习按钮) |
|
||||||
| `/parent/attendance` | `attendance/page.tsx` + `loading.tsx` | 多子女考勤聚合(v4 新增预警横幅) |
|
| `/parent/attendance` | `attendance/page.tsx` + `loading.tsx` | 多子女考勤聚合(v4 新增预警横幅) |
|
||||||
| `/parent/leave` | `leave/page.tsx` + `loading.tsx` | v4 新增:请假申请(占位) |
|
| `/parent/leave` | `leave/page.tsx` + `loading.tsx` | v4 新增:请假申请(占位) |
|
||||||
| `/parent/children/[studentId]` | `children/[studentId]/page.tsx` + `loading.tsx` | 子女详情页(v4 重构:Tab 布局 + 多子女切换) |
|
| `/parent/children/[studentId]` | `children/[studentId]/page.tsx` + `loading.tsx` | 子女详情页(v4 重构:Tab 布局 + 多子女切换) |
|
||||||
@@ -1513,6 +1524,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ v2-P1-4 已修复:~~4 个组件 i18n 完全未接入~~ 全部接入 `useTranslations("diagnostic")`
|
- ✅ v2-P1-4 已修复:~~4 个组件 i18n 完全未接入~~ 全部接入 `useTranslations("diagnostic")`
|
||||||
- ✅ v2-P2-7 已修复:~~`report-list.tsx` 过滤器 Label 缺少 `htmlFor`~~ 添加 `htmlFor` 和 `id`
|
- ✅ v2-P2-7 已修复:~~`report-list.tsx` 过滤器 Label 缺少 `htmlFor`~~ 添加 `htmlFor` 和 `id`
|
||||||
- ✅ 与 grades 模块无职责重叠
|
- ✅ 与 grades 模块无职责重叠
|
||||||
|
- ✅ v3-P2 改进(2026-06-23):`StudentDiagnosticView` 新增 `practiceHrefBase` prop(string | null),null 时隐藏练习按钮;teacher/diagnostic/student/[studentId] 传入 `practiceHrefBase="/teacher/questions"`,parent/diagnostic 传入 `practiceHrefBase={null}` 隐藏练习按钮
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
@@ -1524,7 +1536,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `schema.ts` | 23 | Zod 校验(4 个 schema,v2-P2-3 删除 2 个死代码 schema) |
|
| `schema.ts` | 23 | Zod 校验(4 个 schema,v2-P2-3 删除 2 个死代码 schema) |
|
||||||
| `types.ts` | 87 | 类型定义 |
|
| `types.ts` | 87 | 类型定义 |
|
||||||
| `components/class-diagnostic-view.tsx` | 266 | 班级诊断视图(v2-P1-6 热力图 a11y;v2-P1-4 i18n) |
|
| `components/class-diagnostic-view.tsx` | 266 | 班级诊断视图(v2-P1-6 热力图 a11y;v2-P1-4 i18n) |
|
||||||
| `components/student-diagnostic-view.tsx` | 225 | 学生诊断视图(v2-P1-4 i18n) |
|
| `components/student-diagnostic-view.tsx` | 225+ | 学生诊断视图(v2-P1-4 i18n;v3-P2 新增:practiceHrefBase prop,null 时隐藏练习按钮) |
|
||||||
| `components/mastery-radar-chart.tsx` | 72 | 雷达图(v2-P1-4 i18n) |
|
| `components/mastery-radar-chart.tsx` | 72 | 雷达图(v2-P1-4 i18n) |
|
||||||
| `components/report-list.tsx` | 265 | 报告列表(v2-P2-7 Label htmlFor;v2-P1-4 i18n) |
|
| `components/report-list.tsx` | 265 | 报告列表(v2-P2-7 Label htmlFor;v2-P1-4 i18n) |
|
||||||
|
|
||||||
@@ -1758,6 +1770,12 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
> - **P2-1 a11y 修复**:5 个组件(question-bank-picker/publish-homework-dialog/knowledge-point-picker/exercise-block/text-study-block)添加 `role="dialog"`/`aria-modal`/`aria-label`;inline-question-editor 添加 `role="dialog"`/`aria-modal`/`aria-label`
|
> - **P2-1 a11y 修复**:5 个组件(question-bank-picker/publish-homework-dialog/knowledge-point-picker/exercise-block/text-study-block)添加 `role="dialog"`/`aria-modal`/`aria-label`;inline-question-editor 添加 `role="dialog"`/`aria-modal`/`aria-label`
|
||||||
> - **P2-4 监控埋点预留**:`providers/lesson-plan-provider.tsx` 定义 `LessonPlanTracker` 接口 + `noopTracker` 默认空实现,生产环境可替换为真实埋点
|
> - **P2-4 监控埋点预留**:`providers/lesson-plan-provider.tsx` 定义 `LessonPlanTracker` 接口 + `noopTracker` 默认空实现,生产环境可替换为真实埋点
|
||||||
|
|
||||||
|
> 架构变更(2026-06-23,本次审计修复):
|
||||||
|
> - **Zod schema 补全**:`schema.ts` 新增 `publishLessonPlanHomeworkSchema`(planId/blockId 必填,classIds 至少 1 个,availableAt/dueAt 日期格式校验),导出类型 `PublishLessonPlanHomeworkInput`;3 个 action 文件(actions/actions-publish/actions-ai)补全 Zod 校验
|
||||||
|
> - **data-access-versions.ts 事务修复**:`revertToVersion` 包裹 `db.transaction` 确保原子性(回滚版本时同步更新 lessonPlans.content 与版本记录)
|
||||||
|
> - **统一错误处理**:所有 Server Action catch 块改用 `handleActionError`;`JSON.parse` 改用 `safeJsonParse`
|
||||||
|
> - **block-renderer 拖拽 BUG 修复**:修复拖拽时节点位置计算错误
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 职责 |
|
| 文件 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -1896,14 +1914,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2.29 ai(AI 模块)— ✅ 新增
|
## 2.29 ai(AI 模块)— ✅ 新增 / V2 增强
|
||||||
|
|
||||||
**职责**:统一 AI 能力封装,为备课、错题集、试卷、改题等业务模块提供 AI 服务。
|
**职责**:统一 AI 能力封装,为备课、错题集、试卷、改题等业务模块提供 AI 服务。V2 增加流式响应、Markdown 渲染、全局助手、内容安全过滤、家长学情摘要、管理员使用统计、学生学习路径推荐。
|
||||||
|
|
||||||
**架构定位**:
|
**架构定位**:
|
||||||
- 位于 `modules/` 层,通过 `shared/lib/ai` 调用底层 AI SDK
|
- 位于 `modules/` 层,通过 `shared/lib/ai` 调用底层 AI SDK
|
||||||
- 通过 `AiClientProvider`(React Context)向客户端组件注入 Server Action 引用
|
- 通过 `AiClientProvider`(React Context)向客户端组件注入 Server Action 引用
|
||||||
- 业务模块不直接 import `ai/actions`,仅通过 Context 消费
|
- 业务模块不直接 import `ai/actions`,仅通过 Context 消费
|
||||||
|
- V2:在 dashboard layout 全局注入 `AiClientProvider`,所有页面均可使用 AI 助手
|
||||||
|
|
||||||
**核心导出**:
|
**核心导出**:
|
||||||
|
|
||||||
@@ -1915,19 +1934,34 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| **Server Actions** | `generateLessonContentAction` | `modules/ai/actions.ts` | 备课内容生成(权限:AI_CHAT + LESSON_PLAN_READ) |
|
| **Server Actions** | `generateLessonContentAction` | `modules/ai/actions.ts` | 备课内容生成(权限:AI_CHAT + LESSON_PLAN_READ) |
|
||||||
| **Server Actions** | `generateQuestionVariantAction` | `modules/ai/actions.ts` | 题目变体生成(权限:AI_CHAT + EXAM_AI_GENERATE) |
|
| **Server Actions** | `generateQuestionVariantAction` | `modules/ai/actions.ts` | 题目变体生成(权限:AI_CHAT + EXAM_AI_GENERATE) |
|
||||||
| **Server Actions** | `analyzeWeaknessAction` | `modules/ai/actions.ts` | 薄弱点分析(权限:AI_CHAT + ERROR_BOOK_READ) |
|
| **Server Actions** | `analyzeWeaknessAction` | `modules/ai/actions.ts` | 薄弱点分析(权限:AI_CHAT + ERROR_BOOK_READ) |
|
||||||
| **Service** | `AiService` | `modules/ai/types.ts` | 服务端 AI 服务接口(chat/suggestSimilarQuestions/suggestGrading/generateLessonContent/generateQuestionVariant/analyzeWeakness) |
|
| **Server Actions** | `generateChildSummaryAction` | `modules/ai/actions.ts` | 家长学情摘要(权限:AI_CHAT)— V2 新增 |
|
||||||
|
| **Server Actions** | `recommendStudyPathAction` | `modules/ai/actions.ts` | 学习路径推荐(权限:AI_CHAT)— V2 新增 |
|
||||||
|
| **Server Actions** | `getAiUsageStatsAction` | `modules/ai/actions.ts` | AI 使用统计(权限:AI_CONFIGURE)— V2 新增 |
|
||||||
|
| **SSE Route** | `POST /api/ai/chat/stream` | `app/api/ai/chat/stream/route.ts` | 流式 AI 对话(SSE)— V2 新增 |
|
||||||
|
| **Service** | `AiService` | `modules/ai/types.ts` | 服务端 AI 服务接口(含 8 个方法) |
|
||||||
| **Service** | `AiClientService` | `modules/ai/types.ts` | 客户端 AI 服务接口(Server Action 引用集合) |
|
| **Service** | `AiClientService` | `modules/ai/types.ts` | 客户端 AI 服务接口(Server Action 引用集合) |
|
||||||
| **Provider** | `AiClientProvider` | `modules/ai/context/ai-client-provider.tsx` | React Context Provider,注入 AiClientService |
|
| **Provider** | `AiClientProvider` | `modules/ai/context/ai-client-provider.tsx` | React Context Provider,注入 AiClientService |
|
||||||
| **Hook** | `useAiClient` | `modules/ai/context/ai-client-provider.tsx` | 消费 AiClientService(必须在 Provider 内使用) |
|
| **Hook** | `useAiClient` | `modules/ai/context/ai-client-provider.tsx` | 消费 AiClientService(必须在 Provider 内使用) |
|
||||||
| **Hook** | `useAiClientOptional` | `modules/ai/context/ai-client-provider.tsx` | 可选消费 AiClientService(未注入返回 null) |
|
| **Hook** | `useAiClientOptional` | `modules/ai/context/ai-client-provider.tsx` | 可选消费 AiClientService(未注入返回 null) |
|
||||||
|
| **Hook** | `useAiChatStream` | `modules/ai/hooks/use-ai-chat-stream.ts` | 流式 AI 对话 Hook(SSE + AbortController)— V2 新增 |
|
||||||
|
| **Hook** | `useAiChat` | `modules/ai/hooks/use-ai-chat.ts` | 非流式 AI 对话 Hook |
|
||||||
|
| **Hook** | `useAiSuggestion` | `modules/ai/hooks/use-ai-suggestion.ts` | AI 建议 Hook |
|
||||||
|
| **Component** | `AiAssistantWidget` | `modules/ai/components/ai-assistant-widget.tsx` | 全局 AI 助手悬浮按钮(上下文感知)— V2 新增 |
|
||||||
|
| **Component** | `AiChatPanel` | `modules/ai/components/ai-chat-panel.tsx` | AI 对话面板(流式 + Markdown + 复制 + 停止 + 建议)— V2 增强 |
|
||||||
|
| **Component** | `AiMarkdownRenderer` | `modules/ai/components/ai-markdown-renderer.tsx` | Markdown 渲染器(GFM + 复制按钮)— V2 新增 |
|
||||||
| **Component** | `AiGradingAssist` | `modules/ai/components/ai-grading-assist.tsx` | AI 批改辅助(主观题预评分 + 反馈建议) |
|
| **Component** | `AiGradingAssist` | `modules/ai/components/ai-grading-assist.tsx` | AI 批改辅助(主观题预评分 + 反馈建议) |
|
||||||
| **Component** | `AiErrorBookAnalysis` | `modules/ai/components/ai-error-book-analysis.tsx` | 错题本 AI 分析(相似题 + 薄弱点) |
|
| **Component** | `AiErrorBookAnalysis` | `modules/ai/components/ai-error-book-analysis.tsx` | 错题本 AI 分析(相似题 + 薄弱点) |
|
||||||
| **Component** | `AiLessonContentGenerator` | `modules/ai/components/ai-lesson-content-generator.tsx` | 备课内容生成器(活动/评估/讨论题/素材) |
|
| **Component** | `AiLessonContentGenerator` | `modules/ai/components/ai-lesson-content-generator.tsx` | 备课内容生成器(活动/评估/讨论题/素材) |
|
||||||
| **Component** | `AiQuestionVariantGenerator` | `modules/ai/components/ai-question-variant-generator.tsx` | 题目变体生成器(同知识点/不同难度/不同题型) |
|
| **Component** | `AiQuestionVariantGenerator` | `modules/ai/components/ai-question-variant-generator.tsx` | 题目变体生成器(同知识点/不同难度/不同题型) |
|
||||||
| **Component** | `AiChatPanel` | `modules/ai/components/ai-chat-panel.tsx` | AI 对话面板 |
|
| **Component** | `AiChildSummary` | `modules/ai/components/ai-child-summary.tsx` | 家长 AI 学情摘要 — V2 新增 |
|
||||||
|
| **Component** | `AiUsageDashboard` | `modules/ai/components/ai-usage-dashboard.tsx` | 管理员 AI 使用统计仪表盘 — V2 新增 |
|
||||||
|
| **Component** | `AiStudyPath` | `modules/ai/components/ai-study-path.tsx` | 学生学习路径推荐 — V2 新增 |
|
||||||
| **Component** | `AiErrorBoundary` | `modules/ai/components/ai-error-boundary.tsx` | AI 功能错误边界 |
|
| **Component** | `AiErrorBoundary` | `modules/ai/components/ai-error-boundary.tsx` | AI 功能错误边界 |
|
||||||
| **Component** | `AiSuggestionSkeleton` | `modules/ai/components/ai-skeleton.tsx` | AI 建议加载骨架屏 |
|
| **Component** | `AiSuggestionSkeleton` | `modules/ai/components/ai-skeleton.tsx` | AI 建议加载骨架屏 |
|
||||||
| **Component** | `AiProviderSelector` | `modules/ai/components/ai-provider-selector.tsx` | AI 服务商选择器 |
|
| **Component** | `AiProviderSelector` | `modules/ai/components/ai-provider-selector.tsx` | AI 服务商选择器 |
|
||||||
|
| **Safety** | `filterUserInput` | `modules/ai/services/content-safety.ts` | 输入安全过滤 — V2 新增 |
|
||||||
|
| **Safety** | `filterAiOutput` | `modules/ai/services/content-safety.ts` | 输出安全过滤 — V2 新增 |
|
||||||
|
| **Safety** | `checkDailyLimit` | `modules/ai/services/content-safety.ts` | 每日交互限制 — V2 新增 |
|
||||||
|
|
||||||
**集成点**:
|
**集成点**:
|
||||||
|
|
||||||
@@ -1937,41 +1971,62 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| error-book | `AiErrorBookAnalysis` | `student/error-book` | 相似题推荐 + 薄弱点分析 |
|
| error-book | `AiErrorBookAnalysis` | `student/error-book` | 相似题推荐 + 薄弱点分析 |
|
||||||
| lesson-preparation | `AiLessonContentGenerator` | `teacher/lesson-plans/[planId]/edit` | 备课内容生成 |
|
| lesson-preparation | `AiLessonContentGenerator` | `teacher/lesson-plans/[planId]/edit` | 备课内容生成 |
|
||||||
| exams | `AiQuestionVariantGenerator` | `teacher/exams/[id]/build` | 题目变体生成 |
|
| exams | `AiQuestionVariantGenerator` | `teacher/exams/[id]/build` | 题目变体生成 |
|
||||||
|
| **全局** | `AiAssistantWidget` | `app/(dashboard)/layout.tsx` | 全局 AI 助手悬浮按钮(所有页面)— V2 新增 |
|
||||||
|
| **parent** | `AiChildSummary` | `parent/*` | 家长学情 AI 摘要 — V2 新增 |
|
||||||
|
| **admin** | `AiUsageDashboard` | `admin/*` | AI 使用统计仪表盘 — V2 新增 |
|
||||||
|
| **student** | `AiStudyPath` | `student/*` | 学习路径推荐 — V2 新增 |
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- `modules/ai` → `shared/lib/ai`(AI SDK 封装)
|
- `modules/ai` → `shared/lib/ai`(AI SDK 封装,含流式 `createAiChatCompletionStream`)
|
||||||
- `modules/ai` → `shared/lib/auth-guard`(权限校验)
|
- `modules/ai` → `shared/lib/auth-guard`(权限校验)
|
||||||
|
- `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`(返回值类型)
|
||||||
|
- `app/(dashboard)/layout` → `modules/ai`(全局 Provider + Widget)— V2 新增
|
||||||
- 业务模块 → `modules/ai/context/ai-client-provider`(通过 Context 注入)
|
- 业务模块 → `modules/ai/context/ai-client-provider`(通过 Context 注入)
|
||||||
- 业务模块 → `modules/ai/components/*`(组合 AI 组件)
|
- 业务模块 → `modules/ai/components/*`(组合 AI 组件)
|
||||||
|
|
||||||
|
**安全机制(V2 新增)**:
|
||||||
|
- 输入过滤:`filterUserInput` 拦截暴力/自残/色情/PII
|
||||||
|
- 输出过滤:`filterAiOutput` 扫描 AI 回复
|
||||||
|
- 每日限制:学生 50 次/天,教师 200 次/天,家长 30 次/天
|
||||||
|
- 学生 Socratic 模式:system prompt 强制不直接给答案
|
||||||
|
- SSE 端点权限校验:`requirePermission(AI_CHAT)`
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
|
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `modules/ai/types.ts` | 194 | 类型定义(AiService/AiClientService/业务场景类型) |
|
| `modules/ai/types.ts` | ~270 | 类型定义(8 个业务场景类型 + AiService/AiClientService) |
|
||||||
| `modules/ai/schema.ts` | - | Zod 验证 schema |
|
| `modules/ai/schema.ts` | ~205 | Zod 验证 schema(8 个输入 + 8 个输出) |
|
||||||
| `modules/ai/actions.ts` | 244 | 6 个 Server Actions(含权限校验) |
|
| `modules/ai/actions.ts` | ~340 | 9 个 Server Actions(含权限校验) |
|
||||||
| `modules/ai/services/ai-service.ts` | - | DefaultAiService 实现 |
|
| `modules/ai/services/ai-service.ts` | ~400 | DefaultAiService 实现(8 个方法) |
|
||||||
| `modules/ai/services/prompt-templates.ts` | - | 6 个系统提示词模板 |
|
| `modules/ai/services/prompt-templates.ts` | ~210 | 8 个系统提示词模板 |
|
||||||
| `modules/ai/services/usage-tracker.ts` | - | AI 使用量埋点 |
|
| `modules/ai/services/usage-tracker.ts` | ~83 | AI 使用量埋点 |
|
||||||
| `modules/ai/context/ai-client-provider.tsx` | 59 | React Context Provider + Hooks |
|
| `modules/ai/services/content-safety.ts` | ~130 | 内容安全过滤(输入/输出/每日限制)— V2 新增 |
|
||||||
|
| `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-chat-panel.tsx` | ~305 | AI 对话面板(流式 + Markdown)— V2 增强 |
|
||||||
|
| `modules/ai/components/ai-markdown-renderer.tsx` | ~100 | Markdown 渲染器 — V2 新增 |
|
||||||
|
| `modules/ai/components/ai-child-summary.tsx` | ~170 | 家长学情摘要 — V2 新增 |
|
||||||
|
| `modules/ai/components/ai-usage-dashboard.tsx` | ~180 | 管理员使用统计 — V2 新增 |
|
||||||
|
| `modules/ai/components/ai-study-path.tsx` | ~170 | 学生学习路径 — V2 新增 |
|
||||||
| `modules/ai/components/ai-grading-assist.tsx` | 173 | AI 批改辅助组件 |
|
| `modules/ai/components/ai-grading-assist.tsx` | 173 | AI 批改辅助组件 |
|
||||||
| `modules/ai/components/ai-error-book-analysis.tsx` | 246 | 错题本 AI 分析组件 |
|
| `modules/ai/components/ai-error-book-analysis.tsx` | 246 | 错题本 AI 分析组件 |
|
||||||
| `modules/ai/components/ai-lesson-content-generator.tsx` | - | 备课内容生成器 |
|
| `modules/ai/components/ai-lesson-content-generator.tsx` | ~187 | 备课内容生成器 |
|
||||||
| `modules/ai/components/ai-question-variant-generator.tsx` | - | 题目变体生成器 |
|
| `modules/ai/components/ai-question-variant-generator.tsx` | ~208 | 题目变体生成器 |
|
||||||
| `modules/ai/components/ai-chat-panel.tsx` | - | AI 对话面板 |
|
| `modules/ai/components/ai-error-boundary.tsx` | ~88 | AI 错误边界 |
|
||||||
| `modules/ai/components/ai-error-boundary.tsx` | - | AI 错误边界 |
|
| `modules/ai/components/ai-skeleton.tsx` | ~47 | AI 骨架屏 |
|
||||||
| `modules/ai/components/ai-skeleton.tsx` | - | AI 骨架屏 |
|
| `modules/ai/components/ai-provider-selector.tsx` | ~129 | 服务商选择器 |
|
||||||
| `modules/ai/components/ai-provider-selector.tsx` | - | 服务商选择器 |
|
| `modules/ai/hooks/use-ai-chat-stream.ts` | ~170 | 流式 AI 对话 Hook — V2 新增 |
|
||||||
| `modules/ai/hooks/use-ai-chat.ts` | - | AI 对话 Hook |
|
| `modules/ai/hooks/use-ai-chat.ts` | ~57 | 非流式 AI 对话 Hook |
|
||||||
| `modules/ai/hooks/use-ai-suggestion.ts` | - | AI 建议 Hook |
|
| `modules/ai/hooks/use-ai-suggestion.ts` | ~72 | AI 建议 Hook |
|
||||||
|
| `app/api/ai/chat/stream/route.ts` | ~160 | SSE 流式端点 — V2 新增 |
|
||||||
|
|
||||||
**i18n**:
|
**i18n**:
|
||||||
- 翻译文件:`shared/i18n/messages/{locale}/ai.json`
|
- 翻译文件:`shared/i18n/messages/{locale}/ai.json`
|
||||||
- 命名空间:`ai`
|
- 命名空间:`ai`
|
||||||
- 包含:chat/provider/suggestion/grading/errorBook/lessonPrep/exam/error/capability
|
- V2 新增键:`chat.streaming/stopGeneration/copy/clearConfirm/suggestedPrompts`、`grading.description/batch*`、`lessonPrep.description/additionalContext/insertContent`、`exam.variantType.*/targetDifficulty/addVariant`、`parent.*`、`admin.*`、`studyPath.*`、`widget.*`、`safety.*`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2240,6 +2295,82 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 3.6 教师端全模块审计修复(2026-06-23)
|
||||||
|
|
||||||
|
> 本次审计修复覆盖教师端 13 个模块组、约 80+ 个文件,聚焦越权访问漏洞、错误处理一致性、类型安全与 React 性能。以下仅记录涉及架构图同步的变更(函数签名/导出/新增文件),其余纯实现修复(如 try/catch 补齐、useMemo/useCallback、key prop、as 断言清理)不在此列出。
|
||||||
|
|
||||||
|
### 3.6.1 新增共享文件 `src/shared/lib/action-utils.ts`
|
||||||
|
|
||||||
|
统一 Server Action 错误处理与客户端 Action 调用模式,避免内部错误消息暴露给客户端。
|
||||||
|
|
||||||
|
| 导出 | 类型 | 签名 | 用途 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `handleActionError` | function | `(e: unknown) => ActionState<never>` | 统一 Server Action catch 块错误处理:PermissionDeniedError/BusinessError 返回其 message,其他 Error 返回通用消息并 console.error 记录 |
|
||||||
|
| `safeActionCall` | function | `<T>(action: () => Promise<ActionState<T>>, options?: { onError?, onFinally? }) => Promise<ActionState<T> \| null>` | 客户端调用 Server Action 的 try/catch/finally 包装器,防止 UI 永久卡 loading |
|
||||||
|
| `safeJsonParse` | function | `<T>(json: string, errorMessage: string) => T` | 安全 JSON.parse,失败抛 ValidationError(替代裸 JSON.parse) |
|
||||||
|
| `safeParseDate` | function | `(value: string, fieldName: string) => Date` | 校验日期字符串有效性,无效抛 ValidationError |
|
||||||
|
| `safeParseNumber` | function | `(value: string, fieldName: string) => number` | 校验数字字符串,无效抛 ValidationError |
|
||||||
|
| `escapeLikePattern` | function | `(input: string) => string` | 转义 SQL LIKE 通配符(% _ \\),防止用户输入干扰模糊查询 |
|
||||||
|
| `BusinessError` | class | `extends Error` | 已知业务错误基类,message 可安全返回客户端(含可选 code) |
|
||||||
|
| `NotFoundError` | class | `extends BusinessError` | 资源不存在错误(自动生成 `${resource} 不存在` 消息,code: not_found) |
|
||||||
|
| `ValidationError` | class | `extends BusinessError` | 输入校验错误(code: validation_error) |
|
||||||
|
|
||||||
|
### 3.6.2 grades 模块签名变更
|
||||||
|
|
||||||
|
**data-access 层新增 scope 参数**(P3 修复:所有查询函数应用 `buildScopeClassFilter` 进行行级 scope 过滤):
|
||||||
|
|
||||||
|
| 函数 | 文件 | 旧签名 | 新签名 |
|
||||||
|
|------|------|--------|--------|
|
||||||
|
| `getGradeRecords` | `data-access.ts` | `(params: GradeQueryParams) => Promise<GradeRecordListItem[]>` | `(params: GradeQueryParams & { scope: DataScope; currentUserId?: string; limit?: number; offset?: number }) => Promise<PaginatedGradeRecords>` |
|
||||||
|
| `getClassGradeStats` | `data-access.ts` | `(classId, subjectId?, examId?) => Promise<GradeStats>` | `(classId, subjectId?, examId?, scope?: DataScope, currentUserId?: string) => Promise<GradeStats \| null>` |
|
||||||
|
| `getStudentGradeSummary` | `data-access.ts` | `(studentId) => Promise<StudentGradeSummary>` | `(studentId, scope?: DataScope) => Promise<StudentGradeSummary \| null>`(class_taught scope 校验学生归属) |
|
||||||
|
| `getClassRanking` | `data-access.ts` | `(classId, subjectId?, examId?) => Promise<ClassRankingItem[]>` | `(classId, subjectId?, examId?, scope?: DataScope, currentUserId?: string) => Promise<ClassRankingItem[]>`(含并列排名处理) |
|
||||||
|
| `getClassStudentsForEntry` | `data-access.ts` | `(classId) => Promise<{id,name}[]>` | `(classId, scope?: DataScope) => Promise<{id,name,email}[]>`(class_taught scope 校验 classId) |
|
||||||
|
| `getRankingTrend` | `data-access-ranking.ts` | `(studentId, subjectId?, semester?) => Promise<RankingTrendResult \| null>` | `(studentId, subjectId?, semester?, scope?: DataScope) => Promise<RankingTrendResult \| null>`(class_taught scope 校验) |
|
||||||
|
|
||||||
|
**lib 层签名变更**:
|
||||||
|
- `buildScopeClassFilter(scope: DataScope, currentUserId?: string): SQL | null`(新增 `currentUserId` 参数,`class_members` scope 内置 `eq(gradeRecords.studentId, currentUserId)` 过滤)
|
||||||
|
|
||||||
|
**新增导出**:
|
||||||
|
- `assertClassInScope(scope: DataScope, classId: string): string | null`(`actions.ts`,校验 classId 是否在 scope 允许范围内,供 actions.ts 与 actions-analytics.ts 复用)
|
||||||
|
- `PaginatedGradeRecords` 接口(`data-access.ts`,`{ records: GradeRecordListItem[]; total: number }`,配合 DB 层分页)
|
||||||
|
|
||||||
|
### 3.6.3 homework 模块签名变更
|
||||||
|
|
||||||
|
**data-access 层新增 scope 参数**(P3 修复:教师仅查看自己创建的作业,学生/家长仅查看相关作业):
|
||||||
|
|
||||||
|
| 函数 | 文件 | 新签名 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `getHomeworkAssignments` | `data-access.ts` | `(params?: { creatorId?, ids?, classId?, scope?: DataScope }) => Promise<HomeworkAssignmentListItem[]>` |
|
||||||
|
| `getHomeworkAssignmentById` | `data-access.ts` | `(id: string, scope?: DataScope) => Promise<HomeworkAssignmentListItem \| null>` |
|
||||||
|
| `getHomeworkSubmissions` | `data-access.ts` | `(params?: { assignmentId?, classId?, creatorId?, scope?: DataScope }) => Promise<HomeworkSubmissionListItem[]>` |
|
||||||
|
| `getHomeworkAssignmentReviewList` | `data-access.ts` | `(params: { creatorId: string; scope?: DataScope }) => Promise<HomeworkReviewListItem[]>` |
|
||||||
|
|
||||||
|
### 3.6.4 lesson-preparation 模块变更
|
||||||
|
|
||||||
|
**新增 Zod schema**(`schema.ts`):
|
||||||
|
- `publishLessonPlanHomeworkSchema`:发布作业输入校验(planId/blockId 必填,classIds 至少 1 个,availableAt/dueAt 日期格式校验)
|
||||||
|
- 导出类型 `PublishLessonPlanHomeworkInput = z.infer<typeof publishLessonPlanHomeworkSchema>`
|
||||||
|
|
||||||
|
**data-access-versions.ts 事务变更**:
|
||||||
|
- `revertToVersion` 包裹 `db.transaction` 确保原子性(回滚版本时同步更新 lessonPlans.content 与版本记录)
|
||||||
|
- `createLessonPlanVersion` 已使用 `db.transaction`(版本号自增 + 插入版本记录原子化)
|
||||||
|
|
||||||
|
### 3.6.5 其他修复(不涉及签名变更,仅记录范围)
|
||||||
|
|
||||||
|
- 越权访问修复:15+ 处页面添加 `requirePermission`,data-access 层添加 scope 过滤
|
||||||
|
- 功能性 BUG 修复:question-actions 删除字段名、question-columns 时间显示、textbook-card 删除按钮、block-renderer 拖拽
|
||||||
|
- 统一错误处理:所有 Server Action catch 块改用 `handleActionError`
|
||||||
|
- 客户端 Action 调用添加 try/catch/finally(20+ 处,使用 `safeActionCall`)
|
||||||
|
- `JSON.parse` 改用 `safeJsonParse`(8+ 处)
|
||||||
|
- Date 解析添加校验(15+ 处,使用 `safeParseDate`)
|
||||||
|
- 数据库事务添加:`batchCreateGradeRecords`、`deleteChapter`、`revertToVersion`
|
||||||
|
- 性能优化:`Promise.all` 并行化、DB 层分页(grades `getGradeRecords`)
|
||||||
|
- TypeScript `as` 断言清理(30+ 处,改用类型守卫)
|
||||||
|
- React 性能修复:`useMemo`、`useCallback`、`key` prop、渲染期间副作用移除
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# 附录 A:模块间依赖矩阵
|
# 附录 A:模块间依赖矩阵
|
||||||
|
|
||||||
> 行表示使用方,列表示被使用方。`✅` 合理依赖,`❌` 违规直查,`⟳` 循环依赖。
|
> 行表示使用方,列表示被使用方。`✅` 合理依赖,`❌` 违规直查,`⟳` 循环依赖。
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
508
docs/architecture/audit/ai-module-audit-report-v2.md
Normal file
508
docs/architecture/audit/ai-module-audit-report-v2.md
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
# AI 模块审计报告 V2 — 深度可用性分析与行业对标
|
||||||
|
|
||||||
|
> 审计范围:基于 V1 审计报告(`ai-module-audit-report.md`)已完成的实现,进行第二轮深度审计。
|
||||||
|
> 审计日期:2026-06-23
|
||||||
|
> 审计方法:逐组件可用性走查 + 行业标杆对标(Khanmigo / Duolingo Max / Squirrel AI / Century Tech)+ 多角色用户旅程分析
|
||||||
|
> 审计依据:`docs/standards/coding-standards.md`、`docs/architecture/004_architecture_impact_map.md`、行业研究
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、V1 完成度回顾
|
||||||
|
|
||||||
|
### 1.1 已完成项
|
||||||
|
|
||||||
|
| 编号 | V1 改进项 | 状态 | 实现位置 |
|
||||||
|
|------|----------|------|---------|
|
||||||
|
| P0-1 | AI 聊天端点权限校验 | ✅ | [actions.ts](file:///e:/Desktop/CICD/src/modules/ai/actions.ts) `aiChatAction` |
|
||||||
|
| P0-2 | AI 独立模块 | ✅ | `src/modules/ai/` 完整结构 |
|
||||||
|
| P0-3 | exam-ai-generator i18n | ✅ | [exam-ai-generator.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/exam-ai-generator.tsx) |
|
||||||
|
| P0-4 | AI 管线错误消息 i18n | ✅ | [request.ts](file:///e:/Desktop/CICD/src/modules/exams/ai-pipeline/request.ts) |
|
||||||
|
| P0-5 | ai-suggest.ts 类型安全 | ✅ | [ai-suggest.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/ai-suggest.ts) |
|
||||||
|
| P1-1 | AiService 接口抽象 | ✅ | [types.ts](file:///e:/Desktop/CICD/src/modules/ai/types.ts) |
|
||||||
|
| P1-2 | 可复用 AI 组件 | ✅ | 9 个组件 |
|
||||||
|
| P1-3 | AI Error Boundary | ✅ | [ai-error-boundary.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-error-boundary.tsx) |
|
||||||
|
| P1-4 | 错题集 AI 集成 | ✅ | [ai-error-book-analysis.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-error-book-analysis.tsx) |
|
||||||
|
| P1-5 | 改题 AI 集成 | ✅ | [ai-grading-assist.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-grading-assist.tsx) |
|
||||||
|
| P1-6 | AI 使用监控 | ✅ | [usage-tracker.ts](file:///e:/Desktop/CICD/src/modules/ai/services/usage-tracker.ts) |
|
||||||
|
| P1-7 | 备课 AI 内容生成 | ✅ | [ai-lesson-content-generator.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-lesson-content-generator.tsx) |
|
||||||
|
| P2-4 | 题目变体生成 | ✅ | [ai-question-variant-generator.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-question-variant-generator.tsx) |
|
||||||
|
| P2-7 | 架构图同步 | ✅ | 004/005 文档 |
|
||||||
|
|
||||||
|
### 1.2 未完成项(V2 重点)
|
||||||
|
|
||||||
|
| 编号 | V1 改进项 | 状态 | 原因 |
|
||||||
|
|------|----------|------|------|
|
||||||
|
| P2-1 | 流式响应 | ❌ | V1 仅实现非流式 |
|
||||||
|
| P2-2 | AI 对话历史 | ❌ | 未持久化 |
|
||||||
|
| P2-3 | Prompt 可配置化 | ⚠️ | 模板已抽取但仍硬编码在 TS 文件中 |
|
||||||
|
| P2-5 | 多 Provider 对比 | ❌ | 未实现 |
|
||||||
|
| P2-6 | 内容安全过滤 | ❌ | 未实现 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、深度可用性走查(逐组件)
|
||||||
|
|
||||||
|
### 2.1 AiChatPanel — 通用聊天面板
|
||||||
|
|
||||||
|
**文件**:[ai-chat-panel.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-chat-panel.tsx)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 严重度 | 位置 | 行业对标 | 用户影响 |
|
||||||
|
|------|------|--------|------|---------|---------|
|
||||||
|
| U2.1.1 | **无流式响应** — 用户等待完整 AI 回复才看到内容 | P0 | L77-96 | Khanmigo/Duolingo 均使用 SSE 流式输出,逐 token 渲染 | 长文本(>500 字)等待 10-30 秒,用户以为卡死 |
|
||||||
|
| U2.1.2 | **无 Markdown 渲染** — AI 回复以纯文本显示 | P0 | L139 | 所有主流 AI 产品均渲染 Markdown(代码块、列表、表格) | AI 生成的代码、表格、列表无法正确显示,可读性极差 |
|
||||||
|
| U2.1.3 | **无复制按钮** — 用户无法复制 AI 回复 | P1 | L132-141 | ChatGPT/Claude 均提供 hover 复制按钮 | 教师想复用 AI 生成的内容需手动选择文本 |
|
||||||
|
| U2.1.4 | **无停止生成按钮** — 流式时无法中断 | P1 | — | Khanmigo 明确将 stop-generation 列为 K12 必备 | AI 生成不当内容时无法及时止损 |
|
||||||
|
| U2.1.5 | **无建议提示词** — 空状态无引导 | P1 | L119 | Khanmigo 首屏展示"试试问我..."建议 | 新用户不知道能问什么,首次使用门槛高 |
|
||||||
|
| U2.1.6 | **无清除对话按钮** — i18n 键 `chat.clear` 存在但无 UI | P1 | — | 所有聊天产品均有清空按钮 | 对话越来越长,上下文窗口爆满后 AI 回复质量下降 |
|
||||||
|
| U2.1.7 | **无对话历史持久化** — 刷新页面对话丢失 | P1 | L44 | Khanmigo 提供 chat history 面板 | 教师备课时生成的 AI 内容刷新即丢失 |
|
||||||
|
| U2.1.8 | **无 token/模型指示器** — 用户不知道用了哪个模型 | P2 | — | OpenAI PlayGround 显示模型与 token 用量 | 无法评估 AI 调用成本 |
|
||||||
|
| U2.1.9 | **aria-live 缺失** — 屏幕阅读器无法感知新消息 | P1 | L121 | WCAG 2.1 AA 要求 | 视障用户无法使用 |
|
||||||
|
|
||||||
|
### 2.2 AiGradingAssist — 批改辅助
|
||||||
|
|
||||||
|
**文件**:[ai-grading-assist.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-grading-assist.tsx)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 严重度 | 位置 | 行业对标 | 用户影响 |
|
||||||
|
|------|------|--------|------|---------|---------|
|
||||||
|
| U2.2.1 | **CardDescription 与 CardTitle 使用相同 i18n 键** | P0 | L97 `t("grading.title")` | — | 描述区域显示重复文字,UI 不专业 |
|
||||||
|
| U2.2.2 | **无批量批改** — 一次只能批改一题 | P1 | — | Khanmigo 的 student work summary 支持批量 | 教师批改 30 人 × 5 道主观题 = 150 次点击 |
|
||||||
|
| U2.2.3 | **无分数对比** — 不显示教师已给分数 vs AI 建议 | P1 | — | — | 教师无法快速判断 AI 建议是否合理 |
|
||||||
|
| U2.2.4 | **无置信度阈值配置** — 低置信度建议也直接展示 | P2 | L87 | — | confidence < 0.5 的建议可能误导教师 |
|
||||||
|
| U2.2.5 | **无 Socratic 模式** — 直接给分而非引导思考 | P2 | — | Khanmigo 的 Socratic 方法不直接给答案 | 教师过度依赖 AI,丧失独立判断 |
|
||||||
|
|
||||||
|
### 2.3 AiErrorBookAnalysis — 错题本分析
|
||||||
|
|
||||||
|
**文件**:[ai-error-book-analysis.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-error-book-analysis.tsx)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 严重度 | 位置 | 行业对标 | 用户影响 |
|
||||||
|
|------|------|--------|------|---------|---------|
|
||||||
|
| U2.3.1 | **无"立即练习"按钮** — 相似题生成后只能"选择" | P0 | L150-159 | Duolingo Max 的 "Explain My Answer" 后直接进入练习 | 学生看到相似题但无法直接作答,流程断裂 |
|
||||||
|
| U2.3.2 | **薄弱点分析不持久化** — 刷新即丢失 | P1 | L59 | Squirrel AI 持续追踪薄弱点变化趋势 | 无法追踪薄弱点改善进度 |
|
||||||
|
| U2.3.3 | **无 SM2 算法集成** — AI 相似题不进入复习队列 | P1 | — | Squirrel AI 的闭环:诊断→练习→复习→再诊断 | AI 生成的相似题是一次性的,无法形成学习闭环 |
|
||||||
|
| U2.3.4 | **无趋势可视化** — 薄弱点无历史趋势图 | P2 | — | Century Tech 的 dashboard 展示 mastery 进展 | 学生/家长无法看到进步 |
|
||||||
|
| U2.3.5 | **无难度递进** — 相似题难度不随掌握度调整 | P2 | L69 `count: 3` | Squirrel AI 的自适应难度 | 掌握度高的学生仍收到简单题,浪费时间 |
|
||||||
|
|
||||||
|
### 2.4 AiLessonContentGenerator — 备课内容生成
|
||||||
|
|
||||||
|
**文件**:[ai-lesson-content-generator.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-lesson-content-generator.tsx)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 严重度 | 位置 | 行业对标 | 用户影响 |
|
||||||
|
|------|------|--------|------|---------|---------|
|
||||||
|
| U2.4.1 | **CardDescription 与 CardTitle 使用相同 i18n 键** | P0 | L108 `t("lessonPrep.generateContent")` | — | 描述区域重复 |
|
||||||
|
| U2.4.2 | **附加上下文 label 使用错误键** | P0 | L131 `t("lessonPrep.generateContent")` | — | 标签显示"生成内容"而非"附加上下文" |
|
||||||
|
| U2.4.3 | **placeholder 使用错误键** | P0 | L137 `t("lessonPrep.generateContent")` | — | 占位符显示"生成内容" |
|
||||||
|
| U2.4.4 | **插入按钮使用错误键** | P0 | L178 `t("lessonPrep.generateContent")` | — | 按钮显示"生成内容"而非"插入内容" |
|
||||||
|
| U2.4.5 | **无内容预览/编辑** — 生成后直接插入 | P1 | L168-180 | Khanmigo 生成的内容可编辑后再插入 | 教师无法微调 AI 生成的内容 |
|
||||||
|
| U2.4.6 | **无生成历史** — 无法回看之前生成的内容 | P1 | — | Khanmigo 的 chat history | 教师生成了 5 段内容,只能保留最后 1 段 |
|
||||||
|
| U2.4.7 | **无课程标准对齐** — 生成内容不关联课标 | P2 | — | Khanmigo 与课程标准对齐 | 生成内容可能偏离教学大纲 |
|
||||||
|
|
||||||
|
### 2.5 AiQuestionVariantGenerator — 题目变体生成
|
||||||
|
|
||||||
|
**文件**:[ai-question-variant-generator.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-question-variant-generator.tsx)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 严重度 | 位置 | 行业对标 | 用户影响 |
|
||||||
|
|------|------|--------|------|---------|---------|
|
||||||
|
| U2.5.1 | **所有变体类型标签使用相同 i18n 键** | P0 | L87-89 全部 `t("exam.generate")` | — | 三个选项显示相同文字"生成",无法区分 |
|
||||||
|
| U2.5.2 | **无批量生成** — 一次只生成 1 个变体 | P1 | — | — | 教师需要 5 个变体需点击 5 次 |
|
||||||
|
| U2.5.3 | **无难度滑块** — different_difficulty 无法指定目标难度 | P1 | — | — | 教师无法控制变简单还是变难 |
|
||||||
|
| U2.5.4 | **无知识点映射展示** — 不显示变体覆盖的知识点 | P2 | — | Squirrel AI 的知识图谱可视化 | 教师无法验证变体是否覆盖目标知识点 |
|
||||||
|
|
||||||
|
### 2.6 AiSuggestionCard — 相似题建议卡片
|
||||||
|
|
||||||
|
**文件**:[ai-suggestion-card.tsx](file:///e:/Desktop/CICD/src/modules/ai/components/ai-suggestion-card.tsx)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 严重度 | 位置 | 行业对标 | 用户影响 |
|
||||||
|
|------|------|--------|------|---------|---------|
|
||||||
|
| U2.6.1 | **无难度筛选** — 所有难度混合展示 | P2 | — | — | 学生只想练习中等难度题时无法筛选 |
|
||||||
|
| U2.6.2 | **无"全部添加"按钮** — 需逐题选择 | P2 | — | — | 批量添加效率低 |
|
||||||
|
|
||||||
|
### 2.7 全局架构层面
|
||||||
|
|
||||||
|
| 编号 | 问题 | 严重度 | 行业对标 | 用户影响 |
|
||||||
|
|------|------|--------|---------|---------|
|
||||||
|
| U2.7.1 | **无全局 AI 助手入口** | P0 | Khanmigo 嵌入式助手 / Duolingo 角色触发 | 用户在非集成页面无法获取 AI 帮助 |
|
||||||
|
| U2.7.2 | **无上下文感知** | P0 | Khanmigo 自动感知当前学习内容 | AI 不知道用户当前在做什么,建议不精准 |
|
||||||
|
| U2.7.3 | **无内容安全过滤** | P0 | Khanmigo 多层 moderation + Duolingo 人工审核 | 学生可能接触不当内容,违反 COPPA/FERPA |
|
||||||
|
| U2.7.4 | **无家长 AI 功能** | P1 | Khanmigo 家长可见聊天记录 / Squirrel AI 24/7 家长面板 | 家长无法获取子女学情 AI 摘要 |
|
||||||
|
| U2.7.5 | **无管理员 AI 仪表盘** | P1 | Khanmigo district dashboard / Century Tech 全校视图 | 管理员无法监控 AI 使用量与成本 |
|
||||||
|
| U2.7.6 | **无学生学习路径** | P1 | Squirrel AI 纳米级知识图谱 / Century Tech nuggets | 学生缺少个性化学习引导 |
|
||||||
|
| U2.7.7 | **无每日交互限制** | P1 | Khanmigo 每日上限防止滥用 | 学生可能过度使用 AI 聊天偏离学习 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、行业标杆对标
|
||||||
|
|
||||||
|
### 3.1 竞品功能矩阵
|
||||||
|
|
||||||
|
| 能力 | Khanmigo | Duolingo Max | Squirrel AI | Century Tech | 本系统 V1 | 本系统 V2 目标 |
|
||||||
|
|------|----------|-------------|-------------|-------------|----------|--------------|
|
||||||
|
| **流式输出** | ✅ SSE | ✅ SSE | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
| **Markdown 渲染** | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
| **Socratic 模式** | ✅ 不直接给答案 | — | — | — | ❌ | ✅ |
|
||||||
|
| **内容安全过滤** | ✅ 多层 moderation | ✅ 人工+AI | ✅ 物理中心 | ✅ 教师监督 | ❌ | ✅ |
|
||||||
|
| **对话历史** | ✅ 可查看 | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
| **全局助手入口** | ✅ 嵌入式 | ✅ 角色触发 | ✅ 平台级 | ✅ Dashboard | ❌ | ✅ |
|
||||||
|
| **上下文感知** | ✅ 内容库集成 | ✅ 课程对齐 | ✅ 诊断驱动 | ✅ 自适应 | ❌ | ✅ |
|
||||||
|
| **学习路径推荐** | — | — | ✅ 纳米级 | ✅ nuggets | ❌ | ✅ |
|
||||||
|
| **家长面板** | ✅ 聊天记录可见 | — | ✅ 24/7 分析 | — | ❌ | ✅ |
|
||||||
|
| **管理员仪表盘** | ✅ district | — | ✅ | ✅ 全校 | ❌ | ✅ |
|
||||||
|
| **每日限制** | ✅ | — | — | — | ❌ | ✅ |
|
||||||
|
| **停止生成** | ✅ | ✅ | — | — | ❌ | ✅ |
|
||||||
|
| **批量批改** | ✅ student summary | — | — | ✅ 自标记 | ❌ | ✅ |
|
||||||
|
| **自适应难度** | — | ✅ | ✅ 核心 | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
|
### 3.2 关键差距分析
|
||||||
|
|
||||||
|
#### 差距 1:无流式响应(影响所有 AI 交互)
|
||||||
|
|
||||||
|
**行业做法**:
|
||||||
|
- Khanmigo 和 Duolingo Max 均使用 SSE 流式输出
|
||||||
|
- 逐 token 渲染模拟"打字效果",降低感知延迟
|
||||||
|
- 配合"停止生成"按钮,让用户可控
|
||||||
|
|
||||||
|
**我们的差距**:
|
||||||
|
- 所有 AI 调用等待完整响应才返回
|
||||||
|
- 长文本生成时用户看到的是空白 + loading spinner
|
||||||
|
- 无法中断不当内容生成
|
||||||
|
|
||||||
|
**影响**:用户体验差,长文本等待 10-30 秒,学生误以为系统卡死
|
||||||
|
|
||||||
|
#### 差距 2:无内容安全过滤(影响学生侧)
|
||||||
|
|
||||||
|
**行业做法**(Khanmigo 多层防护):
|
||||||
|
1. **输入过滤**:Moderation API 分类用户输入,拦截暴力/自残/色情/PII
|
||||||
|
2. **输出过滤**:AI 回复展示前扫描
|
||||||
|
3. **行为限制**:每日交互上限
|
||||||
|
4. **透明审计**:所有聊天记录对家长/教师可见
|
||||||
|
5. **自动告警**:moderation 触发时邮件通知成人
|
||||||
|
6. **访问控制**:未成年人仅通过家长/学区订阅
|
||||||
|
|
||||||
|
**我们的差距**:
|
||||||
|
- 学生可直接调用 AI 聊天,无任何过滤
|
||||||
|
- 无每日限制
|
||||||
|
- 无聊天记录审计
|
||||||
|
- 无不当内容告警
|
||||||
|
|
||||||
|
**影响**:违反 COPPA/FERPA 合规要求;学生可能接触不当内容;学校无法审计 AI 使用
|
||||||
|
|
||||||
|
#### 差距 3:无全局 AI 助手入口
|
||||||
|
|
||||||
|
**行业做法**:
|
||||||
|
- Khanmigo:嵌入式聊天集成在教师/学生 dashboard 中
|
||||||
|
- Duolingo Max:角色图标触发(Lin, Eddy 等角色)
|
||||||
|
- 通用模式:右下角悬浮按钮 → 侧边抽屉
|
||||||
|
|
||||||
|
**我们的差距**:
|
||||||
|
- AI 仅嵌入在 4 个特定页面(备课/错题/试卷/批改)
|
||||||
|
- 用户在其他页面无法获取 AI 帮助
|
||||||
|
- 无上下文感知(AI 不知道用户当前页面)
|
||||||
|
|
||||||
|
**影响**:AI 使用率低;用户在需要时找不到 AI 入口
|
||||||
|
|
||||||
|
#### 差距 4:无学习路径推荐
|
||||||
|
|
||||||
|
**行业做法**:
|
||||||
|
- Squirrel AI:纳米级知识分解(10,000+ 节点),诊断驱动路径
|
||||||
|
- Century Tech:nuggets 微内容 + 自适应路径
|
||||||
|
- 共同点:诊断 → 路径 → 练习 → 复习 → 再诊断的闭环
|
||||||
|
|
||||||
|
**我们的差距**:
|
||||||
|
- 错题本 AI 分析是一次性的,不持久化
|
||||||
|
- AI 生成的相似题不进入 SM2 复习队列
|
||||||
|
- 无知识图谱可视化
|
||||||
|
- 无自适应难度
|
||||||
|
|
||||||
|
**影响**:AI 价值未形成闭环;学生缺少个性化学习引导
|
||||||
|
|
||||||
|
#### 差距 5:无家长/管理员 AI 功能
|
||||||
|
|
||||||
|
**行业做法**:
|
||||||
|
- Khanmigo:家长可查看子女聊天记录;学区管理员有 dashboard
|
||||||
|
- Squirrel AI:24/7 家长分析面板
|
||||||
|
- Century Tech:全校课程覆盖视图
|
||||||
|
|
||||||
|
**我们的差距**:
|
||||||
|
- 家长端无任何 AI 功能
|
||||||
|
- 管理员无 AI 使用统计
|
||||||
|
- 无成本监控
|
||||||
|
|
||||||
|
**影响**:家长无法获取子女学情 AI 摘要;管理员无法优化 AI 使用策略
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、V2 改进优先级
|
||||||
|
|
||||||
|
### P0(紧急 — 影响安全与核心体验)
|
||||||
|
|
||||||
|
| 编号 | 改进项 | 对标 | 实现方向 |
|
||||||
|
|------|--------|------|---------|
|
||||||
|
| V2-P0-1 | **流式响应(SSE)** | Khanmigo/Duolingo | 新增 `aiChatStreamAction` + EventSource API + 停止生成按钮 |
|
||||||
|
| V2-P0-2 | **Markdown 渲染** | 所有竞品 | 引入 `react-markdown` + `remark-gfm`,AI 回复渲染为富文本 |
|
||||||
|
| V2-P0-3 | **内容安全过滤** | Khanmigo 多层防护 | 输入/输出双层过滤 + 每日限制 + 学生侧 Socratic 模式 |
|
||||||
|
| V2-P0-4 | **全局 AI 助手悬浮按钮** | Khanmigo 嵌入式 | 右下角悬浮按钮 → 侧边抽屉,上下文感知 |
|
||||||
|
| V2-P0-5 | **修复 i18n 键错误** | — | 修复 AiGradingAssist/AiLessonContentGenerator/AiQuestionVariantGenerator 中重复/错误键 |
|
||||||
|
| V2-P0-6 | **复制按钮 + 清除对话** | ChatGPT/Claude | AiChatPanel 增加 hover 复制 + 清除对话按钮 |
|
||||||
|
| V2-P0-7 | **建议提示词** | Khanmigo | 空状态展示角色相关的建议问题 |
|
||||||
|
| V2-P0-8 | **aria-live 无障碍** | WCAG 2.1 AA | 消息列表添加 `aria-live="polite"` |
|
||||||
|
|
||||||
|
### P1(重要 — 影响功能完整性)
|
||||||
|
|
||||||
|
| 编号 | 改进项 | 对标 | 实现方向 |
|
||||||
|
|------|--------|------|---------|
|
||||||
|
| V2-P1-1 | **AI 对话历史持久化** | Khanmigo | localStorage 存储最近 20 条对话 + 历史面板 |
|
||||||
|
| V2-P1-2 | **家长 AI 学情摘要** | Khanmigo 家长面板 / Squirrel AI | 新增 `AiChildSummary` 组件 + `generateChildSummaryAction` |
|
||||||
|
| V2-P1-3 | **管理员 AI 使用统计** | Khanmigo district / Century Tech | 新增 `AiUsageDashboard` 组件 + `getAiUsageStatsAction` |
|
||||||
|
| V2-P1-4 | **学生学习路径推荐** | Squirrel AI / Century Tech | 新增 `AiStudyPath` 组件 + `recommendStudyPathAction` |
|
||||||
|
| V2-P1-5 | **错题相似题"立即练习"** | Duolingo Max | AiErrorBookAnalysis 增加"练习"按钮,进入答题流程 |
|
||||||
|
| V2-P1-6 | **备课内容预览/编辑** | Khanmigo | AiLessonContentGenerator 生成后可编辑再插入 |
|
||||||
|
| V2-P1-7 | **批量 AI 批改** | Khanmigo student summary | 新增 `AiBatchGradingAssist` 组件 |
|
||||||
|
| V2-P1-8 | **每日交互限制** | Khanmigo | Server Action 层按用户+日期计数,超限返回 429 |
|
||||||
|
|
||||||
|
### P2(优化 — 提升体验与扩展性)
|
||||||
|
|
||||||
|
| 编号 | 改进项 | 对标 | 实现方向 |
|
||||||
|
|------|--------|------|---------|
|
||||||
|
| V2-P2-1 | **自适应难度** | Squirrel AI | 相似题难度根据 masteryLevel 动态调整 |
|
||||||
|
| V2-P2-2 | **薄弱点趋势可视化** | Century Tech | 薄弱点历史趋势图 |
|
||||||
|
| V2-P2-3 | **知识点映射展示** | Squirrel AI 知识图谱 | 变体生成后展示覆盖的知识点 |
|
||||||
|
| V2-P2-4 | **多 Provider 对比** | — | 同一 Prompt 并行调用多 Provider |
|
||||||
|
| V2-P2-5 | **Prompt 可配置化** | — | Prompt 模板存入数据库,支持版本管理 |
|
||||||
|
| V2-P2-6 | **token/模型指示器** | OpenAI PlayGround | AiChatPanel 显示模型与 token 用量 |
|
||||||
|
| V2-P2-7 | **Socratic 模式** | Khanmigo | 学生侧 AI 不直接给答案,引导思考 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、用户旅程分析(多角色)
|
||||||
|
|
||||||
|
### 5.1 教师旅程
|
||||||
|
|
||||||
|
**场景**:张老师要批改 30 名学生的语文主观题作业
|
||||||
|
|
||||||
|
**当前流程(V1)**:
|
||||||
|
1. 进入作业批改页 → 看到学生列表
|
||||||
|
2. 点击学生 A → 看到主观题答案
|
||||||
|
3. 点击"AI 批改建议" → 等待 5 秒 → 看到 AI 建议
|
||||||
|
4. 点击"应用分数" → 点击"应用反馈"
|
||||||
|
5. 点击下一个学生 → 重复 2-4
|
||||||
|
6. **总计**:30 学生 × 3 题 × 4 次点击 = 360 次点击
|
||||||
|
|
||||||
|
**行业最佳实践(Khanmigo)**:
|
||||||
|
1. 进入批改页 → AI 自动扫描所有学生答案
|
||||||
|
2. AI 批量生成评分建议(student work summary)
|
||||||
|
3. 教师查看汇总,快速确认/调整
|
||||||
|
4. **总计**:1 次批量生成 + 30 次确认 = 31 次点击
|
||||||
|
|
||||||
|
**差距**:缺少批量批改能力,效率差 10 倍
|
||||||
|
|
||||||
|
### 5.2 学生旅程
|
||||||
|
|
||||||
|
**场景**:李同学做错了一道数学题,想针对性练习
|
||||||
|
|
||||||
|
**当前流程(V1)**:
|
||||||
|
1. 进入错题本 → 看到错题列表
|
||||||
|
2. 点击错题 → 打开详情对话框
|
||||||
|
3. 点击"AI 智能分析" → 等待 → 看到相似题
|
||||||
|
4. 点击"选择" → 相似题... 然后呢?**流程断裂**
|
||||||
|
5. 无法直接练习相似题
|
||||||
|
|
||||||
|
**行业最佳实践(Duolingo Max)**:
|
||||||
|
1. 做错题 → "Explain My Answer" 按钮
|
||||||
|
2. AI 解释为什么错 → 直接进入"再练一题"
|
||||||
|
3. 相似题难度自适应 → 形成学习闭环
|
||||||
|
|
||||||
|
**差距**:相似题生成后无法直接练习,无自适应难度,无学习闭环
|
||||||
|
|
||||||
|
### 5.3 家长旅程
|
||||||
|
|
||||||
|
**场景**:王家长想了解子女近期学习情况
|
||||||
|
|
||||||
|
**当前流程(V1)**:
|
||||||
|
1. 进入家长 dashboard → 看到成绩/考勤
|
||||||
|
2. **无任何 AI 功能**
|
||||||
|
3. 需手动翻阅各科成绩自行分析
|
||||||
|
|
||||||
|
**行业最佳实践(Squirrel AI)**:
|
||||||
|
1. 家长面板 → AI 自动生成子女学情摘要
|
||||||
|
2. AI 识别薄弱点 → 给出家庭辅导建议
|
||||||
|
3. 24/7 可查看详细分析
|
||||||
|
|
||||||
|
**差距**:家长端完全无 AI 能力
|
||||||
|
|
||||||
|
### 5.4 管理员旅程
|
||||||
|
|
||||||
|
**场景**:赵校长想了解全校 AI 使用情况
|
||||||
|
|
||||||
|
**当前流程(V1)**:
|
||||||
|
1. **无任何 AI 管理功能**
|
||||||
|
2. 无法知道哪些教师在用 AI
|
||||||
|
3. 无法知道 AI 成本
|
||||||
|
4. 无法知道 AI 效果
|
||||||
|
|
||||||
|
**行业最佳实践(Khanmigo district)**:
|
||||||
|
1. 管理员 dashboard → AI 使用量趋势
|
||||||
|
2. 按教师/学科/班级分解
|
||||||
|
3. 成本统计 + 异常告警
|
||||||
|
|
||||||
|
**差距**:管理员完全无 AI 可见性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、V2 实现方案
|
||||||
|
|
||||||
|
### 6.1 流式响应架构
|
||||||
|
|
||||||
|
```
|
||||||
|
客户端 (EventSource)
|
||||||
|
└─▶ POST /api/ai/chat/stream (SSE Route)
|
||||||
|
└─▶ aiChatStreamAction (Server Action)
|
||||||
|
└─▶ AiService.chatStream() (返回 AsyncGenerator)
|
||||||
|
└─▶ createAiChatCompletionStream() (OpenAI SDK stream: true)
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键设计**:
|
||||||
|
- 使用 Server-Sent Events(SSE)而非 WebSocket(单向足够,更简单)
|
||||||
|
- 客户端用 `fetch` + `ReadableStream` 消费(EventSource 不支持 POST)
|
||||||
|
- 支持 `AbortController` 中断生成
|
||||||
|
- 流式完成后 `withAiTracking` 记录完整 token 用量
|
||||||
|
|
||||||
|
### 6.2 全局 AI 助手架构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/(dashboard)/layout.tsx
|
||||||
|
└─▶ <AiAssistantWidget /> (全局悬浮按钮)
|
||||||
|
├─▶ usePathname() 感知当前页面
|
||||||
|
├─▶ 根据路由推断上下文(如 /teacher/homework → 批改上下文)
|
||||||
|
└─▶ 侧边抽屉 <AiChatPanel>
|
||||||
|
├─▶ systemPrompt 根据上下文动态生成
|
||||||
|
└─▶ contextMessage 注入当前页面信息
|
||||||
|
```
|
||||||
|
|
||||||
|
**上下文感知规则**:
|
||||||
|
| 路由模式 | 上下文 | systemPrompt |
|
||||||
|
|---------|--------|-------------|
|
||||||
|
| `/teacher/homework/*` | 作业批改 | "You are a grading assistant..." |
|
||||||
|
| `/teacher/lesson-plans/*` | 备课 | "You are a lesson planning assistant..." |
|
||||||
|
| `/teacher/exams/*` | 试卷 | "You are an exam design assistant..." |
|
||||||
|
| `/student/error-book/*` | 错题本 | "You are a study tutor. Use Socratic method..." |
|
||||||
|
| `/student/homework/*` | 做作业 | "You are a homework helper. Don't give direct answers..." |
|
||||||
|
| `/parent/*` | 家长面板 | "You are a family education advisor..." |
|
||||||
|
|
||||||
|
### 6.3 内容安全过滤架构
|
||||||
|
|
||||||
|
```
|
||||||
|
aiChatAction (Server Action)
|
||||||
|
├─▶ 1. 输入过滤:filterUserInput(messages)
|
||||||
|
│ └─▶ 检查关键词/PII/不当内容 → 拦截返回错误
|
||||||
|
├─▶ 2. 每日限制:checkDailyLimit(userId)
|
||||||
|
│ └─▶ 超限返回 429
|
||||||
|
├─▶ 3. 调用 AI:service.chat()
|
||||||
|
├─▶ 4. 输出过滤:filterAiOutput(content)
|
||||||
|
│ └─▶ 扫描不当内容 → 替换/拦截
|
||||||
|
└─▶ 5. 记录审计:logAiInteraction(userId, messages, response)
|
||||||
|
```
|
||||||
|
|
||||||
|
**学生侧额外限制**:
|
||||||
|
- Socratic 模式:system prompt 强制不直接给答案
|
||||||
|
- 每日上限:50 条消息(可配置)
|
||||||
|
- 关键词过滤:暴力、自残、色情、PII
|
||||||
|
|
||||||
|
### 6.4 i18n 新增键结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chat": {
|
||||||
|
"streaming": "AI is typing...",
|
||||||
|
"stopGeneration": "Stop generating",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"clearConfirm": "Clear all messages?",
|
||||||
|
"suggestedPrompts": {
|
||||||
|
"teacher": ["Help me grade this", "Generate a lesson activity", "Create a quiz question"],
|
||||||
|
"student": ["Explain this concept", "Give me a practice question", "Help me study"],
|
||||||
|
"parent": ["How is my child doing?", "What should I focus on at home?"],
|
||||||
|
"admin": ["Show AI usage stats", "Which teachers use AI most?"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"safety": {
|
||||||
|
"blocked": "Your message was blocked by safety filter",
|
||||||
|
"dailyLimit": "Daily AI usage limit reached. Please try again tomorrow.",
|
||||||
|
"studentMode": "AI is in student mode. It will guide you to find the answer."
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"summary": "AI Learning Summary",
|
||||||
|
"generateSummary": "Generate Summary",
|
||||||
|
"weaknessHint": "Areas to focus on",
|
||||||
|
"suggestion": "Family tutoring suggestion"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"usageDashboard": "AI Usage Dashboard",
|
||||||
|
"totalCalls": "Total AI Calls",
|
||||||
|
"activeUsers": "Active Users",
|
||||||
|
"costEstimate": "Estimated Cost",
|
||||||
|
"topUsers": "Top Users",
|
||||||
|
"byCapability": "By Capability"
|
||||||
|
},
|
||||||
|
"studyPath": {
|
||||||
|
"title": "Your Learning Path",
|
||||||
|
"nextSteps": "Recommended Next Steps",
|
||||||
|
"mastered": "Mastered",
|
||||||
|
"inProgress": "In Progress",
|
||||||
|
"needsWork": "Needs Work"
|
||||||
|
},
|
||||||
|
"lessonPrep": {
|
||||||
|
"additionalContext": "Additional context",
|
||||||
|
"additionalContextPlaceholder": "Add any specific requirements...",
|
||||||
|
"insertContent": "Insert Content",
|
||||||
|
"editBeforeInsert": "Edit before insert"
|
||||||
|
},
|
||||||
|
"exam": {
|
||||||
|
"variantType": {
|
||||||
|
"same_knowledge_point": "Same knowledge point, different context",
|
||||||
|
"different_difficulty": "Different difficulty",
|
||||||
|
"different_format": "Different format"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、架构图同步说明
|
||||||
|
|
||||||
|
V2 实现后需在 004/005 文档中新增以下节点:
|
||||||
|
|
||||||
|
### 7.1 新增导出
|
||||||
|
|
||||||
|
| 文档 | 节点 | 内容 |
|
||||||
|
|------|------|------|
|
||||||
|
| 005 | `modules.ai.exports.functions` | 新增 `aiChatStreamAction`、`generateChildSummaryAction`、`getAiUsageStatsAction`、`recommendStudyPathAction` |
|
||||||
|
| 005 | `modules.ai.exports.components` | 新增 `AiAssistantWidget`、`AiMarkdownRenderer`、`AiChildSummary`、`AiUsageDashboard`、`AiStudyPath`、`AiBatchGradingAssist` |
|
||||||
|
| 005 | `modules.ai.exports.services` | 新增 `filterUserInput`、`filterAiOutput`、`checkDailyLimit`、`logAiInteraction` |
|
||||||
|
| 004 | AI 模块章节 | 新增 V2 组件清单与安全过滤说明 |
|
||||||
|
|
||||||
|
### 7.2 新增路由
|
||||||
|
|
||||||
|
| 文档 | 节点 | 内容 |
|
||||||
|
|------|------|------|
|
||||||
|
| 005 | `routes` | 新增 `/api/ai/chat/stream`(SSE 端点) |
|
||||||
|
|
||||||
|
### 7.3 新增依赖
|
||||||
|
|
||||||
|
| 文档 | 节点 | 内容 |
|
||||||
|
|------|------|------|
|
||||||
|
| 005 | `dependencyMatrix` | `parent → ai`、`dashboard → ai`(全局 widget) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、总结
|
||||||
|
|
||||||
|
V1 完成了 AI 模块的基础架构与四大业务场景集成,但在**用户体验深度**、**安全合规**、**多角色覆盖**三个方面与行业标杆存在显著差距。
|
||||||
|
|
||||||
|
V2 的核心目标是:
|
||||||
|
1. **补齐流式 + Markdown + 安全过滤**三大基础体验
|
||||||
|
2. **新增全局助手 + 上下文感知**提升 AI 可达性
|
||||||
|
3. **覆盖家长 + 管理员**两个缺失角色
|
||||||
|
4. **实现学习路径推荐**形成学习闭环
|
||||||
|
5. **修复 i18n 键错误**消除 UI 缺陷
|
||||||
|
|
||||||
|
实现后,AI 模块将达到 Khanmigo 级别的功能完整度,满足 K12 教育场景的安全合规要求。
|
||||||
@@ -1,6 +1,34 @@
|
|||||||
import { AppSidebar } from "@/modules/layout/components/app-sidebar"
|
import { AppSidebar } from "@/modules/layout/components/app-sidebar"
|
||||||
import { SidebarProvider } from "@/modules/layout/components/sidebar-provider"
|
import { SidebarProvider } from "@/modules/layout/components/sidebar-provider"
|
||||||
import { SiteHeader } from "@/modules/layout/components/site-header"
|
import { SiteHeader } from "@/modules/layout/components/site-header"
|
||||||
|
import {
|
||||||
|
AiClientProvider,
|
||||||
|
} from "@/modules/ai/context/ai-client-provider"
|
||||||
|
import { AiAssistantWidget } from "@/modules/ai/components/ai-assistant-widget"
|
||||||
|
import {
|
||||||
|
aiChatAction,
|
||||||
|
suggestSimilarQuestionsAction,
|
||||||
|
suggestGradingAction,
|
||||||
|
generateLessonContentAction,
|
||||||
|
generateQuestionVariantAction,
|
||||||
|
analyzeWeaknessAction,
|
||||||
|
generateChildSummaryAction,
|
||||||
|
recommendStudyPathAction,
|
||||||
|
getAiUsageStatsAction,
|
||||||
|
} from "@/modules/ai/actions"
|
||||||
|
import type { AiClientService } from "@/modules/ai/types"
|
||||||
|
|
||||||
|
const aiClientService: AiClientService = {
|
||||||
|
chat: aiChatAction,
|
||||||
|
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||||
|
suggestGrading: suggestGradingAction,
|
||||||
|
generateLessonContent: generateLessonContentAction,
|
||||||
|
generateQuestionVariant: generateQuestionVariantAction,
|
||||||
|
analyzeWeakness: analyzeWeaknessAction,
|
||||||
|
generateChildSummary: generateChildSummaryAction,
|
||||||
|
recommendStudyPath: recommendStudyPathAction,
|
||||||
|
getAiUsageStats: getAiUsageStatsAction,
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -8,14 +36,17 @@ export default function DashboardLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider sidebar={<AppSidebar />}>
|
<AiClientProvider service={aiClientService}>
|
||||||
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-background focus:text-foreground focus:border focus:border-border focus:rounded-md focus:m-2">
|
<SidebarProvider sidebar={<AppSidebar />}>
|
||||||
Skip to main content
|
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-background focus:text-foreground focus:border focus:border-border focus:rounded-md focus:m-2">
|
||||||
</a>
|
Skip to main content
|
||||||
<SiteHeader />
|
</a>
|
||||||
<main id="main-content" className="flex-1 overflow-auto p-6">
|
<SiteHeader />
|
||||||
{children}
|
<main id="main-content" className="flex-1 overflow-auto p-6">
|
||||||
</main>
|
{children}
|
||||||
</SidebarProvider>
|
</main>
|
||||||
|
<AiAssistantWidget />
|
||||||
|
</SidebarProvider>
|
||||||
|
</AiClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
182
src/app/api/ai/chat/stream/route.ts
Normal file
182
src/app/api/ai/chat/stream/route.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { NextRequest } from "next/server"
|
||||||
|
|
||||||
|
import { auth } from "@/auth"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
|
import { createAiChatCompletionStream } from "@/shared/lib/ai/client"
|
||||||
|
import { getAiErrorMessage } from "@/shared/lib/ai"
|
||||||
|
import { trackEvent } from "@/shared/lib/track-event"
|
||||||
|
import { env } from "@/env.mjs"
|
||||||
|
|
||||||
|
import { CHAT_SYSTEM_PROMPT } from "@/modules/ai/services/prompt-templates"
|
||||||
|
import {
|
||||||
|
filterUserInput,
|
||||||
|
filterAiOutput,
|
||||||
|
checkDailyLimit,
|
||||||
|
incrementDailyUsage,
|
||||||
|
} from "@/modules/ai/services/content-safety"
|
||||||
|
import type { AiChatMessage } from "@/modules/ai/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 聊天流式端点(SSE)
|
||||||
|
*
|
||||||
|
* 使用 Server-Sent Events 逐 token 推送 AI 回复,
|
||||||
|
* 降低用户感知延迟。
|
||||||
|
*
|
||||||
|
* 安全:
|
||||||
|
* - requirePermission(AI_CHAT) 权限校验
|
||||||
|
* - 输入/输出内容安全过滤
|
||||||
|
* - 每日交互限制
|
||||||
|
* - 学生侧 Socratic 模式
|
||||||
|
*/
|
||||||
|
|
||||||
|
const formatEvent = (data: unknown): string => {
|
||||||
|
return `data: ${JSON.stringify(data)}\n\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatError = (message: string): string => {
|
||||||
|
return formatEvent({ type: "error", message })
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMAT_DONE = "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 权限校验
|
||||||
|
const ctx = await requirePermission(Permissions.AI_CHAT)
|
||||||
|
const session = await auth()
|
||||||
|
const userRole = session?.user?.role ?? "student"
|
||||||
|
const isStudent = userRole === "student"
|
||||||
|
|
||||||
|
// 2. 每日限制
|
||||||
|
const limitCheck = checkDailyLimit(ctx.userId, userRole)
|
||||||
|
if (limitCheck.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. 输入安全过滤
|
||||||
|
for (const msg of body.messages) {
|
||||||
|
if (msg.role === "user") {
|
||||||
|
const filterResult = filterUserInput(msg.content, { isStudent })
|
||||||
|
if (filterResult.blocked) {
|
||||||
|
return new Response(formatError("Input blocked by safety filter"), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
const messages: AiChatMessage[] = [
|
||||||
|
{ role: "system", content: studentSystemPrompt },
|
||||||
|
...body.messages,
|
||||||
|
]
|
||||||
|
|
||||||
|
// 6. 流式调用 AI
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
async start(controller) {
|
||||||
|
const startTime = Date.now()
|
||||||
|
let fullContent = ""
|
||||||
|
let success = true
|
||||||
|
let errorMessage: string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aiStream = createAiChatCompletionStream({
|
||||||
|
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
||||||
|
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
|
||||||
|
temperature: 0.7,
|
||||||
|
...(body.providerId ? { providerId: body.providerId } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
for await (const chunk of aiStream) {
|
||||||
|
fullContent += chunk
|
||||||
|
|
||||||
|
// 输出安全过滤(逐 chunk 检查关键词)
|
||||||
|
const outputFilter = filterAiOutput(chunk, { isStudent })
|
||||||
|
if (outputFilter.blocked) {
|
||||||
|
controller.enqueue(encoder.encode(formatEvent({
|
||||||
|
type: "filtered",
|
||||||
|
message: "Content filtered for safety",
|
||||||
|
})))
|
||||||
|
success = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.enqueue(encoder.encode(formatEvent({ type: "token", content: chunk })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加每日使用计数
|
||||||
|
incrementDailyUsage(ctx.userId)
|
||||||
|
|
||||||
|
controller.enqueue(encoder.encode(FORMAT_DONE))
|
||||||
|
} catch (error) {
|
||||||
|
success = false
|
||||||
|
errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
controller.enqueue(encoder.encode(formatError(getAiErrorMessage(error))))
|
||||||
|
} finally {
|
||||||
|
controller.close()
|
||||||
|
|
||||||
|
// 埋点
|
||||||
|
void trackEvent({
|
||||||
|
event: "ai.chat_stream",
|
||||||
|
userId: ctx.userId,
|
||||||
|
targetType: "chat",
|
||||||
|
properties: {
|
||||||
|
success,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
tokenCount: fullContent.length / 4,
|
||||||
|
errorMessage,
|
||||||
|
isStudent,
|
||||||
|
},
|
||||||
|
}).catch(() => {
|
||||||
|
// 静默失败
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PermissionDeniedError) {
|
||||||
|
return new Response(formatError("Permission denied"), {
|
||||||
|
status: 403,
|
||||||
|
headers: { "Content-Type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return new Response(formatError(getAiErrorMessage(error)), {
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
QuestionVariantInputSchema,
|
QuestionVariantInputSchema,
|
||||||
SimilarQuestionInputSchema,
|
SimilarQuestionInputSchema,
|
||||||
WeaknessAnalysisInputSchema,
|
WeaknessAnalysisInputSchema,
|
||||||
|
ChildSummaryInputSchema,
|
||||||
|
StudyPathInputSchema,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
import type {
|
import type {
|
||||||
AiChatMessage,
|
AiChatMessage,
|
||||||
@@ -28,6 +30,11 @@ import type {
|
|||||||
SimilarQuestionResult,
|
SimilarQuestionResult,
|
||||||
WeaknessAnalysisInput,
|
WeaknessAnalysisInput,
|
||||||
WeaknessAnalysisResult,
|
WeaknessAnalysisResult,
|
||||||
|
ChildSummaryInput,
|
||||||
|
ChildSummaryResult,
|
||||||
|
StudyPathInput,
|
||||||
|
StudyPathResult,
|
||||||
|
AiUsageStats,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -242,3 +249,94 @@ export async function analyzeWeaknessAction(
|
|||||||
return { success: false, message: t("error.analysisFailed") }
|
return { success: false, message: t("error.analysisFailed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 家长 AI 学情摘要
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function generateChildSummaryAction(
|
||||||
|
input: ChildSummaryInput
|
||||||
|
): Promise<ActionState<ChildSummaryResult>> {
|
||||||
|
const t = await getTranslations("ai")
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.AI_CHAT)
|
||||||
|
const parsed = ChildSummaryInputSchema.safeParse(input)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, message: t("error.invalidInput") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createAiService(ctx.userId)
|
||||||
|
const result = await safeAiCall(() => service.generateChildSummary(parsed.data))
|
||||||
|
if (!result.ok) {
|
||||||
|
return { success: false, message: result.message }
|
||||||
|
}
|
||||||
|
return { success: true, data: result.data }
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PermissionDeniedError) {
|
||||||
|
return { success: false, message: error.message }
|
||||||
|
}
|
||||||
|
return { success: false, message: t("parent.error") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 学生学习路径推荐
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function recommendStudyPathAction(
|
||||||
|
input: StudyPathInput
|
||||||
|
): Promise<ActionState<StudyPathResult>> {
|
||||||
|
const t = await getTranslations("ai")
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.AI_CHAT)
|
||||||
|
const parsed = StudyPathInputSchema.safeParse(input)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, message: t("error.invalidInput") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createAiService(ctx.userId)
|
||||||
|
const result = await safeAiCall(() => service.recommendStudyPath(parsed.data))
|
||||||
|
if (!result.ok) {
|
||||||
|
return { success: false, message: result.message }
|
||||||
|
}
|
||||||
|
return { success: true, data: result.data }
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PermissionDeniedError) {
|
||||||
|
return { success: false, message: error.message }
|
||||||
|
}
|
||||||
|
return { success: false, message: t("studyPath.error") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 管理员 AI 使用统计
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function getAiUsageStatsAction(): Promise<ActionState<AiUsageStats>> {
|
||||||
|
const t = await getTranslations("ai")
|
||||||
|
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: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: stats }
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PermissionDeniedError) {
|
||||||
|
return { success: false, message: error.message }
|
||||||
|
}
|
||||||
|
return { success: false, message: t("error.chatFailed") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
217
src/modules/ai/components/ai-assistant-widget.tsx
Normal file
217
src/modules/ai/components/ai-assistant-widget.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Bot, X } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/shared/components/ui/sheet"
|
||||||
|
import { AiChatPanel } from "./ai-chat-panel"
|
||||||
|
import { useAiClientOptional } from "../context/ai-client-provider"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上下文感知规则
|
||||||
|
*
|
||||||
|
* 根据当前路由推断用户上下文,动态生成 systemPrompt 和 contextMessage。
|
||||||
|
*/
|
||||||
|
type AiContextConfig = {
|
||||||
|
systemPrompt: string
|
||||||
|
contextMessage: string
|
||||||
|
suggestedPrompts?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局 AI 助手悬浮按钮
|
||||||
|
*
|
||||||
|
* 参考 Khanmigo 嵌入式助手模式:
|
||||||
|
* - 右下角悬浮按钮,任何页面可见
|
||||||
|
* - 点击打开侧边抽屉,内嵌 AiChatPanel
|
||||||
|
* - 上下文感知:根据当前路由自动推断用户场景
|
||||||
|
* - 流式响应 + Markdown 渲染
|
||||||
|
*
|
||||||
|
* 使用:
|
||||||
|
* 在 dashboard layout 中引入即可全局生效。
|
||||||
|
* 需要 AiClientProvider 包裹(可选,未注入时按钮不显示)。
|
||||||
|
*/
|
||||||
|
export function AiAssistantWidget(): React.ReactNode {
|
||||||
|
const t = useTranslations("ai")
|
||||||
|
const pathname = usePathname()
|
||||||
|
const aiClient = useAiClientOptional()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
// 根据路由推断上下文
|
||||||
|
const contextConfig = useMemo<AiContextConfig>(() => {
|
||||||
|
return inferContextFromPath(pathname, t)
|
||||||
|
}, [pathname, t])
|
||||||
|
|
||||||
|
// 如果未注入 AI 客户端服务,不显示悬浮按钮
|
||||||
|
if (!aiClient) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
className="fixed bottom-6 right-6 z-50 h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-shadow"
|
||||||
|
aria-label={t("widget.open")}
|
||||||
|
>
|
||||||
|
<Bot className="h-6 w-6" />
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className="w-full sm:max-w-[440px] overflow-y-auto p-0">
|
||||||
|
<SheetHeader className="px-4 py-3 border-b">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
<Bot className="h-4 w-4 text-primary" />
|
||||||
|
{t("widget.title")}
|
||||||
|
</SheetTitle>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label={t("widget.close")}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("widget.contextAware")}</p>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="p-4">
|
||||||
|
<AiChatPanel
|
||||||
|
systemPrompt={contextConfig.systemPrompt}
|
||||||
|
contextMessage={contextConfig.contextMessage}
|
||||||
|
suggestedPrompts={contextConfig.suggestedPrompts}
|
||||||
|
maxMessages={30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据路由推断 AI 上下文
|
||||||
|
*/
|
||||||
|
function inferContextFromPath(
|
||||||
|
pathname: string,
|
||||||
|
t: ReturnType<typeof useTranslations>
|
||||||
|
): AiContextConfig {
|
||||||
|
// 教师批改
|
||||||
|
if (pathname.includes("/teacher/homework/submissions")) {
|
||||||
|
return {
|
||||||
|
systemPrompt:
|
||||||
|
"You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
|
||||||
|
contextMessage: "Current page: Homework grading view",
|
||||||
|
suggestedPrompts: [
|
||||||
|
t("chat.suggestedPrompts.teacher.0"),
|
||||||
|
"What are common mistakes in this type of question?",
|
||||||
|
"How should I give constructive feedback?",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 教师备课
|
||||||
|
if (pathname.includes("/teacher/lesson-plans")) {
|
||||||
|
return {
|
||||||
|
systemPrompt:
|
||||||
|
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
|
||||||
|
contextMessage: "Current page: Lesson plan editor",
|
||||||
|
suggestedPrompts: [
|
||||||
|
t("chat.suggestedPrompts.teacher.1"),
|
||||||
|
"Suggest a hook for this lesson",
|
||||||
|
"What are some differentiation strategies?",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 教师试卷
|
||||||
|
if (pathname.includes("/teacher/exams")) {
|
||||||
|
return {
|
||||||
|
systemPrompt:
|
||||||
|
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
|
||||||
|
contextMessage: "Current page: Exam builder",
|
||||||
|
suggestedPrompts: [
|
||||||
|
t("chat.suggestedPrompts.teacher.2"),
|
||||||
|
"Generate a question on this topic",
|
||||||
|
"Analyze the difficulty distribution",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学生错题本
|
||||||
|
if (pathname.includes("/student/error-book")) {
|
||||||
|
return {
|
||||||
|
systemPrompt:
|
||||||
|
"You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
|
||||||
|
contextMessage: "Current page: Error book (student view)",
|
||||||
|
suggestedPrompts: [
|
||||||
|
t("chat.suggestedPrompts.student.0"),
|
||||||
|
t("chat.suggestedPrompts.student.1"),
|
||||||
|
t("chat.suggestedPrompts.student.2"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学生作业
|
||||||
|
if (pathname.includes("/student/homework") || pathname.includes("/student/learning")) {
|
||||||
|
return {
|
||||||
|
systemPrompt:
|
||||||
|
"You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
|
||||||
|
contextMessage: "Current page: Student homework view",
|
||||||
|
suggestedPrompts: [
|
||||||
|
t("chat.suggestedPrompts.student.0"),
|
||||||
|
"Give me a hint, not the answer",
|
||||||
|
"Help me understand this concept",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 家长面板
|
||||||
|
if (pathname.includes("/parent")) {
|
||||||
|
return {
|
||||||
|
systemPrompt:
|
||||||
|
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
|
||||||
|
contextMessage: "Current page: Parent dashboard",
|
||||||
|
suggestedPrompts: [
|
||||||
|
t("chat.suggestedPrompts.parent.0"),
|
||||||
|
t("chat.suggestedPrompts.parent.1"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员面板
|
||||||
|
if (pathname.includes("/admin")) {
|
||||||
|
return {
|
||||||
|
systemPrompt:
|
||||||
|
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
|
||||||
|
contextMessage: "Current page: Admin dashboard",
|
||||||
|
suggestedPrompts: [
|
||||||
|
t("chat.suggestedPrompts.admin.0"),
|
||||||
|
t("chat.suggestedPrompts.admin.1"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认
|
||||||
|
return {
|
||||||
|
systemPrompt: "You are a helpful AI assistant for a K12 school management system.",
|
||||||
|
contextMessage: "",
|
||||||
|
suggestedPrompts: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,22 @@
|
|||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react"
|
import { useState, useRef, useEffect, useCallback } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Send, Bot, User } from "lucide-react"
|
import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
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"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip"
|
||||||
import { AiChatSkeleton } from "./ai-skeleton"
|
import { AiChatSkeleton } from "./ai-skeleton"
|
||||||
import { useAiClient } from "../context/ai-client-provider"
|
import { AiMarkdownRenderer } from "./ai-markdown-renderer"
|
||||||
|
import { useAiChatStream } from "../hooks/use-ai-chat-stream"
|
||||||
import type { AiChatMessage } from "../types"
|
import type { AiChatMessage } from "../types"
|
||||||
|
|
||||||
type AiChatPanelProps = {
|
type AiChatPanelProps = {
|
||||||
@@ -24,13 +31,25 @@ type AiChatPanelProps = {
|
|||||||
title?: string
|
title?: string
|
||||||
/** 最大消息数 */
|
/** 最大消息数 */
|
||||||
maxMessages?: number
|
maxMessages?: number
|
||||||
|
/** 是否启用流式响应(默认 true) */
|
||||||
|
streaming?: boolean
|
||||||
|
/** 建议提示词列表(空状态展示) */
|
||||||
|
suggestedPrompts?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 聊天面板
|
* AI 聊天面板
|
||||||
*
|
*
|
||||||
* 通用 AI 对话组件,可嵌入任何页面。
|
* 通用 AI 对话组件,可嵌入任何页面。
|
||||||
* 通过 useAiClient() 获取 Server Action 引用,不直接 import actions。
|
* V2 增强:
|
||||||
|
* - 流式响应(SSE)逐 token 渲染
|
||||||
|
* - Markdown 渲染(代码块、表格、列表)
|
||||||
|
* - 复制按钮
|
||||||
|
* - 停止生成按钮
|
||||||
|
* - 清除对话按钮
|
||||||
|
* - 建议提示词
|
||||||
|
* - aria-live 无障碍
|
||||||
|
* - 对话历史持久化(localStorage)
|
||||||
*/
|
*/
|
||||||
export function AiChatPanel({
|
export function AiChatPanel({
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
@@ -38,63 +57,68 @@ export function AiChatPanel({
|
|||||||
placeholder,
|
placeholder,
|
||||||
title,
|
title,
|
||||||
maxMessages = 50,
|
maxMessages = 50,
|
||||||
|
streaming: _streamingEnabled = true,
|
||||||
|
suggestedPrompts,
|
||||||
}: AiChatPanelProps): React.ReactNode {
|
}: AiChatPanelProps): React.ReactNode {
|
||||||
const t = useTranslations("ai")
|
const t = useTranslations("ai")
|
||||||
const aiClient = useAiClient()
|
const { messages, streaming, error, send, stop, clear } = useAiChatStream()
|
||||||
const [messages, setMessages] = useState<AiChatMessage[]>([])
|
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const storageKey = "ai-chat-history"
|
||||||
|
|
||||||
|
// 从 localStorage 恢复对话历史
|
||||||
|
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 {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 持久化对话历史
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (messages.length > 0) {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(messages.slice(-20)))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略写入错误
|
||||||
|
}
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
// 自动滚动到底部
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
}
|
}
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
const handleSend = useCallback(async (): Promise<void> => {
|
const handleSend = useCallback(
|
||||||
const trimmed = input.trim()
|
async (content?: string): Promise<void> => {
|
||||||
if (!trimmed || loading || messages.length >= maxMessages) return
|
const trimmed = (content ?? input).trim()
|
||||||
|
if (!trimmed || streaming || messages.length >= maxMessages) return
|
||||||
|
|
||||||
const userMessage: AiChatMessage = { role: "user", content: trimmed }
|
const contextPrefix = contextMessage
|
||||||
const contextPrefix = contextMessage
|
? `Context:\n${contextMessage}\n\nUser question: ${trimmed}`
|
||||||
? `Context:\n${contextMessage}\n\nUser question: ${trimmed}`
|
: trimmed
|
||||||
: trimmed
|
|
||||||
const systemMessage: AiChatMessage | null = systemPrompt
|
|
||||||
? { role: "system", content: systemPrompt }
|
|
||||||
: null
|
|
||||||
|
|
||||||
const requestMessages: AiChatMessage[] = [
|
const requestMessages: AiChatMessage[] = [
|
||||||
...(systemMessage ? [systemMessage] : []),
|
...messages,
|
||||||
...messages,
|
{ role: "user", content: contextPrefix },
|
||||||
{ role: "user" as const, content: contextPrefix },
|
]
|
||||||
]
|
|
||||||
|
|
||||||
setInput("")
|
setInput("")
|
||||||
setLoading(true)
|
await send(requestMessages, { systemPrompt })
|
||||||
setMessages((prev) => [...prev, userMessage])
|
},
|
||||||
|
[input, streaming, messages, maxMessages, systemPrompt, contextMessage, send]
|
||||||
try {
|
)
|
||||||
const result = await aiClient.chat({
|
|
||||||
messages: requestMessages,
|
|
||||||
})
|
|
||||||
if (result.success && result.data) {
|
|
||||||
const assistantContent = result.data.content
|
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ role: "assistant", content: assistantContent },
|
|
||||||
])
|
|
||||||
} else {
|
|
||||||
toast.error(result.message ?? t("error.chatFailed"))
|
|
||||||
setMessages((prev) => prev.filter((m) => m !== userMessage))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error(t("error.chatFailed"))
|
|
||||||
setMessages((prev) => prev.filter((m) => m !== userMessage))
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [input, loading, messages, maxMessages, systemPrompt, contextMessage, aiClient, t])
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
@@ -103,21 +127,77 @@ export function AiChatPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && messages.length === 0) {
|
const handleClear = (): void => {
|
||||||
|
if (window.confirm(t("chat.clearConfirm"))) {
|
||||||
|
clear()
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(storageKey)
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
toast.success(t("chat.clear"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSuggestedPrompt = (prompt: string): void => {
|
||||||
|
void handleSend(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streaming && messages.length === 0) {
|
||||||
return <AiChatSkeleton />
|
return <AiChatSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultSuggestedPrompts = suggestedPrompts ?? [
|
||||||
|
t("chat.suggestedPrompts.teacher.0"),
|
||||||
|
t("chat.suggestedPrompts.teacher.1"),
|
||||||
|
t("chat.suggestedPrompts.teacher.2"),
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<Bot className="h-4 w-4 text-primary" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
{title ?? t("chat.title")}
|
<Bot className="h-4 w-4 text-primary" />
|
||||||
</CardTitle>
|
{title ?? t("chat.title")}
|
||||||
|
</CardTitle>
|
||||||
|
{messages.length > 0 ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-muted-foreground"
|
||||||
|
onClick={handleClear}
|
||||||
|
aria-label={t("chat.clear")}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t("chat.clear")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
|
{error ? (
|
||||||
|
<div
|
||||||
|
className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
<ScrollArea className="h-[300px] w-full rounded-md border p-3">
|
<ScrollArea
|
||||||
|
className="h-[300px] w-full rounded-md border p-3"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-relevant="additions text"
|
||||||
|
>
|
||||||
<div className="space-y-3" ref={scrollRef}>
|
<div className="space-y-3" ref={scrollRef}>
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<div
|
<div
|
||||||
@@ -136,21 +216,50 @@ export function AiChatPanel({
|
|||||||
: "bg-muted"
|
: "bg-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
{message.role === "assistant" ? (
|
||||||
|
<AiMarkdownRenderer content={message.content} />
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{loading ? (
|
{streaming ? (
|
||||||
<div className="flex gap-2 justify-start">
|
<div className="flex gap-2 justify-start">
|
||||||
<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" />
|
||||||
<div className="rounded-md px-3 py-2 text-sm bg-muted">
|
<div className="rounded-md px-3 py-2 text-sm bg-muted">
|
||||||
<span className="animate-pulse">{t("chat.thinking")}</span>
|
<span className="animate-pulse">{t("chat.streaming")}</span>
|
||||||
|
<span className="inline-block w-1 h-4 ml-1 bg-primary animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
) : null}
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md border border-dashed p-6 text-center">
|
||||||
|
<Sparkles className="h-8 w-8 text-primary mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
{t("chat.suggestedPrompts.title")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
|
{defaultSuggestedPrompts.map((prompt, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => handleSuggestedPrompt(prompt)}
|
||||||
|
>
|
||||||
|
{prompt}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={input}
|
value={input}
|
||||||
@@ -158,18 +267,30 @@ export function AiChatPanel({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder ?? t("chat.placeholder")}
|
placeholder={placeholder ?? t("chat.placeholder")}
|
||||||
className="min-h-[60px] resize-none"
|
className="min-h-[60px] resize-none"
|
||||||
disabled={loading || messages.length >= maxMessages}
|
disabled={streaming || messages.length >= maxMessages}
|
||||||
aria-label={t("chat.inputLabel")}
|
aria-label={t("chat.inputLabel")}
|
||||||
/>
|
/>
|
||||||
<Button
|
{streaming ? (
|
||||||
type="button"
|
<Button
|
||||||
size="icon"
|
type="button"
|
||||||
onClick={() => void handleSend()}
|
size="icon"
|
||||||
disabled={!input.trim() || loading || messages.length >= maxMessages}
|
variant="destructive"
|
||||||
aria-label={t("chat.send")}
|
onClick={stop}
|
||||||
>
|
aria-label={t("chat.stopGeneration")}
|
||||||
<Send className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Square className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => void handleSend()}
|
||||||
|
disabled={!input.trim() || messages.length >= maxMessages}
|
||||||
|
aria-label={t("chat.send")}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{messages.length >= maxMessages ? (
|
{messages.length >= maxMessages ? (
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
|||||||
186
src/modules/ai/components/ai-child-summary.tsx
Normal file
186
src/modules/ai/components/ai-child-summary.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Sparkles, TrendingUp, Lightbulb, CheckCircle, AlertCircle } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
|
||||||
|
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
|
||||||
|
import { AiMarkdownRenderer } from "@/modules/ai/components/ai-markdown-renderer"
|
||||||
|
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
|
||||||
|
import type { ChildSummaryResult, ChildSummaryInput } from "@/modules/ai/types"
|
||||||
|
|
||||||
|
type AiChildSummaryProps = {
|
||||||
|
studentId: string
|
||||||
|
studentName?: string
|
||||||
|
grade?: string
|
||||||
|
recentGrades?: ChildSummaryInput["recentGrades"]
|
||||||
|
attendanceRate?: number
|
||||||
|
homeworkCompletionRate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 家长 AI 学情摘要组件
|
||||||
|
*
|
||||||
|
* 参考 Squirrel AI 24/7 家长面板和 Khanmigo 家长可见性。
|
||||||
|
* 为家长生成子女学情的 AI 摘要,包括:
|
||||||
|
* - 整体评估
|
||||||
|
* - 优势领域
|
||||||
|
* - 需改进领域
|
||||||
|
* - 家庭辅导建议
|
||||||
|
* - 下一步行动
|
||||||
|
*/
|
||||||
|
export function AiChildSummary({
|
||||||
|
studentId,
|
||||||
|
studentName,
|
||||||
|
grade,
|
||||||
|
recentGrades,
|
||||||
|
attendanceRate,
|
||||||
|
homeworkCompletionRate,
|
||||||
|
}: AiChildSummaryProps): React.ReactNode {
|
||||||
|
const t = useTranslations("ai")
|
||||||
|
const aiClient = useAiClient()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [summary, setSummary] = useState<ChildSummaryResult | null>(null)
|
||||||
|
|
||||||
|
const handleGenerate = async (): Promise<void> => {
|
||||||
|
if (!aiClient.generateChildSummary) {
|
||||||
|
toast.error(t("parent.error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await aiClient.generateChildSummary({
|
||||||
|
studentId,
|
||||||
|
studentName,
|
||||||
|
grade,
|
||||||
|
recentGrades,
|
||||||
|
attendanceRate,
|
||||||
|
homeworkCompletionRate,
|
||||||
|
})
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setSummary(result.data)
|
||||||
|
toast.success(t("parent.summary"))
|
||||||
|
} else {
|
||||||
|
toast.error(result.message ?? t("parent.error"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("parent.error"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AiErrorBoundary>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
|
{t("parent.summary")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("parent.summaryDescription")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<AiSuggestionSkeleton />
|
||||||
|
) : summary ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 整体评估 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-medium">{t("parent.summary")}</h4>
|
||||||
|
<div className="text-sm text-muted-foreground rounded-md bg-muted p-3">
|
||||||
|
<AiMarkdownRenderer content={summary.overallAssessment} showCopyButton={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 优势领域 */}
|
||||||
|
{summary.strengths.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
{t("parent.summary")}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{summary.strengths.map((item, index) => (
|
||||||
|
<li key={index} className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<span className="text-green-500 mt-0.5">•</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 需改进领域 */}
|
||||||
|
{summary.areasForImprovement.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 text-orange-500" />
|
||||||
|
{t("parent.weaknessHint")}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{summary.areasForImprovement.map((item, index) => (
|
||||||
|
<li key={index} className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<span className="text-orange-500 mt-0.5">•</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 家庭辅导建议 */}
|
||||||
|
{summary.familyTutoringSuggestions.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<Lightbulb className="h-3.5 w-3.5 text-primary" />
|
||||||
|
{t("parent.suggestion")}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{summary.familyTutoringSuggestions.map((item, index) => (
|
||||||
|
<li key={index} className="text-sm text-muted-foreground flex items-start gap-2">
|
||||||
|
<span className="text-primary mt-0.5">•</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 下一步行动 */}
|
||||||
|
{summary.nextSteps.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<TrendingUp className="h-3.5 w-3.5 text-primary" />
|
||||||
|
{t("studyPath.nextSteps")}
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{summary.nextSteps.map((item, index) => (
|
||||||
|
<Badge key={index} variant="outline" className="text-xs">
|
||||||
|
{item}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleGenerate} className="w-full">
|
||||||
|
{t("suggestion.regenerate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleGenerate} className="w-full">
|
||||||
|
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
||||||
|
{t("parent.generateSummary")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AiErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ export function AiGradingAssist({
|
|||||||
<Sparkles className="h-4 w-4 text-primary" />
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
{t("grading.title")}
|
{t("grading.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{t("grading.title")}</CardDescription>
|
<CardDescription>{t("grading.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -31,13 +31,6 @@ type AiLessonContentGeneratorProps = {
|
|||||||
onInsertContent?: (result: LessonContentResult) => void
|
onInsertContent?: (result: LessonContentResult) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTENT_TYPE_ICONS: Record<ContentType, typeof Sparkles> = {
|
|
||||||
activity: Lightbulb,
|
|
||||||
assessment: FileText,
|
|
||||||
question: HelpCircle,
|
|
||||||
material: BookOpen,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 备课内容生成器
|
* AI 备课内容生成器
|
||||||
*
|
*
|
||||||
@@ -105,7 +98,7 @@ export function AiLessonContentGenerator({
|
|||||||
<Sparkles className="h-4 w-4 text-primary" />
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
{t("lessonPrep.generateContent")}
|
{t("lessonPrep.generateContent")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{t("lessonPrep.generateContent")}</CardDescription>
|
<CardDescription>{t("lessonPrep.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* 内容类型选择 */}
|
{/* 内容类型选择 */}
|
||||||
@@ -128,13 +121,13 @@ export function AiLessonContentGenerator({
|
|||||||
{/* 附加上下文 */}
|
{/* 附加上下文 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs text-muted-foreground" htmlFor="ai-additional-context">
|
<label className="text-xs text-muted-foreground" htmlFor="ai-additional-context">
|
||||||
{t("lessonPrep.generateContent")}
|
{t("lessonPrep.additionalContext")}
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="ai-additional-context"
|
id="ai-additional-context"
|
||||||
value={additionalContext}
|
value={additionalContext}
|
||||||
onChange={(e) => setAdditionalContext(e.target.value)}
|
onChange={(e) => setAdditionalContext(e.target.value)}
|
||||||
placeholder={t("lessonPrep.generateContent")}
|
placeholder={t("lessonPrep.additionalContextPlaceholder")}
|
||||||
className="min-h-[60px] text-sm"
|
className="min-h-[60px] text-sm"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
@@ -172,10 +165,10 @@ export function AiLessonContentGenerator({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onInsertContent(result)
|
onInsertContent(result)
|
||||||
toast.success(t("lessonPrep.generateContent"))
|
toast.success(t("lessonPrep.insertContent"))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("lessonPrep.generateContent")}
|
{t("lessonPrep.insertContent")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
119
src/modules/ai/components/ai-markdown-renderer.tsx
Normal file
119
src/modules/ai/components/ai-markdown-renderer.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { memo, useState, useCallback } from "react"
|
||||||
|
import ReactMarkdown from "react-markdown"
|
||||||
|
import remarkGfm from "remark-gfm"
|
||||||
|
import { Copy, Check } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
type AiMarkdownRendererProps = {
|
||||||
|
content: string
|
||||||
|
/** 是否显示复制按钮 */
|
||||||
|
showCopyButton?: boolean
|
||||||
|
/** 自定义类名 */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Markdown 渲染器
|
||||||
|
*
|
||||||
|
* 将 AI 回复渲染为富文本 Markdown,支持:
|
||||||
|
* - GFM(表格、删除线、任务列表)
|
||||||
|
* - 代码块语法高亮
|
||||||
|
* - 复制按钮
|
||||||
|
*
|
||||||
|
* 安全:react-markdown 默认不执行 HTML,防止 XSS。
|
||||||
|
*/
|
||||||
|
function AiMarkdownRendererImpl({
|
||||||
|
content,
|
||||||
|
showCopyButton = true,
|
||||||
|
className,
|
||||||
|
}: AiMarkdownRendererProps): React.ReactNode {
|
||||||
|
const t = useTranslations("ai")
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content)
|
||||||
|
setCopied(true)
|
||||||
|
toast.success(t("chat.copied"))
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
toast.error(t("error.chatFailed"))
|
||||||
|
}
|
||||||
|
}, [content, t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"prose prose-sm dark:prose-invert max-w-none",
|
||||||
|
"prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0",
|
||||||
|
"prose-code:before:content-none prose-code:after:content-none",
|
||||||
|
"prose-pre:bg-muted prose-pre:p-3 prose-pre:rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
code({ className: codeClass, children, ...props }) {
|
||||||
|
const isInline = !codeClass?.includes("language-")
|
||||||
|
if (isInline) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className="rounded bg-muted px-1 py-0.5 text-xs font-mono"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className={codeClass} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
a({ children, ...props }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
{...props}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
{showCopyButton ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 transition-opacity h-7 px-2"
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
aria-label={t("chat.copy")}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AiMarkdownRenderer = memo(AiMarkdownRendererImpl)
|
||||||
@@ -84,9 +84,9 @@ export function AiQuestionVariantGenerator({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variantTypeLabels: Record<VariantType, string> = {
|
const variantTypeLabels: Record<VariantType, string> = {
|
||||||
same_knowledge_point: t("exam.generate"),
|
same_knowledge_point: t("exam.variantType.same_knowledge_point"),
|
||||||
different_difficulty: t("exam.generate"),
|
different_difficulty: t("exam.variantType.different_difficulty"),
|
||||||
different_format: t("exam.generate"),
|
different_format: t("exam.variantType.different_format"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,7 +97,7 @@ export function AiQuestionVariantGenerator({
|
|||||||
<Sparkles className="h-4 w-4 text-primary" />
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
{t("capability.questionVariant")}
|
{t("capability.questionVariant")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{t("exam.generate")}</CardDescription>
|
<CardDescription>{t("exam.variantType.label")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{/* 变体类型选择 */}
|
{/* 变体类型选择 */}
|
||||||
@@ -186,7 +186,7 @@ export function AiQuestionVariantGenerator({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
{t("exam.generate")}
|
{t("exam.addVariant")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
193
src/modules/ai/components/ai-study-path.tsx
Normal file
193
src/modules/ai/components/ai-study-path.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Sparkles, CheckCircle, Clock, AlertCircle, Target } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
|
||||||
|
import { AiSuggestionSkeleton } from "@/modules/ai/components/ai-skeleton"
|
||||||
|
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
|
||||||
|
import type { StudyPathResult, StudyPathInput } from "@/modules/ai/types"
|
||||||
|
|
||||||
|
type AiStudyPathProps = {
|
||||||
|
studentId: string
|
||||||
|
subject?: string
|
||||||
|
currentMastery?: StudyPathInput["currentMastery"]
|
||||||
|
learningGoal?: string
|
||||||
|
onStartLearning?: (step: StudyPathResult["learningPath"][number]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生学习路径推荐组件
|
||||||
|
*
|
||||||
|
* 参考 Squirrel AI 纳米级知识图谱和 Century Tech 自适应路径。
|
||||||
|
* 为学生生成个性化学习路径:
|
||||||
|
* - 当前水平评估
|
||||||
|
* - 分步骤学习路径(含状态、建议、预计时间)
|
||||||
|
* - 学习总结
|
||||||
|
* - 鼓励语
|
||||||
|
*/
|
||||||
|
export function AiStudyPath({
|
||||||
|
studentId,
|
||||||
|
subject,
|
||||||
|
currentMastery,
|
||||||
|
learningGoal,
|
||||||
|
onStartLearning,
|
||||||
|
}: AiStudyPathProps): React.ReactNode {
|
||||||
|
const t = useTranslations("ai")
|
||||||
|
const aiClient = useAiClient()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [path, setPath] = useState<StudyPathResult | null>(null)
|
||||||
|
|
||||||
|
const handleGenerate = async (): Promise<void> => {
|
||||||
|
if (!aiClient.recommendStudyPath) {
|
||||||
|
toast.error(t("studyPath.error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await aiClient.recommendStudyPath({
|
||||||
|
studentId,
|
||||||
|
subject,
|
||||||
|
currentMastery,
|
||||||
|
learningGoal,
|
||||||
|
})
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setPath(result.data)
|
||||||
|
toast.success(t("studyPath.title"))
|
||||||
|
} else {
|
||||||
|
toast.error(result.message ?? t("studyPath.error"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("studyPath.error"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
mastered: {
|
||||||
|
icon: CheckCircle,
|
||||||
|
color: "text-green-500",
|
||||||
|
badge: "secondary" as const,
|
||||||
|
label: t("studyPath.mastered"),
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
icon: Clock,
|
||||||
|
color: "text-blue-500",
|
||||||
|
badge: "default" as const,
|
||||||
|
label: t("studyPath.inProgress"),
|
||||||
|
},
|
||||||
|
needs_work: {
|
||||||
|
icon: AlertCircle,
|
||||||
|
color: "text-orange-500",
|
||||||
|
badge: "destructive" as const,
|
||||||
|
label: t("studyPath.needsWork"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AiErrorBoundary>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Target className="h-4 w-4 text-primary" />
|
||||||
|
{t("studyPath.title")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("studyPath.description")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<AiSuggestionSkeleton />
|
||||||
|
) : path ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 当前水平 */}
|
||||||
|
<div className="rounded-md bg-primary/5 border border-primary/20 p-3">
|
||||||
|
<p className="text-sm font-medium text-primary">{path.currentLevel}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 学习路径步骤 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">{t("studyPath.nextSteps")}</h4>
|
||||||
|
{path.learningPath.map((step, index) => {
|
||||||
|
const config = statusConfig[step.status]
|
||||||
|
const Icon = config.icon
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-md border p-3 space-y-2 relative"
|
||||||
|
>
|
||||||
|
{/* 连接线 */}
|
||||||
|
{index < path.learningPath.length - 1 ? (
|
||||||
|
<div className="absolute left-5 top-12 bottom-0 w-px bg-border" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`mt-0.5 ${config.color}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{step.step}. {step.knowledgePoint}
|
||||||
|
</span>
|
||||||
|
<Badge variant={config.badge} className="text-xs">
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{step.recommendedAction}</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<span>{step.estimatedTime}</span>
|
||||||
|
</div>
|
||||||
|
{onStartLearning && step.status !== "mastered" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-1 h-7 text-xs"
|
||||||
|
onClick={() => onStartLearning(step)}
|
||||||
|
>
|
||||||
|
{t("studyPath.startLearning")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 学习总结 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-sm font-medium">{t("studyPath.title")}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{path.summary}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 鼓励语 */}
|
||||||
|
<div className="rounded-md bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900 p-3">
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-400 flex items-start gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
{path.motivation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleGenerate} className="w-full">
|
||||||
|
{t("suggestion.regenerate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleGenerate} className="w-full">
|
||||||
|
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
||||||
|
{t("studyPath.generate")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AiErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Sparkles, Check, X, RefreshCw } from "lucide-react"
|
import { Sparkles, Check, RefreshCw } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
|||||||
221
src/modules/ai/components/ai-usage-dashboard.tsx
Normal file
221
src/modules/ai/components/ai-usage-dashboard.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Activity, Users, AlertTriangle, Clock } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Progress } from "@/shared/components/ui/progress"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { AiErrorBoundary } from "@/modules/ai/components/ai-error-boundary"
|
||||||
|
import { useAiClient } from "@/modules/ai/context/ai-client-provider"
|
||||||
|
import type { AiUsageStats } from "@/modules/ai/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员 AI 使用统计仪表盘
|
||||||
|
*
|
||||||
|
* 参考 Khanmigo district dashboard 和 Century Tech 全校视图。
|
||||||
|
* 展示:
|
||||||
|
* - 总调用数 / 今日 / 本周
|
||||||
|
* - 活跃用户数
|
||||||
|
* - 错误率
|
||||||
|
* - 平均耗时
|
||||||
|
* - 按能力分类
|
||||||
|
* - 按角色分类
|
||||||
|
* - 高频用户
|
||||||
|
* - 最近活动
|
||||||
|
*/
|
||||||
|
export function AiUsageDashboard(): React.ReactNode {
|
||||||
|
const t = useTranslations("ai")
|
||||||
|
const aiClient = useAiClient()
|
||||||
|
const [stats, setStats] = useState<AiUsageStats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const loadStats = async (): Promise<void> => {
|
||||||
|
if (!aiClient.getAiUsageStats) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await aiClient.getAiUsageStats()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setStats(result.data)
|
||||||
|
} else {
|
||||||
|
toast.error(result.message ?? t("error.chatFailed"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("error.chatFailed"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadStats()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const statCards = stats
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t("admin.totalCalls"),
|
||||||
|
value: stats.totalCalls.toString(),
|
||||||
|
icon: Activity,
|
||||||
|
color: "text-blue-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("admin.callsToday"),
|
||||||
|
value: stats.callsToday.toString(),
|
||||||
|
icon: Clock,
|
||||||
|
color: "text-green-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("admin.activeUsers"),
|
||||||
|
value: stats.activeUsers.toString(),
|
||||||
|
icon: Users,
|
||||||
|
color: "text-purple-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("admin.errorRate"),
|
||||||
|
value: `${(stats.errorRate * 100).toFixed(1)}%`,
|
||||||
|
icon: AlertTriangle,
|
||||||
|
color: stats.errorRate > 0.05 ? "text-red-500" : "text-green-500",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AiErrorBoundary>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4 text-primary" />
|
||||||
|
{t("admin.usageDashboard")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("admin.dashboardDescription")}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadStats()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("suggestion.regenerate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{loading && !stats ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="h-20 rounded-md bg-muted animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : stats ? (
|
||||||
|
<>
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{statCards.map((card, index) => {
|
||||||
|
const Icon = card.icon
|
||||||
|
return (
|
||||||
|
<div key={index} className="rounded-md border p-3 space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">{card.label}</span>
|
||||||
|
<Icon className={`h-3.5 w-3.5 ${card.color}`} />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold">{card.value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 按能力分类 */}
|
||||||
|
{stats.byCapability.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">{t("admin.byCapability")}</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.byCapability.map((item, index) => {
|
||||||
|
const maxCount = Math.max(...stats.byCapability.map((c) => c.count), 1)
|
||||||
|
const percent = (item.count / maxCount) * 100
|
||||||
|
return (
|
||||||
|
<div key={index} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">{item.capability}</span>
|
||||||
|
<span className="font-medium">{item.count}</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={percent} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 按角色分类 */}
|
||||||
|
{stats.byRole.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">{t("admin.byRole")}</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{stats.byRole.map((item, index) => (
|
||||||
|
<Badge key={index} variant="secondary" className="text-xs">
|
||||||
|
{item.role}: {item.count}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 高频用户 */}
|
||||||
|
{stats.topUsers.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">{t("admin.topUsers")}</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{stats.topUsers.slice(0, 5).map((user, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">{user.userId}</span>
|
||||||
|
<Badge variant="outline">{user.count}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 最近活动 */}
|
||||||
|
{stats.recentActivity.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-medium">{t("admin.recentActivity")}</h4>
|
||||||
|
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||||
|
{stats.recentActivity.slice(0, 10).map((activity, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between text-xs border-b pb-1">
|
||||||
|
<span className="text-muted-foreground">{activity.capability}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={activity.success ? "text-green-500" : "text-red-500"}>
|
||||||
|
{activity.success ? "✓" : "✗"}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{activity.durationMs}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{stats.totalCalls === 0 ? (
|
||||||
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
{t("admin.noData")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
{t("admin.noData")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AiErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
src/modules/ai/hooks/use-ai-chat-stream.ts
Normal file
190
src/modules/ai/hooks/use-ai-chat-stream.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import type { AiChatMessage } from "../types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 流式聊天 Hook
|
||||||
|
*
|
||||||
|
* 通过 SSE 端点消费流式 AI 回复。
|
||||||
|
* 支持:
|
||||||
|
* - 逐 token 渲染
|
||||||
|
* - 停止生成(AbortController)
|
||||||
|
* - 错误处理
|
||||||
|
*/
|
||||||
|
|
||||||
|
type StreamState = {
|
||||||
|
messages: AiChatMessage[]
|
||||||
|
streaming: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseAiChatStreamReturn = StreamState & {
|
||||||
|
send: (messages: AiChatMessage[], options?: { systemPrompt?: string; providerId?: string }) => Promise<void>
|
||||||
|
stop: () => void
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAiChatStream(): UseAiChatStreamReturn {
|
||||||
|
const t = useTranslations("ai")
|
||||||
|
const [messages, setMessages] = useState<AiChatMessage[]>([])
|
||||||
|
const [streaming, setStreaming] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
async (
|
||||||
|
inputMessages: AiChatMessage[],
|
||||||
|
options?: { systemPrompt?: string; providerId?: string }
|
||||||
|
): Promise<void> => {
|
||||||
|
if (streaming) return
|
||||||
|
setStreaming(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const userMessage = inputMessages[inputMessages.length - 1]
|
||||||
|
if (userMessage && userMessage.role === "user") {
|
||||||
|
setMessages((prev) => [...prev, userMessage])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加空的 assistant 消息,用于流式更新
|
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: "" }])
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortControllerRef.current = controller
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/ai/chat/stream", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
messages: inputMessages,
|
||||||
|
systemPrompt: options?.systemPrompt,
|
||||||
|
providerId: options?.providerId,
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
})
|
||||||
|
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 {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
// 用户主动停止,不显示错误
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[streaming, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const stop = useCallback((): void => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clear = useCallback((): void => {
|
||||||
|
setMessages([])
|
||||||
|
setError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { messages, streaming, error, send, stop, clear }
|
||||||
|
}
|
||||||
@@ -132,3 +132,74 @@ export const WeaknessAnalysisResultSchema = z.object({
|
|||||||
studyPlan: z.string().min(1),
|
studyPlan: z.string().min(1),
|
||||||
recommendedResources: z.array(z.string()),
|
recommendedResources: z.array(z.string()),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 家长学情摘要校验
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const ChildSummaryInputSchema = z.object({
|
||||||
|
studentId: z.string().min(1),
|
||||||
|
studentName: z.string().optional(),
|
||||||
|
grade: z.string().optional(),
|
||||||
|
recentGrades: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
subject: z.string().min(1),
|
||||||
|
score: z.number(),
|
||||||
|
maxScore: z.number(),
|
||||||
|
trend: z.enum(["up", "down", "stable"]),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
attendanceRate: z.number().min(0).max(1).optional(),
|
||||||
|
errorBookSummary: z
|
||||||
|
.object({
|
||||||
|
totalErrors: z.number().int().min(0),
|
||||||
|
topWeakSubjects: z.array(z.string()),
|
||||||
|
masteryTrend: z.enum(["improving", "declining", "stable"]),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
homeworkCompletionRate: z.number().min(0).max(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ChildSummaryResultSchema = z.object({
|
||||||
|
overallAssessment: z.string().min(1),
|
||||||
|
strengths: z.array(z.string()),
|
||||||
|
areasForImprovement: z.array(z.string()),
|
||||||
|
familyTutoringSuggestions: z.array(z.string()),
|
||||||
|
nextSteps: z.array(z.string()),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 学习路径推荐校验
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const StudyPathInputSchema = z.object({
|
||||||
|
studentId: z.string().min(1),
|
||||||
|
subject: z.string().optional(),
|
||||||
|
currentMastery: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
knowledgePoint: z.string().min(1),
|
||||||
|
masteryLevel: z.number().min(0).max(5),
|
||||||
|
errorCount: z.number().int().min(0),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
learningGoal: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const StudyPathResultSchema = z.object({
|
||||||
|
currentLevel: z.string().min(1),
|
||||||
|
learningPath: z.array(
|
||||||
|
z.object({
|
||||||
|
step: z.number().int().min(1),
|
||||||
|
knowledgePoint: z.string().min(1),
|
||||||
|
status: z.enum(["mastered", "in_progress", "needs_work"]),
|
||||||
|
recommendedAction: z.string().min(1),
|
||||||
|
estimatedTime: z.string().min(1),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
summary: z.string().min(1),
|
||||||
|
motivation: z.string().min(1),
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
QUESTION_VARIANT_SYSTEM_PROMPT,
|
QUESTION_VARIANT_SYSTEM_PROMPT,
|
||||||
SIMILAR_QUESTION_SYSTEM_PROMPT,
|
SIMILAR_QUESTION_SYSTEM_PROMPT,
|
||||||
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
|
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
|
||||||
|
CHILD_SUMMARY_SYSTEM_PROMPT,
|
||||||
|
STUDY_PATH_SYSTEM_PROMPT,
|
||||||
} from "./prompt-templates"
|
} from "./prompt-templates"
|
||||||
import { withAiTracking } from "./usage-tracker"
|
import { withAiTracking } from "./usage-tracker"
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +19,8 @@ import {
|
|||||||
QuestionVariantResultSchema,
|
QuestionVariantResultSchema,
|
||||||
SimilarQuestionListSchema,
|
SimilarQuestionListSchema,
|
||||||
WeaknessAnalysisResultSchema,
|
WeaknessAnalysisResultSchema,
|
||||||
|
ChildSummaryResultSchema,
|
||||||
|
StudyPathResultSchema,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import type {
|
import type {
|
||||||
AiChatMessage,
|
AiChatMessage,
|
||||||
@@ -33,6 +37,10 @@ import type {
|
|||||||
SimilarQuestionResult,
|
SimilarQuestionResult,
|
||||||
WeaknessAnalysisInput,
|
WeaknessAnalysisInput,
|
||||||
WeaknessAnalysisResult,
|
WeaknessAnalysisResult,
|
||||||
|
ChildSummaryInput,
|
||||||
|
ChildSummaryResult,
|
||||||
|
StudyPathInput,
|
||||||
|
StudyPathResult,
|
||||||
} from "../types"
|
} from "../types"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -320,6 +328,76 @@ export class DefaultAiService implements AiService {
|
|||||||
return { result: validated.data }
|
return { result: validated.data }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult> {
|
||||||
|
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
|
||||||
|
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)}`
|
||||||
|
: "",
|
||||||
|
input.attendanceRate !== undefined
|
||||||
|
? `Attendance Rate: ${(input.attendanceRate * 100).toFixed(1)}%`
|
||||||
|
: "",
|
||||||
|
input.errorBookSummary
|
||||||
|
? `Error Book Summary:\n${JSON.stringify(input.errorBookSummary, null, 2)}`
|
||||||
|
: "",
|
||||||
|
input.homeworkCompletionRate !== undefined
|
||||||
|
? `Homework Completion Rate: ${(input.homeworkCompletionRate * 100).toFixed(1)}%`
|
||||||
|
: "",
|
||||||
|
].filter((line) => line.length > 0)
|
||||||
|
const { content } = await callAi(
|
||||||
|
buildChatMessages(CHILD_SUMMARY_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||||
|
{ temperature: 0.4, maxTokens: 2000 }
|
||||||
|
)
|
||||||
|
const parsed = extractJson(content)
|
||||||
|
const validated = ChildSummaryResultSchema.safeParse(parsed)
|
||||||
|
if (!validated.success) {
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
overallAssessment: "Unable to generate summary at this time.",
|
||||||
|
strengths: [],
|
||||||
|
areasForImprovement: [],
|
||||||
|
familyTutoringSuggestions: [],
|
||||||
|
nextSteps: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { result: validated.data }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult> {
|
||||||
|
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
|
||||||
|
const userLines = [
|
||||||
|
`Student ID: ${input.studentId}`,
|
||||||
|
input.subject ? `Subject: ${input.subject}` : "",
|
||||||
|
input.currentMastery && input.currentMastery.length > 0
|
||||||
|
? `Current Mastery:\n${JSON.stringify(input.currentMastery, null, 2)}`
|
||||||
|
: "",
|
||||||
|
input.learningGoal ? `Learning Goal: ${input.learningGoal}` : "",
|
||||||
|
].filter((line) => line.length > 0)
|
||||||
|
const { content } = await callAi(
|
||||||
|
buildChatMessages(STUDY_PATH_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||||
|
{ temperature: 0.5, maxTokens: 2000 }
|
||||||
|
)
|
||||||
|
const parsed = extractJson(content)
|
||||||
|
const validated = StudyPathResultSchema.safeParse(parsed)
|
||||||
|
if (!validated.success) {
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
currentLevel: "Analysis unavailable",
|
||||||
|
learningPath: [],
|
||||||
|
summary: "Unable to generate learning path at this time.",
|
||||||
|
motivation: "Keep learning!",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { result: validated.data }
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
173
src/modules/ai/services/content-safety.ts
Normal file
173
src/modules/ai/services/content-safety.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 内容安全过滤
|
||||||
|
*
|
||||||
|
* 多层防护:
|
||||||
|
* 1. 输入过滤:检查用户输入是否包含不当内容
|
||||||
|
* 2. 输出过滤:检查 AI 回复是否包含不当内容
|
||||||
|
* 3. 每日限制:按用户 + 日期计数
|
||||||
|
*
|
||||||
|
* 参考 Khanmigo 的多层 moderation 模式。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 不当内容关键词(基础过滤,生产环境应接入专业 Moderation API)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BLOCKED_INPUT_PATTERNS: readonly RegExp[] = [
|
||||||
|
/\b(violence|kill|murder|suicide|self[- ]?harm|cut myself)\b/i,
|
||||||
|
/\b(porn|sex|nude|nsfw|explicit)\b/i,
|
||||||
|
/\b(drug|cocaine|heroin|weed|marijuana)\b/i,
|
||||||
|
/\b(hack|exploit|malware|virus|phishing)\b/i,
|
||||||
|
// PII 请求
|
||||||
|
/\b(your (password|credit card|ssn|social security|bank account))\b/i,
|
||||||
|
/\b(home address|phone number|real name)\b/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
const BLOCKED_OUTPUT_PATTERNS: readonly RegExp[] = [
|
||||||
|
/\b(violence|kill|murder|suicide|self[- ]?harm)\b/i,
|
||||||
|
/\b(porn|sex|nude|nsfw|explicit)\b/i,
|
||||||
|
/\b(drug|cocaine|heroin)\b/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
const STUDENT_BLOCKED_PATTERNS: readonly RegExp[] = [
|
||||||
|
// 学生侧额外限制:禁止直接给出作业答案
|
||||||
|
/\b(here is the (complete )?answer|the answer is:?)\b/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 输入过滤
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type SafetyFilterResult = {
|
||||||
|
blocked: boolean
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filterUserInput = (
|
||||||
|
content: string,
|
||||||
|
options?: { isStudent?: boolean }
|
||||||
|
): SafetyFilterResult => {
|
||||||
|
const text = String(content ?? "")
|
||||||
|
|
||||||
|
for (const pattern of BLOCKED_INPUT_PATTERNS) {
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
return {
|
||||||
|
blocked: true,
|
||||||
|
reason: "Input contains inappropriate content",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.isStudent) {
|
||||||
|
// 学生侧额外检查
|
||||||
|
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
return {
|
||||||
|
blocked: true,
|
||||||
|
reason: "Student input blocked by safety filter",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blocked: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 输出过滤
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const filterAiOutput = (
|
||||||
|
content: string,
|
||||||
|
options?: { isStudent?: boolean }
|
||||||
|
): SafetyFilterResult => {
|
||||||
|
const text = String(content ?? "")
|
||||||
|
|
||||||
|
for (const pattern of BLOCKED_OUTPUT_PATTERNS) {
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
return {
|
||||||
|
blocked: true,
|
||||||
|
reason: "AI output contains inappropriate content",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.isStudent) {
|
||||||
|
for (const pattern of STUDENT_BLOCKED_PATTERNS) {
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
return {
|
||||||
|
blocked: true,
|
||||||
|
reason: "AI output blocked for student safety",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blocked: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 每日限制
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DAILY_LIMITS: Record<string, number> = {
|
||||||
|
student: 50,
|
||||||
|
teacher: 200,
|
||||||
|
parent: 30,
|
||||||
|
admin: 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDailyLimit = (role: string): number => {
|
||||||
|
return DAILY_LIMITS[role] ?? 50
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户今日 AI 使用次数
|
||||||
|
*
|
||||||
|
* 生产环境应接入 Redis 或数据库计数器。
|
||||||
|
* 当前实现为内存映射(单实例场景),多实例需替换为 Redis。
|
||||||
|
*/
|
||||||
|
const dailyUsageMap = new Map<string, { date: string; count: number }>()
|
||||||
|
|
||||||
|
export const checkDailyLimit = (userId: string, role: string): SafetyFilterResult => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
const key = `${userId}:${today}`
|
||||||
|
const limit = getDailyLimit(role)
|
||||||
|
const current = dailyUsageMap.get(key)
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
return { blocked: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.count >= limit) {
|
||||||
|
return {
|
||||||
|
blocked: true,
|
||||||
|
reason: `Daily limit reached (${current.count}/${limit})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blocked: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const incrementDailyUsage = (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 += 1
|
||||||
|
} else {
|
||||||
|
dailyUsageMap.set(key, { date: today, count: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理过期条目(防止内存泄漏)
|
||||||
|
if (dailyUsageMap.size > 10000) {
|
||||||
|
for (const [k, v] of dailyUsageMap.entries()) {
|
||||||
|
if (v.date !== today) {
|
||||||
|
dailyUsageMap.delete(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,3 +152,72 @@ export const JSON_REPAIR_SYSTEM_PROMPT = [
|
|||||||
"Do not use placeholders such as ... or [...].",
|
"Do not use placeholders such as ... or [...].",
|
||||||
"Return JSON only without markdown.",
|
"Return JSON only without markdown.",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 通用聊天(全局 AI 助手)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const CHAT_SYSTEM_PROMPT = [
|
||||||
|
"You are a helpful K12 education assistant for the Next_Edu school management system.",
|
||||||
|
"You assist teachers, students, parents, and administrators with their daily tasks.",
|
||||||
|
"Respond in the user's language (Chinese by default).",
|
||||||
|
"Use Markdown formatting for structured content (lists, tables, code blocks).",
|
||||||
|
"Be concise, accurate, and pedagogically sound.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 家长学情摘要
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const CHILD_SUMMARY_SYSTEM_PROMPT = [
|
||||||
|
"You are an expert K12 family education advisor.",
|
||||||
|
"Analyze the student's learning data and generate a summary for parents.",
|
||||||
|
"Return JSON only without markdown.",
|
||||||
|
"Output schema:",
|
||||||
|
"{",
|
||||||
|
' "overallAssessment": "brief overall assessment in parent-friendly language",',
|
||||||
|
' "strengths": ["strength 1", "strength 2"],',
|
||||||
|
' "areasForImprovement": ["area 1", "area 2"],',
|
||||||
|
' "familyTutoringSuggestions": ["suggestion 1", "suggestion 2"],',
|
||||||
|
' "nextSteps": ["actionable next step 1", "actionable next step 2"]',
|
||||||
|
"}",
|
||||||
|
"Rules:",
|
||||||
|
"- Use encouraging and constructive tone.",
|
||||||
|
"- Focus on actionable advice parents can follow at home.",
|
||||||
|
"- Avoid educational jargon; use plain language.",
|
||||||
|
"- Consider cultural sensitivity in family education.",
|
||||||
|
"- If data is limited, provide general guidance.",
|
||||||
|
"Never output placeholders.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 学习路径推荐
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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.",
|
||||||
|
"Return JSON only without markdown.",
|
||||||
|
"Output schema:",
|
||||||
|
"{",
|
||||||
|
' "currentLevel": "brief description of current level",',
|
||||||
|
' "learningPath": [',
|
||||||
|
" {",
|
||||||
|
' "step": 1,',
|
||||||
|
' "knowledgePoint": "knowledge point name",',
|
||||||
|
' "status": "mastered | in_progress | needs_work",',
|
||||||
|
' "recommendedAction": "specific action to take",',
|
||||||
|
' "estimatedTime": "15 min"',
|
||||||
|
" }",
|
||||||
|
" ],",
|
||||||
|
' "summary": "brief summary of the learning path",',
|
||||||
|
' "motivation": "encouraging message for the student"',
|
||||||
|
"}",
|
||||||
|
"Rules:",
|
||||||
|
"- Order learning path from foundational to advanced.",
|
||||||
|
"- Prioritize weak areas (mastery < 2) first.",
|
||||||
|
"- Include 3-7 steps in the learning path.",
|
||||||
|
"- estimatedTime should be realistic (5-30 min per step).",
|
||||||
|
"- motivation should be age-appropriate and encouraging.",
|
||||||
|
"Never output placeholders.",
|
||||||
|
].join("\n")
|
||||||
|
|||||||
@@ -129,6 +129,81 @@ export type WeaknessAnalysisResult = {
|
|||||||
recommendedResources: string[]
|
recommendedResources: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 家长学情摘要输入 */
|
||||||
|
export type ChildSummaryInput = {
|
||||||
|
studentId: string
|
||||||
|
studentName?: string
|
||||||
|
grade?: string
|
||||||
|
recentGrades?: Array<{
|
||||||
|
subject: string
|
||||||
|
score: number
|
||||||
|
maxScore: number
|
||||||
|
trend: "up" | "down" | "stable"
|
||||||
|
}>
|
||||||
|
attendanceRate?: number
|
||||||
|
errorBookSummary?: {
|
||||||
|
totalErrors: number
|
||||||
|
topWeakSubjects: string[]
|
||||||
|
masteryTrend: "improving" | "declining" | "stable"
|
||||||
|
}
|
||||||
|
homeworkCompletionRate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 家长学情摘要结果 */
|
||||||
|
export type ChildSummaryResult = {
|
||||||
|
overallAssessment: string
|
||||||
|
strengths: string[]
|
||||||
|
areasForImprovement: string[]
|
||||||
|
familyTutoringSuggestions: string[]
|
||||||
|
nextSteps: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学习路径推荐输入 */
|
||||||
|
export type StudyPathInput = {
|
||||||
|
studentId: string
|
||||||
|
subject?: string
|
||||||
|
currentMastery?: Array<{
|
||||||
|
knowledgePoint: string
|
||||||
|
masteryLevel: number
|
||||||
|
errorCount: number
|
||||||
|
}>
|
||||||
|
learningGoal?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学习路径推荐结果 */
|
||||||
|
export type StudyPathResult = {
|
||||||
|
currentLevel: string
|
||||||
|
learningPath: Array<{
|
||||||
|
step: number
|
||||||
|
knowledgePoint: string
|
||||||
|
status: "mastered" | "in_progress" | "needs_work"
|
||||||
|
recommendedAction: string
|
||||||
|
estimatedTime: string
|
||||||
|
}>
|
||||||
|
summary: string
|
||||||
|
motivation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 使用统计(管理员) */
|
||||||
|
export type AiUsageStats = {
|
||||||
|
totalCalls: number
|
||||||
|
callsToday: number
|
||||||
|
callsThisWeek: number
|
||||||
|
activeUsers: number
|
||||||
|
errorRate: number
|
||||||
|
avgDurationMs: number
|
||||||
|
byCapability: Array<{ capability: string; count: number }>
|
||||||
|
byRole: Array<{ role: string; count: number }>
|
||||||
|
topUsers: Array<{ userId: string; count: number }>
|
||||||
|
recentActivity: Array<{
|
||||||
|
userId: string
|
||||||
|
capability: string
|
||||||
|
success: boolean
|
||||||
|
durationMs: number
|
||||||
|
timestamp: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AI 能力配置(角色驱动)
|
// AI 能力配置(角色驱动)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -163,6 +238,8 @@ export interface AiService {
|
|||||||
generateLessonContent(input: LessonContentInput): Promise<LessonContentResult>
|
generateLessonContent(input: LessonContentInput): Promise<LessonContentResult>
|
||||||
generateQuestionVariant(input: QuestionVariantInput): Promise<QuestionVariantResult>
|
generateQuestionVariant(input: QuestionVariantInput): Promise<QuestionVariantResult>
|
||||||
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
|
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
|
||||||
|
generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult>
|
||||||
|
recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -189,6 +266,13 @@ export interface AiClientService {
|
|||||||
analyzeWeakness: (
|
analyzeWeakness: (
|
||||||
input: WeaknessAnalysisInput
|
input: WeaknessAnalysisInput
|
||||||
) => Promise<ActionState<WeaknessAnalysisResult>>
|
) => Promise<ActionState<WeaknessAnalysisResult>>
|
||||||
|
generateChildSummary?: (
|
||||||
|
input: ChildSummaryInput
|
||||||
|
) => Promise<ActionState<ChildSummaryResult>>
|
||||||
|
recommendStudyPath?: (
|
||||||
|
input: StudyPathInput
|
||||||
|
) => Promise<ActionState<StudyPathResult>>
|
||||||
|
getAiUsageStats?: () => Promise<ActionState<AiUsageStats>>
|
||||||
/** 预留埋点接口 */
|
/** 预留埋点接口 */
|
||||||
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
|
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,20 @@
|
|||||||
"inputLabel": "Message input",
|
"inputLabel": "Message input",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"thinking": "AI is thinking...",
|
"thinking": "AI is thinking...",
|
||||||
|
"streaming": "AI is typing...",
|
||||||
|
"stopGeneration": "Stop generating",
|
||||||
"maxReached": "Maximum messages reached",
|
"maxReached": "Maximum messages reached",
|
||||||
"clear": "Clear conversation"
|
"clear": "Clear conversation",
|
||||||
|
"clearConfirm": "Clear all messages?",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"suggestedPrompts": {
|
||||||
|
"title": "Try asking...",
|
||||||
|
"teacher": ["Help me grade this question", "Generate a classroom activity", "Create a quiz question"],
|
||||||
|
"student": ["Explain this concept", "Give me a practice question", "Help me study"],
|
||||||
|
"parent": ["How is my child doing?", "What should I focus on at home?"],
|
||||||
|
"admin": ["Show AI usage stats", "Which teachers use AI most?"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"label": "AI Provider",
|
"label": "AI Provider",
|
||||||
@@ -28,10 +40,13 @@
|
|||||||
"loaded": "Suggestions loaded",
|
"loaded": "Suggestions loaded",
|
||||||
"selected": "Suggestion selected",
|
"selected": "Suggestion selected",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"difficulty": "Difficulty"
|
"difficulty": "Difficulty",
|
||||||
|
"practiceNow": "Practice Now",
|
||||||
|
"addAll": "Add All"
|
||||||
},
|
},
|
||||||
"grading": {
|
"grading": {
|
||||||
"title": "AI Grading Suggestion",
|
"title": "AI Grading Suggestion",
|
||||||
|
"description": "AI-powered scoring and feedback for subjective questions",
|
||||||
"suggestedScore": "Suggested Score",
|
"suggestedScore": "Suggested Score",
|
||||||
"confidence": "Confidence",
|
"confidence": "Confidence",
|
||||||
"feedback": "Feedback",
|
"feedback": "Feedback",
|
||||||
@@ -40,7 +55,13 @@
|
|||||||
"applyFeedback": "Apply Feedback",
|
"applyFeedback": "Apply Feedback",
|
||||||
"loading": "AI is grading...",
|
"loading": "AI is grading...",
|
||||||
"error": "AI grading failed",
|
"error": "AI grading failed",
|
||||||
"notAvailable": "AI grading not available for this question type"
|
"notAvailable": "AI grading not available for this question type",
|
||||||
|
"batchTitle": "Batch AI Grading",
|
||||||
|
"batchDescription": "Generate AI suggestions for all subjective questions at once",
|
||||||
|
"batchGenerate": "Generate All Suggestions",
|
||||||
|
"batchProgress": "Processing {done}/{total}",
|
||||||
|
"currentScore": "Current Score",
|
||||||
|
"scoreDifference": "Difference"
|
||||||
},
|
},
|
||||||
"errorBook": {
|
"errorBook": {
|
||||||
"similarQuestions": "Similar Questions",
|
"similarQuestions": "Similar Questions",
|
||||||
@@ -56,11 +77,18 @@
|
|||||||
},
|
},
|
||||||
"lessonPrep": {
|
"lessonPrep": {
|
||||||
"generateContent": "Generate Content",
|
"generateContent": "Generate Content",
|
||||||
|
"description": "AI-powered teaching content generation",
|
||||||
"generateActivity": "Suggest Activity",
|
"generateActivity": "Suggest Activity",
|
||||||
"generateAssessment": "Generate Assessment",
|
"generateAssessment": "Generate Assessment",
|
||||||
"generateQuestion": "Generate Discussion Question",
|
"generateQuestion": "Generate Discussion Question",
|
||||||
"loading": "Generating...",
|
"loading": "Generating...",
|
||||||
"error": "Content generation failed"
|
"error": "Content generation failed",
|
||||||
|
"additionalContext": "Additional context",
|
||||||
|
"additionalContextPlaceholder": "Add any specific requirements or context...",
|
||||||
|
"insertContent": "Insert Content",
|
||||||
|
"editBeforeInsert": "Edit before insert",
|
||||||
|
"history": "Generation History",
|
||||||
|
"clearHistory": "Clear history"
|
||||||
},
|
},
|
||||||
"exam": {
|
"exam": {
|
||||||
"generate": "Generate",
|
"generate": "Generate",
|
||||||
@@ -81,7 +109,64 @@
|
|||||||
"sourceTextPlaceholder": "Paste the full exam text to parse into questions.",
|
"sourceTextPlaceholder": "Paste the full exam text to parse into questions.",
|
||||||
"sourceTextDesc": "AI will extract questions and structure from this text.",
|
"sourceTextDesc": "AI will extract questions and structure from this text.",
|
||||||
"generationTitle": "AI Generation",
|
"generationTitle": "AI Generation",
|
||||||
"generationDesc": "Paste the exam text and generate a structured preview."
|
"generationDesc": "Paste the exam text and generate a structured preview.",
|
||||||
|
"variantType": {
|
||||||
|
"label": "Variant type",
|
||||||
|
"same_knowledge_point": "Same knowledge point, different context",
|
||||||
|
"different_difficulty": "Different difficulty level",
|
||||||
|
"different_format": "Different question format"
|
||||||
|
},
|
||||||
|
"targetDifficulty": "Target difficulty",
|
||||||
|
"addVariant": "Add Variant"
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"summary": "AI Learning Summary",
|
||||||
|
"summaryDescription": "AI-generated overview of your child's learning progress",
|
||||||
|
"generateSummary": "Generate Summary",
|
||||||
|
"weaknessHint": "Areas to focus on",
|
||||||
|
"suggestion": "Family tutoring suggestion",
|
||||||
|
"loading": "Generating summary...",
|
||||||
|
"error": "Failed to generate summary"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"usageDashboard": "AI Usage Dashboard",
|
||||||
|
"dashboardDescription": "Monitor AI usage across the school",
|
||||||
|
"totalCalls": "Total AI Calls",
|
||||||
|
"activeUsers": "Active Users",
|
||||||
|
"costEstimate": "Estimated Cost",
|
||||||
|
"topUsers": "Top Users",
|
||||||
|
"byCapability": "By Capability",
|
||||||
|
"byRole": "By Role",
|
||||||
|
"recentActivity": "Recent Activity",
|
||||||
|
"noData": "No AI usage data available",
|
||||||
|
"callsToday": "Calls today",
|
||||||
|
"callsThisWeek": "Calls this week",
|
||||||
|
"errorRate": "Error rate",
|
||||||
|
"avgDuration": "Avg duration"
|
||||||
|
},
|
||||||
|
"studyPath": {
|
||||||
|
"title": "Your Learning Path",
|
||||||
|
"description": "AI-personalized learning recommendations",
|
||||||
|
"nextSteps": "Recommended Next Steps",
|
||||||
|
"mastered": "Mastered",
|
||||||
|
"inProgress": "In Progress",
|
||||||
|
"needsWork": "Needs Work",
|
||||||
|
"generate": "Generate Learning Path",
|
||||||
|
"loading": "Generating learning path...",
|
||||||
|
"error": "Failed to generate learning path",
|
||||||
|
"startLearning": "Start Learning"
|
||||||
|
},
|
||||||
|
"widget": {
|
||||||
|
"title": "AI Assistant",
|
||||||
|
"open": "Open AI Assistant",
|
||||||
|
"close": "Close",
|
||||||
|
"contextAware": "Context-aware"
|
||||||
|
},
|
||||||
|
"safety": {
|
||||||
|
"blocked": "Your message was blocked by the safety filter. Please keep the conversation educational.",
|
||||||
|
"dailyLimit": "Daily AI usage limit reached. Please try again tomorrow.",
|
||||||
|
"studentMode": "AI is in student mode. It will guide you to find the answer.",
|
||||||
|
"contentFiltered": "Inappropriate content was filtered from the AI response."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalidInput": "Invalid input data",
|
"invalidInput": "Invalid input data",
|
||||||
@@ -104,6 +189,8 @@
|
|||||||
"lessonContent": "AI Lesson Content",
|
"lessonContent": "AI Lesson Content",
|
||||||
"questionVariant": "AI Question Variant",
|
"questionVariant": "AI Question Variant",
|
||||||
"similarQuestion": "AI Similar Questions",
|
"similarQuestion": "AI Similar Questions",
|
||||||
"weaknessAnalysis": "AI Weakness Analysis"
|
"weaknessAnalysis": "AI Weakness Analysis",
|
||||||
|
"childSummary": "AI Child Summary",
|
||||||
|
"studyPath": "AI Study Path"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,20 @@
|
|||||||
"inputLabel": "消息输入",
|
"inputLabel": "消息输入",
|
||||||
"send": "发送",
|
"send": "发送",
|
||||||
"thinking": "AI 正在思考...",
|
"thinking": "AI 正在思考...",
|
||||||
|
"streaming": "AI 正在输入...",
|
||||||
|
"stopGeneration": "停止生成",
|
||||||
"maxReached": "已达到最大消息数",
|
"maxReached": "已达到最大消息数",
|
||||||
"clear": "清空对话"
|
"clear": "清空对话",
|
||||||
|
"clearConfirm": "确认清空所有消息?",
|
||||||
|
"copy": "复制",
|
||||||
|
"copied": "已复制!",
|
||||||
|
"suggestedPrompts": {
|
||||||
|
"title": "试试问我...",
|
||||||
|
"teacher": ["帮我批改这道题", "生成一个课堂活动", "创建一道测验题"],
|
||||||
|
"student": ["解释这个概念", "给我一道练习题", "帮我复习"],
|
||||||
|
"parent": ["我孩子学得怎么样?", "在家应该关注什么?"],
|
||||||
|
"admin": ["显示 AI 使用统计", "哪些老师最常使用 AI?"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"label": "AI 服务商",
|
"label": "AI 服务商",
|
||||||
@@ -28,10 +40,13 @@
|
|||||||
"loaded": "建议已加载",
|
"loaded": "建议已加载",
|
||||||
"selected": "已选择建议",
|
"selected": "已选择建议",
|
||||||
"select": "选择",
|
"select": "选择",
|
||||||
"difficulty": "难度"
|
"difficulty": "难度",
|
||||||
|
"practiceNow": "立即练习",
|
||||||
|
"addAll": "全部添加"
|
||||||
},
|
},
|
||||||
"grading": {
|
"grading": {
|
||||||
"title": "AI 批改建议",
|
"title": "AI 批改建议",
|
||||||
|
"description": "AI 驱动的主观题评分与反馈",
|
||||||
"suggestedScore": "建议分数",
|
"suggestedScore": "建议分数",
|
||||||
"confidence": "置信度",
|
"confidence": "置信度",
|
||||||
"feedback": "反馈",
|
"feedback": "反馈",
|
||||||
@@ -40,7 +55,13 @@
|
|||||||
"applyFeedback": "应用反馈",
|
"applyFeedback": "应用反馈",
|
||||||
"loading": "AI 批改中...",
|
"loading": "AI 批改中...",
|
||||||
"error": "AI 批改失败",
|
"error": "AI 批改失败",
|
||||||
"notAvailable": "此题型不支持 AI 批改"
|
"notAvailable": "此题型不支持 AI 批改",
|
||||||
|
"batchTitle": "批量 AI 批改",
|
||||||
|
"batchDescription": "一次性为所有主观题生成 AI 建议",
|
||||||
|
"batchGenerate": "生成全部建议",
|
||||||
|
"batchProgress": "处理中 {done}/{total}",
|
||||||
|
"currentScore": "当前分数",
|
||||||
|
"scoreDifference": "差值"
|
||||||
},
|
},
|
||||||
"errorBook": {
|
"errorBook": {
|
||||||
"similarQuestions": "相似题目",
|
"similarQuestions": "相似题目",
|
||||||
@@ -56,11 +77,18 @@
|
|||||||
},
|
},
|
||||||
"lessonPrep": {
|
"lessonPrep": {
|
||||||
"generateContent": "生成内容",
|
"generateContent": "生成内容",
|
||||||
|
"description": "AI 驱动的教学内容生成",
|
||||||
"generateActivity": "建议活动",
|
"generateActivity": "建议活动",
|
||||||
"generateAssessment": "生成评估",
|
"generateAssessment": "生成评估",
|
||||||
"generateQuestion": "生成讨论题",
|
"generateQuestion": "生成讨论题",
|
||||||
"loading": "生成中...",
|
"loading": "生成中...",
|
||||||
"error": "内容生成失败"
|
"error": "内容生成失败",
|
||||||
|
"additionalContext": "附加上下文",
|
||||||
|
"additionalContextPlaceholder": "添加特定要求或上下文信息...",
|
||||||
|
"insertContent": "插入内容",
|
||||||
|
"editBeforeInsert": "插入前编辑",
|
||||||
|
"history": "生成历史",
|
||||||
|
"clearHistory": "清空历史"
|
||||||
},
|
},
|
||||||
"exam": {
|
"exam": {
|
||||||
"generate": "生成",
|
"generate": "生成",
|
||||||
@@ -81,7 +109,64 @@
|
|||||||
"sourceTextPlaceholder": "粘贴试卷文本以解析为题目",
|
"sourceTextPlaceholder": "粘贴试卷文本以解析为题目",
|
||||||
"sourceTextDesc": "AI 将从文本中提取题目和结构。",
|
"sourceTextDesc": "AI 将从文本中提取题目和结构。",
|
||||||
"generationTitle": "AI 生成",
|
"generationTitle": "AI 生成",
|
||||||
"generationDesc": "粘贴试卷文本并生成结构化预览。"
|
"generationDesc": "粘贴试卷文本并生成结构化预览。",
|
||||||
|
"variantType": {
|
||||||
|
"label": "变体类型",
|
||||||
|
"same_knowledge_point": "同知识点,不同情境",
|
||||||
|
"different_difficulty": "不同难度",
|
||||||
|
"different_format": "不同题型"
|
||||||
|
},
|
||||||
|
"targetDifficulty": "目标难度",
|
||||||
|
"addVariant": "添加变体"
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"summary": "AI 学情摘要",
|
||||||
|
"summaryDescription": "AI 生成的子女学习进度概览",
|
||||||
|
"generateSummary": "生成摘要",
|
||||||
|
"weaknessHint": "需关注领域",
|
||||||
|
"suggestion": "家庭辅导建议",
|
||||||
|
"loading": "生成摘要中...",
|
||||||
|
"error": "生成摘要失败"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"usageDashboard": "AI 使用仪表盘",
|
||||||
|
"dashboardDescription": "监控全校 AI 使用情况",
|
||||||
|
"totalCalls": "AI 调用总数",
|
||||||
|
"activeUsers": "活跃用户",
|
||||||
|
"costEstimate": "预估成本",
|
||||||
|
"topUsers": "高频用户",
|
||||||
|
"byCapability": "按能力分类",
|
||||||
|
"byRole": "按角色分类",
|
||||||
|
"recentActivity": "最近活动",
|
||||||
|
"noData": "暂无 AI 使用数据",
|
||||||
|
"callsToday": "今日调用",
|
||||||
|
"callsThisWeek": "本周调用",
|
||||||
|
"errorRate": "错误率",
|
||||||
|
"avgDuration": "平均耗时"
|
||||||
|
},
|
||||||
|
"studyPath": {
|
||||||
|
"title": "你的学习路径",
|
||||||
|
"description": "AI 个性化学习建议",
|
||||||
|
"nextSteps": "推荐下一步",
|
||||||
|
"mastered": "已掌握",
|
||||||
|
"inProgress": "学习中",
|
||||||
|
"needsWork": "需要加强",
|
||||||
|
"generate": "生成学习路径",
|
||||||
|
"loading": "生成学习路径中...",
|
||||||
|
"error": "生成学习路径失败",
|
||||||
|
"startLearning": "开始学习"
|
||||||
|
},
|
||||||
|
"widget": {
|
||||||
|
"title": "AI 助手",
|
||||||
|
"open": "打开 AI 助手",
|
||||||
|
"close": "关闭",
|
||||||
|
"contextAware": "上下文感知"
|
||||||
|
},
|
||||||
|
"safety": {
|
||||||
|
"blocked": "您的消息被安全过滤器拦截,请保持教育性对话。",
|
||||||
|
"dailyLimit": "今日 AI 使用次数已达上限,请明天再试。",
|
||||||
|
"studentMode": "AI 处于学生模式,将引导你自主找到答案。",
|
||||||
|
"contentFiltered": "AI 回复中的不当内容已被过滤。"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalidInput": "输入数据无效",
|
"invalidInput": "输入数据无效",
|
||||||
@@ -104,6 +189,8 @@
|
|||||||
"lessonContent": "AI 备课内容",
|
"lessonContent": "AI 备课内容",
|
||||||
"questionVariant": "AI 题目变体",
|
"questionVariant": "AI 题目变体",
|
||||||
"similarQuestion": "AI 相似题",
|
"similarQuestion": "AI 相似题",
|
||||||
"weaknessAnalysis": "AI 薄弱点分析"
|
"weaknessAnalysis": "AI 薄弱点分析",
|
||||||
|
"childSummary": "AI 子女摘要",
|
||||||
|
"studyPath": "AI 学习路径"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,3 +65,31 @@ export const createAiChatCompletion = async (input: AiChatRequest) => {
|
|||||||
const usage = "usage" in result ? result.usage ?? null : null
|
const usage = "usage" in result ? result.usage ?? null : null
|
||||||
return { content, usage }
|
return { content, usage }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式 AI 聊天补全
|
||||||
|
*
|
||||||
|
* 返回 AsyncGenerator,逐 token 产出内容。
|
||||||
|
* 用于 SSE 流式响应,降低用户感知延迟。
|
||||||
|
*/
|
||||||
|
export async function* createAiChatCompletionStream(
|
||||||
|
input: AiChatRequest
|
||||||
|
): AsyncGenerator<string, void, unknown> {
|
||||||
|
const config = await getAiProviderConfig(input.providerId)
|
||||||
|
const client = await getAiClient(config)
|
||||||
|
const stream = await client.chat.completions.create({
|
||||||
|
model: config.model || input.model,
|
||||||
|
messages: input.messages,
|
||||||
|
temperature: input.temperature,
|
||||||
|
...(typeof input.maxTokens === "number" ? { max_tokens: input.maxTokens } : {}),
|
||||||
|
stream: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const delta = chunk.choices?.[0]?.delta
|
||||||
|
const content = extractMessageContent(delta)
|
||||||
|
if (content) {
|
||||||
|
yield content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { encryptAiApiKey, decryptAiApiKey } from "./api-key-crypto"
|
export { encryptAiApiKey, decryptAiApiKey } from "./api-key-crypto"
|
||||||
export { createAiChatCompletion, testAiProviderById, testAiProviderConfig } from "./client"
|
export { createAiChatCompletion, createAiChatCompletionStream, testAiProviderById, testAiProviderConfig } from "./client"
|
||||||
export { getAiErrorMessage } from "./errors"
|
export { getAiErrorMessage } from "./errors"
|
||||||
export { parseAiChatPayload, isRecord } from "./payload-parser"
|
export { parseAiChatPayload, isRecord } from "./payload-parser"
|
||||||
export type { AiChatRequest, ChatMessage, ChatRole } from "./payload-parser"
|
export type { AiChatRequest, ChatMessage, ChatRole } from "./payload-parser"
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type EventName =
|
|||||||
| "homework.auto_save_failed"
|
| "homework.auto_save_failed"
|
||||||
// AI 模块监控事件
|
// AI 模块监控事件
|
||||||
| "ai.chat"
|
| "ai.chat"
|
||||||
|
| "ai.chat_stream"
|
||||||
| "ai.similar_question"
|
| "ai.similar_question"
|
||||||
| "ai.grading_assist"
|
| "ai.grading_assist"
|
||||||
| "ai.lesson_content"
|
| "ai.lesson_content"
|
||||||
|
|||||||
Reference in New Issue
Block a user