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:
SpecialX
2026-06-23 01:34:37 +08:00
parent a60105455e
commit 4da9194a5e
27 changed files with 3522 additions and 172 deletions

View File

@@ -430,6 +430,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- `cn()` / `formatDate()` / `formatFileSize()` — 通用工具
- `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`
- `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 重构新增,按类别组织):
@@ -601,6 +603,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ V3-8新增 `getHomeworkAssignmentsByExamId` + `getGradedSubmissionsByExamId`,供 exams 模块跨模块调用
- ✅ V3-9新增 `getStudentSubmissionResult` + `HomeworkSubmissionResult` + 路由 `/student/learning/assignments/[assignmentId]/result``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 复用)
- Data-access`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend` / `PaginatedGradeRecords`(✅ P3 新增:分页结果接口 `{ records, total }`
- 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 }`/ `saveGradeDraft` / `getGradeDraft` / `deleteGradeDraft`(✅ v3-P2 新增:成绩录入草稿 CRUDupsert + 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 参数)
- 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
@@ -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~~`grade-trend-card.tsx` 排序未处理 NaN 日期、除法未检查 fullScore > 0~~ 新增日期有效性检查与 `fullScore > 0` 守卫
- ✅ P3 修复2026-06-22~~页面文件缺少 scope 传递~~ teacher/grades/page.tsx 使用 DB 层分页analytics/page.tsx 添加 EmptyStateentry/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 存储 scores24 小时过期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添加学期和考试筛选 ChipNavteacher/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-23parent/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 校验) |
| `data-access.ts` | 428 | 成绩 CRUD + 统计(含 v2-P2-9 修复recorderName 批量查询P3 修复PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名) |
| `data-access-analytics.ts` | 200 | 趋势/对比分析P3 修复getClassComparison 应用 buildScopeClassFilter |
| `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 应用 buildScopeClassFilterv3-P2 新增getExamOptionsForGrades/getSchoolWideGradeSummarygetGradeTrend/getClassComparison/getSubjectComparison/getGradeDistribution 新增 semester/examId 可选参数 |
| `data-access-ranking.ts` | 83 | 排名查询P3 修复getRankingTrend 接受 scope 参数 + class_taught 校验) |
| `stats-service.ts` | 279 | 统计计算纯函数P1-1 新增8 个纯函数 + 2 个常量 + 2 个接口) |
| `export.ts` | 189 | Excel 导出v2-P1-5 修复:传递 currentUserId 到 data-accessP3 修复:适配 PaginatedGradeRecords 结构 + 传递 scope |
| `schema.ts` | 113 | Zod 校验(含 12 个查询 schemaP3 修复score .max(1000) + records .max(500) + 补全查询字段) |
| `schema.ts` | 113+ | Zod 校验(含 12 个查询 schemaP3 修复score .max(1000) + records .max(500) + 补全查询字段v3-P2 新增grade_drafts 表定义第 1444-1469 行 |
| `lib/grade-utils.ts` | 66 | 公共工具函数toNumber/normalize/buildScopeClassFilterv2-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/school-wide-summary-card.tsx` | - | v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格) |
| `components/grade-trend-card.tsx` | 69 | 趋势卡片v2-P2-9 修复a11yv2-P1-4i18nP3 修复NaN 日期检查 + fullScore > 0 守卫) |
| `components/grade-record-list.tsx` | 125 | 成绩记录列表v2-P1-4i18nP3 修复safeActionCall 包装删除操作) |
| `components/grade-distribution-chart.tsx` | 100 | 分数分布图v2-P1-4i18n |
@@ -775,16 +786,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `components/class-comparison-chart.tsx` | 58 | 班级对比图v2-P1-4i18n |
| `components/grade-trend-chart.tsx` | 59 | 趋势图v2-P1-4i18n |
| `components/grade-record-form.tsx` | 177 | 录入表单v2-P2-7 修复Label htmlForv2-P1-4i18nP3 修复safeActionCall 包装提交) |
| `components/batch-grade-entry.tsx` | 435 | 批量录入v2-P2-7 修复Label htmlForv2-P1-4i18nP3 修复safeActionCall + localStorage 安全检查 + 区分未录入与录入 0 |
| `components/batch-grade-entry.tsx` | 435+ | 批量录入v2-P2-7 修复Label htmlForv2-P1-4i18nP3 修复safeActionCall + localStorage 安全检查 + 区分未录入与录入 0v3-P2 新增:接入服务端草稿 saveGradeDraftAction/getGradeDraftAction/deleteGradeDraftAction |
| `components/grade-filters.tsx` | 76 | 过滤器v2-P1-4i18n |
| `components/student-grade-summary.tsx` | 107 | 学生成绩摘要v2-P1-4i18n |
| `components/export-button.tsx` | 79 | 导出按钮v2-P1-4i18nP3 修复safeActionCall 包装导出操作) |
| `components/analytics-filters.tsx` | 86 | 分析过滤器v2-P1-4i18n |
| `components/analytics-filters.tsx` | 86+ | 分析过滤器v2-P1-4i18nv3-P2 新增exams/currentExamId/currentSemester props添加学期和考试筛选 ChipNav |
| `components/stats-class-selector.tsx` | 40 | 统计班级选择器v2-P1-4i18n |
| `components/grade-stats-card.tsx` | 74 | 统计卡片v2-P1-4i18n |
| `components/class-grade-report.tsx` | 90 | 班级成绩报告v2-P1-4i18n |
| `components/grade-query-filters.tsx` | 96 | 查询过滤器v2-P2-7 修复Label htmlForv2-P1-4i18n |
| `types.ts` | 168 | 类型定义 |
---
@@ -1362,6 +1372,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ⚠️ v4 保留:`/parent/leave` 为占位页,待后端实现请假审批流后接入
- ⚠️ v4 保留:`ParentExportButton` 为占位,待后端实现成绩导出 Server Action 后接入
- ⚠️ v4 保留:详情页 Attendance/Diagnostic Tab 为占位提示,待对应功能实现后填充
- ✅ v3-P2 改进2026-06-23parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCardparent/diagnostic/page.tsx 传入 `practiceHrefBase={null}` 隐藏练习按钮
- ✅ 职责单一,正确复用其他模块 data-access
**文件清单**
@@ -1390,8 +1401,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| 路由 | 文件 | 说明 |
|------|------|------|
| `/parent/dashboard` | `dashboard/page.tsx` + `loading.tsx` | 家长仪表盘 |
| `/parent/grades` | `grades/page.tsx` + `loading.tsx` | 多子女成绩聚合 |
| `/parent/diagnostic` | `diagnostic/page.tsx` + `loading.tsx` + `error.tsx` | P2-5 新增:多子女学情诊断聚合 |
| `/parent/grades` | `grades/page.tsx` + `loading.tsx` | 多子女成绩聚合v3-P2并行查询 getClassAverageTrend + GradeTrendCard |
| `/parent/diagnostic` | `diagnostic/page.tsx` + `loading.tsx` + `error.tsx` | P2-5 新增:多子女学情诊断聚合v3-P2practiceHrefBase={null} 隐藏练习按钮) |
| `/parent/attendance` | `attendance/page.tsx` + `loading.tsx` | 多子女考勤聚合v4 新增预警横幅) |
| `/parent/leave` | `leave/page.tsx` + `loading.tsx` | v4 新增:请假申请(占位) |
| `/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-P2-7 已修复:~~`report-list.tsx` 过滤器 Label 缺少 `htmlFor`~~ 添加 `htmlFor``id`
- ✅ 与 grades 模块无职责重叠
- ✅ v3-P2 改进2026-06-23`StudentDiagnosticView` 新增 `practiceHrefBase` propstring | nullnull 时隐藏练习按钮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 个 schemav2-P2-3 删除 2 个死代码 schema |
| `types.ts` | 87 | 类型定义 |
| `components/class-diagnostic-view.tsx` | 266 | 班级诊断视图v2-P1-6 热力图 a11yv2-P1-4 i18n |
| `components/student-diagnostic-view.tsx` | 225 | 学生诊断视图v2-P1-4 i18n |
| `components/student-diagnostic-view.tsx` | 225+ | 学生诊断视图v2-P1-4 i18nv3-P2 新增practiceHrefBase propnull 时隐藏练习按钮 |
| `components/mastery-radar-chart.tsx` | 72 | 雷达图v2-P1-4 i18n |
| `components/report-list.tsx` | 265 | 报告列表v2-P2-7 Label htmlForv2-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-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 aiAI 模块)— ✅ 新增
## 2.29 aiAI 模块)— ✅ 新增 / V2 增强
**职责**:统一 AI 能力封装,为备课、错题集、试卷、改题等业务模块提供 AI 服务。
**职责**:统一 AI 能力封装,为备课、错题集、试卷、改题等业务模块提供 AI 服务。V2 增加流式响应、Markdown 渲染、全局助手、内容安全过滤、家长学情摘要、管理员使用统计、学生学习路径推荐。
**架构定位**
- 位于 `modules/` 层,通过 `shared/lib/ai` 调用底层 AI SDK
- 通过 `AiClientProvider`React Context向客户端组件注入 Server Action 引用
- 业务模块不直接 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** | `generateQuestionVariantAction` | `modules/ai/actions.ts` | 题目变体生成权限AI_CHAT + EXAM_AI_GENERATE |
| **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 引用集合) |
| **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** | `useAiClientOptional` | `modules/ai/context/ai-client-provider.tsx` | 可选消费 AiClientService未注入返回 null |
| **Hook** | `useAiChatStream` | `modules/ai/hooks/use-ai-chat-stream.ts` | 流式 AI 对话 HookSSE + 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** | `AiErrorBookAnalysis` | `modules/ai/components/ai-error-book-analysis.tsx` | 错题本 AI 分析(相似题 + 薄弱点) |
| **Component** | `AiLessonContentGenerator` | `modules/ai/components/ai-lesson-content-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** | `AiSuggestionSkeleton` | `modules/ai/components/ai-skeleton.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` | 相似题推荐 + 薄弱点分析 |
| lesson-preparation | `AiLessonContentGenerator` | `teacher/lesson-plans/[planId]/edit` | 备课内容生成 |
| 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/track-event`(使用量埋点)
- `modules/ai``shared/types/permissions`(权限常量)
- `modules/ai``shared/types/action-state`(返回值类型)
- `app/(dashboard)/layout``modules/ai`(全局 Provider + Widget— V2 新增
- 业务模块 → `modules/ai/context/ai-client-provider`(通过 Context 注入)
- 业务模块 → `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/schema.ts` | - | Zod 验证 schema |
| `modules/ai/actions.ts` | 244 | 6 个 Server Actions含权限校验 |
| `modules/ai/services/ai-service.ts` | - | DefaultAiService 实现 |
| `modules/ai/services/prompt-templates.ts` | - | 6 个系统提示词模板 |
| `modules/ai/services/usage-tracker.ts` | - | AI 使用量埋点 |
| `modules/ai/context/ai-client-provider.tsx` | 59 | React Context Provider + Hooks |
| `modules/ai/types.ts` | ~270 | 类型定义(8 个业务场景类型 + AiService/AiClientService |
| `modules/ai/schema.ts` | ~205 | Zod 验证 schema8 个输入 + 8 个输出) |
| `modules/ai/actions.ts` | ~340 | 9 个 Server Actions含权限校验 |
| `modules/ai/services/ai-service.ts` | ~400 | DefaultAiService 实现8 个方法) |
| `modules/ai/services/prompt-templates.ts` | ~210 | 8 个系统提示词模板 |
| `modules/ai/services/usage-tracker.ts` | ~83 | AI 使用量埋点 |
| `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-error-book-analysis.tsx` | 246 | 错题本 AI 分析组件 |
| `modules/ai/components/ai-lesson-content-generator.tsx` | - | 备课内容生成器 |
| `modules/ai/components/ai-question-variant-generator.tsx` | - | 题目变体生成器 |
| `modules/ai/components/ai-chat-panel.tsx` | - | AI 对话面板 |
| `modules/ai/components/ai-error-boundary.tsx` | - | AI 错误边界 |
| `modules/ai/components/ai-skeleton.tsx` | - | AI 骨架屏 |
| `modules/ai/components/ai-provider-selector.tsx` | - | 服务商选择器 |
| `modules/ai/hooks/use-ai-chat.ts` | - | AI 对话 Hook |
| `modules/ai/hooks/use-ai-suggestion.ts` | - | AI 建议 Hook |
| `modules/ai/components/ai-lesson-content-generator.tsx` | ~187 | 备课内容生成器 |
| `modules/ai/components/ai-question-variant-generator.tsx` | ~208 | 题目变体生成器 |
| `modules/ai/components/ai-error-boundary.tsx` | ~88 | AI 错误边界 |
| `modules/ai/components/ai-skeleton.tsx` | ~47 | AI 骨架屏 |
| `modules/ai/components/ai-provider-selector.tsx` | ~129 | 服务商选择器 |
| `modules/ai/hooks/use-ai-chat-stream.ts` | ~170 | 流式 AI 对话 Hook — V2 新增 |
| `modules/ai/hooks/use-ai-chat.ts` | ~57 | 非流式 AI 对话 Hook |
| `modules/ai/hooks/use-ai-suggestion.ts` | ~72 | AI 建议 Hook |
| `app/api/ai/chat/stream/route.ts` | ~160 | SSE 流式端点 — V2 新增 |
**i18n**
- 翻译文件:`shared/i18n/messages/{locale}/ai.json`
- 命名空间:`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/finally20+ 处,使用 `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模块间依赖矩阵
> 行表示使用方,列表示被使用方。`✅` 合理依赖,`❌` 违规直查,`⟳` 循环依赖。

File diff suppressed because one or more lines are too long

View 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 Technuggets 微内容 + 自适应路径
- 共同点:诊断 → 路径 → 练习 → 复习 → 再诊断的闭环
**我们的差距**
- 错题本 AI 分析是一次性的,不持久化
- AI 生成的相似题不进入 SM2 复习队列
- 无知识图谱可视化
- 无自适应难度
**影响**AI 价值未形成闭环;学生缺少个性化学习引导
#### 差距 5无家长/管理员 AI 功能
**行业做法**
- Khanmigo家长可查看子女聊天记录学区管理员有 dashboard
- Squirrel AI24/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 EventsSSE而非 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. 调用 AIservice.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 教育场景的安全合规要求。

View File

@@ -1,6 +1,34 @@
import { AppSidebar } from "@/modules/layout/components/app-sidebar"
import { SidebarProvider } from "@/modules/layout/components/sidebar-provider"
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({
children,
@@ -8,14 +36,17 @@ export default function DashboardLayout({
children: React.ReactNode
}) {
return (
<SidebarProvider sidebar={<AppSidebar />}>
<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">
Skip to main content
</a>
<SiteHeader />
<main id="main-content" className="flex-1 overflow-auto p-6">
{children}
</main>
</SidebarProvider>
<AiClientProvider service={aiClientService}>
<SidebarProvider sidebar={<AppSidebar />}>
<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">
Skip to main content
</a>
<SiteHeader />
<main id="main-content" className="flex-1 overflow-auto p-6">
{children}
</main>
<AiAssistantWidget />
</SidebarProvider>
</AiClientProvider>
)
}

View 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" },
})
}
}

View File

@@ -14,6 +14,8 @@ import {
QuestionVariantInputSchema,
SimilarQuestionInputSchema,
WeaknessAnalysisInputSchema,
ChildSummaryInputSchema,
StudyPathInputSchema,
} from "./schema"
import type {
AiChatMessage,
@@ -28,6 +30,11 @@ import type {
SimilarQuestionResult,
WeaknessAnalysisInput,
WeaknessAnalysisResult,
ChildSummaryInput,
ChildSummaryResult,
StudyPathInput,
StudyPathResult,
AiUsageStats,
} from "./types"
// ---------------------------------------------------------------------------
@@ -242,3 +249,94 @@ export async function analyzeWeaknessAction(
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") }
}
}

View 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,
}
}

View File

@@ -2,15 +2,22 @@
import { useState, useRef, useEffect, useCallback } from "react"
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 { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip"
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"
type AiChatPanelProps = {
@@ -24,13 +31,25 @@ type AiChatPanelProps = {
title?: string
/** 最大消息数 */
maxMessages?: number
/** 是否启用流式响应(默认 true */
streaming?: boolean
/** 建议提示词列表(空状态展示) */
suggestedPrompts?: string[]
}
/**
* AI 聊天面板
*
* 通用 AI 对话组件,可嵌入任何页面。
* 通过 useAiClient() 获取 Server Action 引用,不直接 import actions。
* V2 增强:
* - 流式响应SSE逐 token 渲染
* - Markdown 渲染(代码块、表格、列表)
* - 复制按钮
* - 停止生成按钮
* - 清除对话按钮
* - 建议提示词
* - aria-live 无障碍
* - 对话历史持久化localStorage
*/
export function AiChatPanel({
systemPrompt,
@@ -38,63 +57,68 @@ export function AiChatPanel({
placeholder,
title,
maxMessages = 50,
streaming: _streamingEnabled = true,
suggestedPrompts,
}: AiChatPanelProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [messages, setMessages] = useState<AiChatMessage[]>([])
const { messages, streaming, error, send, stop, clear } = useAiChatStream()
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
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(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages])
const handleSend = useCallback(async (): Promise<void> => {
const trimmed = input.trim()
if (!trimmed || loading || messages.length >= maxMessages) return
const handleSend = useCallback(
async (content?: string): Promise<void> => {
const trimmed = (content ?? input).trim()
if (!trimmed || streaming || messages.length >= maxMessages) return
const userMessage: AiChatMessage = { role: "user", content: trimmed }
const contextPrefix = contextMessage
? `Context:\n${contextMessage}\n\nUser question: ${trimmed}`
: trimmed
const systemMessage: AiChatMessage | null = systemPrompt
? { role: "system", content: systemPrompt }
: null
const contextPrefix = contextMessage
? `Context:\n${contextMessage}\n\nUser question: ${trimmed}`
: trimmed
const requestMessages: AiChatMessage[] = [
...(systemMessage ? [systemMessage] : []),
...messages,
{ role: "user" as const, content: contextPrefix },
]
const requestMessages: AiChatMessage[] = [
...messages,
{ role: "user", content: contextPrefix },
]
setInput("")
setLoading(true)
setMessages((prev) => [...prev, userMessage])
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])
setInput("")
await send(requestMessages, { systemPrompt })
},
[input, streaming, messages, maxMessages, systemPrompt, contextMessage, send]
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
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 />
}
const defaultSuggestedPrompts = suggestedPrompts ?? [
t("chat.suggestedPrompts.teacher.0"),
t("chat.suggestedPrompts.teacher.1"),
t("chat.suggestedPrompts.teacher.2"),
]
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
{title ?? t("chat.title")}
</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
{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>
<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 ? (
<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}>
{messages.map((message, index) => (
<div
@@ -136,21 +216,50 @@ export function AiChatPanel({
: "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>
))}
{loading ? (
{streaming ? (
<div className="flex gap-2 justify-start">
<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">
<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>
) : null}
</div>
</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">
<Textarea
value={input}
@@ -158,18 +267,30 @@ export function AiChatPanel({
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("chat.placeholder")}
className="min-h-[60px] resize-none"
disabled={loading || messages.length >= maxMessages}
disabled={streaming || messages.length >= maxMessages}
aria-label={t("chat.inputLabel")}
/>
<Button
type="button"
size="icon"
onClick={() => void handleSend()}
disabled={!input.trim() || loading || messages.length >= maxMessages}
aria-label={t("chat.send")}
>
<Send className="h-4 w-4" />
</Button>
{streaming ? (
<Button
type="button"
size="icon"
variant="destructive"
onClick={stop}
aria-label={t("chat.stopGeneration")}
>
<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>
{messages.length >= maxMessages ? (
<p className="text-xs text-muted-foreground text-center">

View 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>
)
}

View File

@@ -94,7 +94,7 @@ export function AiGradingAssist({
<Sparkles className="h-4 w-4 text-primary" />
{t("grading.title")}
</CardTitle>
<CardDescription>{t("grading.title")}</CardDescription>
<CardDescription>{t("grading.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{loading ? (

View File

@@ -31,13 +31,6 @@ type AiLessonContentGeneratorProps = {
onInsertContent?: (result: LessonContentResult) => void
}
const CONTENT_TYPE_ICONS: Record<ContentType, typeof Sparkles> = {
activity: Lightbulb,
assessment: FileText,
question: HelpCircle,
material: BookOpen,
}
/**
* AI 备课内容生成器
*
@@ -105,7 +98,7 @@ export function AiLessonContentGenerator({
<Sparkles className="h-4 w-4 text-primary" />
{t("lessonPrep.generateContent")}
</CardTitle>
<CardDescription>{t("lessonPrep.generateContent")}</CardDescription>
<CardDescription>{t("lessonPrep.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 内容类型选择 */}
@@ -128,13 +121,13 @@ export function AiLessonContentGenerator({
{/* 附加上下文 */}
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor="ai-additional-context">
{t("lessonPrep.generateContent")}
{t("lessonPrep.additionalContext")}
</label>
<Textarea
id="ai-additional-context"
value={additionalContext}
onChange={(e) => setAdditionalContext(e.target.value)}
placeholder={t("lessonPrep.generateContent")}
placeholder={t("lessonPrep.additionalContextPlaceholder")}
className="min-h-[60px] text-sm"
maxLength={500}
/>
@@ -172,10 +165,10 @@ export function AiLessonContentGenerator({
size="sm"
onClick={() => {
onInsertContent(result)
toast.success(t("lessonPrep.generateContent"))
toast.success(t("lessonPrep.insertContent"))
}}
>
{t("lessonPrep.generateContent")}
{t("lessonPrep.insertContent")}
</Button>
) : null}
</div>

View 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)

View File

@@ -84,9 +84,9 @@ export function AiQuestionVariantGenerator({
}
const variantTypeLabels: Record<VariantType, string> = {
same_knowledge_point: t("exam.generate"),
different_difficulty: t("exam.generate"),
different_format: t("exam.generate"),
same_knowledge_point: t("exam.variantType.same_knowledge_point"),
different_difficulty: t("exam.variantType.different_difficulty"),
different_format: t("exam.variantType.different_format"),
}
return (
@@ -97,7 +97,7 @@ export function AiQuestionVariantGenerator({
<Sparkles className="h-4 w-4 text-primary" />
{t("capability.questionVariant")}
</CardTitle>
<CardDescription>{t("exam.generate")}</CardDescription>
<CardDescription>{t("exam.variantType.label")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* 变体类型选择 */}
@@ -186,7 +186,7 @@ export function AiQuestionVariantGenerator({
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
{t("exam.generate")}
{t("exam.addVariant")}
</Button>
) : null}
<Button

View 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>
)
}

View File

@@ -2,7 +2,7 @@
import { useState } from "react"
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 { Button } from "@/shared/components/ui/button"

View 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>
)
}

View 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 }
}

View File

@@ -132,3 +132,74 @@ export const WeaknessAnalysisResultSchema = z.object({
studyPlan: z.string().min(1),
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),
})

View File

@@ -9,6 +9,8 @@ import {
QUESTION_VARIANT_SYSTEM_PROMPT,
SIMILAR_QUESTION_SYSTEM_PROMPT,
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
CHILD_SUMMARY_SYSTEM_PROMPT,
STUDY_PATH_SYSTEM_PROMPT,
} from "./prompt-templates"
import { withAiTracking } from "./usage-tracker"
import {
@@ -17,6 +19,8 @@ import {
QuestionVariantResultSchema,
SimilarQuestionListSchema,
WeaknessAnalysisResultSchema,
ChildSummaryResultSchema,
StudyPathResultSchema,
} from "../schema"
import type {
AiChatMessage,
@@ -33,6 +37,10 @@ import type {
SimilarQuestionResult,
WeaknessAnalysisInput,
WeaknessAnalysisResult,
ChildSummaryInput,
ChildSummaryResult,
StudyPathInput,
StudyPathResult,
} from "../types"
// ---------------------------------------------------------------------------
@@ -320,6 +328,76 @@ export class DefaultAiService implements AiService {
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 }
})
}
}
/**

View 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)
}
}
}
}

View File

@@ -152,3 +152,72 @@ export const JSON_REPAIR_SYSTEM_PROMPT = [
"Do not use placeholders such as ... or [...].",
"Return JSON only without markdown.",
].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")

View File

@@ -129,6 +129,81 @@ export type WeaknessAnalysisResult = {
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 能力配置(角色驱动)
// ---------------------------------------------------------------------------
@@ -163,6 +238,8 @@ export interface AiService {
generateLessonContent(input: LessonContentInput): Promise<LessonContentResult>
generateQuestionVariant(input: QuestionVariantInput): Promise<QuestionVariantResult>
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult>
recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult>
}
/**
@@ -189,6 +266,13 @@ export interface AiClientService {
analyzeWeakness: (
input: WeaknessAnalysisInput
) => 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
}

View File

@@ -5,8 +5,20 @@
"inputLabel": "Message input",
"send": "Send",
"thinking": "AI is thinking...",
"streaming": "AI is typing...",
"stopGeneration": "Stop generating",
"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": {
"label": "AI Provider",
@@ -28,10 +40,13 @@
"loaded": "Suggestions loaded",
"selected": "Suggestion selected",
"select": "Select",
"difficulty": "Difficulty"
"difficulty": "Difficulty",
"practiceNow": "Practice Now",
"addAll": "Add All"
},
"grading": {
"title": "AI Grading Suggestion",
"description": "AI-powered scoring and feedback for subjective questions",
"suggestedScore": "Suggested Score",
"confidence": "Confidence",
"feedback": "Feedback",
@@ -40,7 +55,13 @@
"applyFeedback": "Apply Feedback",
"loading": "AI is grading...",
"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": {
"similarQuestions": "Similar Questions",
@@ -56,11 +77,18 @@
},
"lessonPrep": {
"generateContent": "Generate Content",
"description": "AI-powered teaching content generation",
"generateActivity": "Suggest Activity",
"generateAssessment": "Generate Assessment",
"generateQuestion": "Generate Discussion Question",
"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": {
"generate": "Generate",
@@ -81,7 +109,64 @@
"sourceTextPlaceholder": "Paste the full exam text to parse into questions.",
"sourceTextDesc": "AI will extract questions and structure from this text.",
"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": {
"invalidInput": "Invalid input data",
@@ -104,6 +189,8 @@
"lessonContent": "AI Lesson Content",
"questionVariant": "AI Question Variant",
"similarQuestion": "AI Similar Questions",
"weaknessAnalysis": "AI Weakness Analysis"
"weaknessAnalysis": "AI Weakness Analysis",
"childSummary": "AI Child Summary",
"studyPath": "AI Study Path"
}
}

View File

@@ -5,8 +5,20 @@
"inputLabel": "消息输入",
"send": "发送",
"thinking": "AI 正在思考...",
"streaming": "AI 正在输入...",
"stopGeneration": "停止生成",
"maxReached": "已达到最大消息数",
"clear": "清空对话"
"clear": "清空对话",
"clearConfirm": "确认清空所有消息?",
"copy": "复制",
"copied": "已复制!",
"suggestedPrompts": {
"title": "试试问我...",
"teacher": ["帮我批改这道题", "生成一个课堂活动", "创建一道测验题"],
"student": ["解释这个概念", "给我一道练习题", "帮我复习"],
"parent": ["我孩子学得怎么样?", "在家应该关注什么?"],
"admin": ["显示 AI 使用统计", "哪些老师最常使用 AI"]
}
},
"provider": {
"label": "AI 服务商",
@@ -28,10 +40,13 @@
"loaded": "建议已加载",
"selected": "已选择建议",
"select": "选择",
"difficulty": "难度"
"difficulty": "难度",
"practiceNow": "立即练习",
"addAll": "全部添加"
},
"grading": {
"title": "AI 批改建议",
"description": "AI 驱动的主观题评分与反馈",
"suggestedScore": "建议分数",
"confidence": "置信度",
"feedback": "反馈",
@@ -40,7 +55,13 @@
"applyFeedback": "应用反馈",
"loading": "AI 批改中...",
"error": "AI 批改失败",
"notAvailable": "此题型不支持 AI 批改"
"notAvailable": "此题型不支持 AI 批改",
"batchTitle": "批量 AI 批改",
"batchDescription": "一次性为所有主观题生成 AI 建议",
"batchGenerate": "生成全部建议",
"batchProgress": "处理中 {done}/{total}",
"currentScore": "当前分数",
"scoreDifference": "差值"
},
"errorBook": {
"similarQuestions": "相似题目",
@@ -56,11 +77,18 @@
},
"lessonPrep": {
"generateContent": "生成内容",
"description": "AI 驱动的教学内容生成",
"generateActivity": "建议活动",
"generateAssessment": "生成评估",
"generateQuestion": "生成讨论题",
"loading": "生成中...",
"error": "内容生成失败"
"error": "内容生成失败",
"additionalContext": "附加上下文",
"additionalContextPlaceholder": "添加特定要求或上下文信息...",
"insertContent": "插入内容",
"editBeforeInsert": "插入前编辑",
"history": "生成历史",
"clearHistory": "清空历史"
},
"exam": {
"generate": "生成",
@@ -81,7 +109,64 @@
"sourceTextPlaceholder": "粘贴试卷文本以解析为题目",
"sourceTextDesc": "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": {
"invalidInput": "输入数据无效",
@@ -104,6 +189,8 @@
"lessonContent": "AI 备课内容",
"questionVariant": "AI 题目变体",
"similarQuestion": "AI 相似题",
"weaknessAnalysis": "AI 薄弱点分析"
"weaknessAnalysis": "AI 薄弱点分析",
"childSummary": "AI 子女摘要",
"studyPath": "AI 学习路径"
}
}

View File

@@ -65,3 +65,31 @@ export const createAiChatCompletion = async (input: AiChatRequest) => {
const usage = "usage" in result ? result.usage ?? null : null
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
}
}
}

View File

@@ -1,5 +1,5 @@
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 { parseAiChatPayload, isRecord } from "./payload-parser"
export type { AiChatRequest, ChatMessage, ChatRole } from "./payload-parser"

View File

@@ -60,6 +60,7 @@ export type EventName =
| "homework.auto_save_failed"
// AI 模块监控事件
| "ai.chat"
| "ai.chat_stream"
| "ai.similar_question"
| "ai.grading_assist"
| "ai.lesson_content"