feat(ai): 新增 AI 模块并集成至备课/错题集/试卷/改题四大业务场景
- 新增 src/modules/ai 独立模块,遵循三层架构(actions → services → shared/lib/ai) - 通过 AiClientProvider + useAiClient 实现 React Context 依赖注入,业务组件零直接 import - 6 个 Server Actions 均调用 requirePermission() 权限校验,返回 ActionState<T> - withAiTracking 统一埋点,覆盖 chat/similar_question/grading_assist/lesson_content/question_variant/weakness_analysis - 集成场景:作业批改 AiGradingAssist、错题集 AiErrorBookAnalysis、备课 AiLessonContentGenerator、试卷 AiQuestionVariantGenerator - 全量 i18n(en/zh-CN ai.json),Error Boundary + Skeleton 边界处理 - 同步架构图 004/005,新增审计报告 ai-module-audit-report.md
This commit is contained in:
@@ -1686,20 +1686,30 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
|
||||
> 架构变更(2026-06-21):编辑器从列表式(BlockRenderer + @dnd-kit)升级为节点图式(NodeEditor + @xyflow/react)。数据结构从 v1(blocks 数组)升级到 v2(nodes + edges 节点图),旧数据通过 `migrateV1ToV2()` 自动迁移。
|
||||
|
||||
> 架构变更(2026-06-23):数据结构从 v2 升级到 v3(课文锚点画布格式)。正文节点(textbook_content)作为画布中央核心,教学节点围绕正文组织。新增锚点系统(range/point 两种锚定方式)、占位符注入算法、11 种定制节点类型。旧数据通过 `migrateV2ToV3()` 自动迁移。
|
||||
|
||||
**数据结构**:
|
||||
- v1(已废弃,仅向后兼容读取):`{ version: 1, blocks: Block[] }`
|
||||
- v2(当前):`{ version: 2, nodes: LessonPlanNode[]; edges: LessonPlanEdge[] }`
|
||||
- `LessonPlanNode`:`Block` + `position: { x, y }`(画布坐标)
|
||||
- `LessonPlanEdge`:`{ id, source, target, sourceHandle?, targetHandle? }`(节点间连线)
|
||||
- v2(已废弃,仅向后兼容读取):`{ version: 2, nodes: LessonPlanNode[]; edges: LessonPlanEdge[] }`
|
||||
- v3(当前):`{ version: 3, textbookContentNodeId: string; nodes: AnyLessonPlanNode[]; edges: AnyLessonPlanEdge[]; anchors: NodeAnchor[] }`
|
||||
- `TextbookContentNode`:正文节点(`draggable: false`,画布中央,可缩放)
|
||||
- `LessonPlanNode`:教学节点(`Block` + `position`,可拖动可连线)
|
||||
- `NodeAnchor`:锚点(`{ id, nodeId, type: "range"|"point", start, end?, textPreview?, invalid? }`)
|
||||
- `AnchorEdge`:锚点连线(教学节点 → 正文节点,默认 10% 透明度)
|
||||
- `FlowEdge`:流程连线(教学节点 → 教学节点)
|
||||
|
||||
**导出函数**:
|
||||
- Data-access(`data-access.ts`):`getLessonPlans` / `getLessonPlanById` / `createLessonPlan` / `updateLessonPlanContent` / `softDeleteLessonPlan` / `duplicateLessonPlan` / `getTemplateById` / `buildInitialContent` / `migrateV1ToV2`(v1→v2 迁移:blocks 数组转换为 nodes + 线性 edges)/ `normalizeDocument`(规范化:确保 content 为 v2 格式,兼容旧数据)
|
||||
- Data-access(`data-access.ts`):`getLessonPlans` / `getLessonPlanById` / `createLessonPlan` / `updateLessonPlanContent` / `softDeleteLessonPlan` / `duplicateLessonPlan` / `getTemplateById` / `buildInitialContent` / `migrateV1ToV2` / `normalizeDocument`(v3 规范化,兼容 v1/v2 旧数据)/ `buildDefaultSkeleton`(v3 默认 10 节点骨架)/ `getTextbooksForPicker` / `getChaptersForPicker` / `findChapterById`
|
||||
- Lib(`lib/document-migration.ts`):`defaultDataForType` / `migrateV1ToV2` / `migrateV2ToV3` / `normalizeDocument` / `buildInitialContent` / `buildDefaultSkeleton` / `isTextbookContentNode` / `isAnchorEdge` / `getAnchorsForNode` / `getActiveAnchorIds` / `getAnchorEdges`
|
||||
- Lib(`lib/anchor-injector.ts`):`markdownToPlainText` / `injectPlaceholders` / `parseAnchoredText` / `toCircledNumber` / `getNextPointIndex` / `relocateAnchors` / `getAnchorColor`
|
||||
- Lib(`lib/node-summary.ts`):`getNodeSummary` / `getTextbookContentSummary` / `getNodeColor` / `NODE_COLORS`
|
||||
- Lib(`lib/rf-mappers.ts`):`toRfNodes`(支持 textbook_content 节点)/ `toRfEdges`(区分 anchor/flow 边透明度)/ `fromRfEdges`
|
||||
- Data-access-versions(`data-access-versions.ts`):`getLessonPlanVersions` / `createLessonPlanVersion` / `getVersionContent` / `revertToVersion` / `pruneAutoVersions`
|
||||
- Data-access-templates(`data-access-templates.ts`):`getLessonPlanTemplates` / `saveAsTemplate` / `deletePersonalTemplate`
|
||||
- Data-access-knowledge(`data-access-knowledge.ts`):`getLessonPlansByKnowledgePoint` / `getLessonPlansByQuestion`
|
||||
- Publish-service(`publish-service.ts`):`publishLessonPlanHomework`
|
||||
- AI-suggest(`ai-suggest.ts`):`suggestKnowledgePoints`
|
||||
- Actions:`getLessonPlansAction` / `getLessonPlanByIdAction` / `createLessonPlanAction` / `updateLessonPlanAction` / `saveLessonPlanVersionAction` / `getLessonPlanVersionsAction` / `revertLessonPlanVersionAction` / `deleteLessonPlanAction` / `duplicateLessonPlanAction` / `getLessonPlanTemplatesAction` / `saveAsTemplateAction` / `deleteTemplateAction` / `suggestKnowledgePointsAction` / `publishLessonPlanHomeworkAction` / `getKnowledgePointOptionsAction`
|
||||
- Actions:`getLessonPlansAction` / `getLessonPlanByIdAction` / `createLessonPlanAction` / `updateLessonPlanAction` / `saveLessonPlanVersionAction` / `getLessonPlanVersionsAction` / `revertLessonPlanVersionAction` / `deleteLessonPlanAction` / `duplicateLessonPlanAction` / `getLessonPlanTemplatesAction` / `saveAsTemplateAction` / `deleteTemplateAction` / `suggestKnowledgePointsAction` / `publishLessonPlanHomeworkAction` / `getKnowledgePointOptionsAction` / `getTextbooksForPickerAction` / `getChaptersForPickerAction`
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`、`shared/lib/ai`、`@xyflow/react`(节点图编辑器)、`textbooks`(只读章节/知识点树)、`questions`(创建/查询题目)、`exams`(创建 exam 草稿)、`homework`(创建作业下发)、`classes`(查询教师班级)、`files`(附件)
|
||||
@@ -1729,13 +1739,14 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**文件清单**:
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `types.ts` | 类型定义(含 v1/v2 文档类型、LessonPlanNode、LessonPlanEdge) |
|
||||
| `types.ts` | 类型定义(含 v1/v2/v3 文档类型、TextbookContentNode、LessonPlanNode、NodeAnchor、AnchorEdge、FlowEdge、11 种 BlockData 接口) |
|
||||
| `constants.ts` | 常量定义 |
|
||||
| `schema.ts` | Zod 验证 |
|
||||
| `lib/document-migration.ts` | **纯函数**:v1→v2 迁移(migrateV1ToV2)/ 规范化(normalizeDocument)/ 初始内容构建(buildInitialContent),使用类型守卫 isV1Document/isV2Document 替代 as 断言 |
|
||||
| `lib/node-summary.ts` | **纯函数**:getNodeSummary(接受翻译函数注入,支持 i18n)+ NODE_COLORS + getNodeColor |
|
||||
| `lib/rf-mappers.ts` | **纯函数**:toRfNodes/toRfEdges/fromRfEdges(LessonPlanNode/Edge ↔ React Flow Node/Edge 映射) |
|
||||
| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + getBlockComponent/isRichTextBlock,node-edit-panel 通过配置渲染 Block |
|
||||
| `lib/document-migration.ts` | **纯函数**:v1→v2(migrateV1ToV2)/ v2→v3(migrateV2ToV3)/ 规范化(normalizeDocument,兼容 v1/v2/v3)/ 初始内容(buildInitialContent)/ 默认骨架(buildDefaultSkeleton,10 节点 + 正文节点)/ defaultDataForType / 工具函数(isTextbookContentNode/isAnchorEdge/getAnchorsForNode/getActiveAnchorIds/getAnchorEdges) |
|
||||
| `lib/anchor-injector.ts` | **纯函数**:锚点注入算法(markdownToPlainText/injectPlaceholders/parseAnchoredText/toCircledNumber/getNextPointIndex/relocateAnchors/getAnchorColor) |
|
||||
| `lib/node-summary.ts` | **纯函数**:getNodeSummary(支持 11 种节点类型)+ getTextbookContentSummary + NODE_COLORS + getNodeColor |
|
||||
| `lib/rf-mappers.ts` | **纯函数**:toRfNodes(支持 textbook_content 节点 + 锚点回调)/ toRfEdges(区分 anchor/flow 边透明度)/ fromRfEdges |
|
||||
| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + BlockRenderer(switch 渲染 11 种定制节点 + textbook_content) |
|
||||
| `providers/lesson-plan-provider.tsx` | **Provider + Context(P1-5/P1-7/P2-4/V2-6)**:LessonPlanProvider 注入数据服务/角色配置/埋点;定义 LessonPlanDataService 接口、4 个角色配置(TEACHER/ADMIN/STUDENT/PARENT)、ROLE_CONFIGS 注册表、LessonPlanTracker 接口 + noopTracker;hooks:useLessonPlanContextSafe(返回 null 不抛错)/useLessonPlanContext/useRoleConfig/useLessonPlanService/useLessonPlanTracker/useLessonPlanTrackerSafe(V2-6 新增,返回 noopTracker 不抛错) |
|
||||
| `services/default-data-service.ts` | **默认数据服务实现**:createDefaultDataService() 包装 Server Actions 为 LessonPlanDataService 实现,测试可替换为 mock |
|
||||
| `data-access.ts` | 课案 CRUD + 模板查询(migrateV1ToV2/normalizeDocument/buildInitialContent 从 lib/ 导入并 re-export 保持向后兼容;buildScopeCondition 按 scope 类型精确过滤 P0-3;V2-1:抛出 `LessonPlanDataError` 错误码;V2-3:mapRowToLessonPlan/mapRowToListItem/mapRowToTemplate 显式映射 + isLessonPlanStatus/isTemplateType/isTemplateScope 类型守卫) |
|
||||
@@ -1749,14 +1760,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `publish-service.ts` | 发布作业服务(编排 homework/exams/classes,通过对方 data-access 调用,无直查跨模块表;V2-1:抛出 `PublishServiceError` 错误码;V2-3:显式字段映射替代 `as unknown as`) |
|
||||
| `ai-suggest.ts` | AI 知识点建议服务 |
|
||||
| `seed-templates.ts` | 模板种子数据 |
|
||||
| `hooks/use-lesson-plan-editor.ts` | 课案编辑器 Hook(基于 zustand,支持 nodes/edges 操作:addNode/updateNode/updateNodePosition/removeNode/connect/disconnect/setEdges/selectNode) |
|
||||
| `hooks/use-lesson-plan-editor.ts` | 课案编辑器 Hook(基于 zustand,支持 nodes/edges/anchors 操作:addNode/updateNode/updateNodePosition/removeNode/connect/disconnect/setEdges/selectNode + 锚点操作 addAnchor/removeAnchor/updateAnchor + 正文节点操作 updateTextbookContent/getTextbookContentNode;实时拖动) |
|
||||
| `components/lesson-plan-list.tsx` | 课案列表(i18n 已接入) |
|
||||
| `components/lesson-plan-card.tsx` | 课案卡片(i18n 已接入;V2-6:duplicate/archive 调用 tracker.track) |
|
||||
| `components/lesson-plan-filters.tsx` | 课案筛选器(i18n 已接入;V2-5:3 个表单元素 label htmlFor 关联) |
|
||||
| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPanel,i18n 已接入;V2-6:handleManualSave 调用 tracker.track) |
|
||||
| `components/node-editor.tsx` | **节点图画布**(React Flow,使用 lib/rf-mappers + lib/node-summary 纯函数,i18n 已接入;V2-4:MiniMap 复用 getNodeColor;V2-5:role=application + 键盘导航配置) |
|
||||
| `components/node-edit-panel.tsx` | **侧边内容编辑面板**(配置驱动渲染 Block,通过 getBlockComponent + LessonPlanErrorBoundary 包裹,i18n 已接入) |
|
||||
| `components/nodes/lesson-node.tsx` | **自定义节点组件**(使用 lib/node-summary 的 getNodeSummary/getNodeColor,i18n 已接入) |
|
||||
| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPanel,i18n 已接入;V2-6:handleManualSave 调用 tracker.track;V3:顶部工具栏显示教材/章节标题指示器) |
|
||||
| `components/node-editor.tsx` | **节点图画布**(React Flow,使用 lib/rf-mappers + lib/node-summary 纯函数,i18n 已接入;V2-4:MiniMap 复用 getNodeColor;V2-5:role=application + 键盘导航配置;V3:注册 textbook_content 节点类型 + 锚点回调 + 实时拖动) |
|
||||
| `components/node-edit-panel.tsx` | **侧边内容编辑面板**(配置驱动渲染 Block,通过 BlockRenderer + LessonPlanErrorBoundary 包裹,i18n 已接入;V3:处理 textbook_content 节点,教学节点类型收窄) |
|
||||
| `components/nodes/lesson-node.tsx` | **自定义教学节点组件**(使用 lib/node-summary 的 getNodeSummary/getNodeColor,i18n 已接入) |
|
||||
| `components/nodes/textbook-content-node.tsx` | **正文节点组件**(V3 新增):ReactMarkdown 渲染正文 + 锚点注入 + 文本选择(range 锚定)+ 点击位置(point 锚定)+ 缩放控制 + 锚点浮动菜单 |
|
||||
| `components/lesson-plan-error-boundary.tsx` | **错误边界**:LessonPlanErrorBoundary 类组件,支持 fallback 和 onError 回调 |
|
||||
| `components/lesson-plan-skeleton.tsx` | **骨架屏**:VersionListSkeleton/QuestionBankSkeleton/KnowledgePointSkeleton/LessonPlanListSkeleton |
|
||||
| `components/block-renderer.tsx` | ⚠️ @deprecated Block 渲染器(已被 NodeEditor 替代,保留向后兼容) |
|
||||
@@ -1804,12 +1816,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| teaching_head | `grade_managed` | 查看所管年级学生的错题分析 |
|
||||
|
||||
**核心算法:SM-2 间隔重复(简化版)**:
|
||||
- 独立纯函数模块:[sm2-algorithm.ts](file:///e:/Desktop/CICD/src/modules/error-book/sm2-algorithm.ts)(可替换为其他算法如 FSRS)
|
||||
- 4 级评级:`again`(重来)/ `hard`(困难)/ `good`(良好)/ `easy`(简单)
|
||||
- 初始间隔:1/2/4/7 天
|
||||
- 间隔增长:`again` 重置为 1 天;`hard` ×1.2;`good` ×1.5;`easy` ×2
|
||||
- 掌握度:0-5 级,`again` -1,`hard` ±0,`good` +1,`easy` +2
|
||||
- 已掌握判定:掌握度 ≥5 或连续答对 ≥3 次
|
||||
- 复习时间:次日早上 9 点
|
||||
- 单元测试:[sm2-algorithm.test.ts](file:///e:/Desktop/CICD/src/modules/error-book/sm2-algorithm.test.ts)(39 个测试用例,全部通过)
|
||||
- 导出函数:`calculateNewInterval` / `calculateNewMastery` / `deriveStatus` / `calculateNextReviewAt` / `calculateNewCorrectStreak` / `calculateSm2Result`
|
||||
|
||||
**自动采集机制**:
|
||||
- `collectFromExamSubmission`:从考试提交记录中筛选得分 < 满分的题目,去重后批量插入
|
||||
@@ -1820,7 +1835,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `actions.ts` | ~180 | 9 个 Server Actions,全部使用 `requirePermission()` + `ActionState<T>` |
|
||||
| `data-access.ts` | ~960 | 16 个数据访问函数 + SM-2 算法实现 + 自动采集逻辑 |
|
||||
| `data-access.ts` | ~960 | 16 个数据访问函数 + 自动采集逻辑(SM-2 算法已提取到独立模块) |
|
||||
| `sm2-algorithm.ts` | ~180 | SM-2 间隔重复算法(独立纯函数模块,可替换,支持时间注入测试) |
|
||||
| `sm2-algorithm.test.ts` | ~280 | SM-2 算法单元测试(39 个测试用例,覆盖所有函数和边界条件) |
|
||||
| `schema.ts` | ~60 | 4 个 Zod 验证 schema |
|
||||
| `types.ts` | ~120 | 6 个类型定义 + 状态映射常量 + 错误标签常量 |
|
||||
| `components/error-book-stats-cards.tsx` | ~80 | 5 个统计卡片(总数/待学习/学习中/已掌握/待复习) |
|
||||
@@ -1857,6 +1874,85 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
|
||||
---
|
||||
|
||||
## 2.29 ai(AI 模块)— ✅ 新增
|
||||
|
||||
**职责**:统一 AI 能力封装,为备课、错题集、试卷、改题等业务模块提供 AI 服务。
|
||||
|
||||
**架构定位**:
|
||||
- 位于 `modules/` 层,通过 `shared/lib/ai` 调用底层 AI SDK
|
||||
- 通过 `AiClientProvider`(React Context)向客户端组件注入 Server Action 引用
|
||||
- 业务模块不直接 import `ai/actions`,仅通过 Context 消费
|
||||
|
||||
**核心导出**:
|
||||
|
||||
| 类型 | 名称 | 文件 | 说明 |
|
||||
|------|------|------|------|
|
||||
| **Server Actions** | `aiChatAction` | `modules/ai/actions.ts` | AI 对话(权限:AI_CHAT) |
|
||||
| **Server Actions** | `suggestSimilarQuestionsAction` | `modules/ai/actions.ts` | 相似题推荐(权限:AI_CHAT + ERROR_BOOK_READ) |
|
||||
| **Server Actions** | `suggestGradingAction` | `modules/ai/actions.ts` | AI 辅助批改(权限:AI_CHAT + HOMEWORK_GRADE) |
|
||||
| **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) |
|
||||
| **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) |
|
||||
| **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** | `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 服务商选择器 |
|
||||
|
||||
**集成点**:
|
||||
|
||||
| 业务模块 | 集成组件 | 页面 | 说明 |
|
||||
|---------|---------|------|------|
|
||||
| homework | `AiGradingAssist` | `teacher/homework/submissions/[submissionId]` | 主观题辅助评分 |
|
||||
| error-book | `AiErrorBookAnalysis` | `student/error-book` | 相似题推荐 + 薄弱点分析 |
|
||||
| lesson-preparation | `AiLessonContentGenerator` | `teacher/lesson-plans/[planId]/edit` | 备课内容生成 |
|
||||
| exams | `AiQuestionVariantGenerator` | `teacher/exams/[id]/build` | 题目变体生成 |
|
||||
|
||||
**依赖关系**:
|
||||
- `modules/ai` → `shared/lib/ai`(AI SDK 封装)
|
||||
- `modules/ai` → `shared/lib/auth-guard`(权限校验)
|
||||
- `modules/ai` → `shared/types/permissions`(权限常量)
|
||||
- `modules/ai` → `shared/types/action-state`(返回值类型)
|
||||
- 业务模块 → `modules/ai/context/ai-client-provider`(通过 Context 注入)
|
||||
- 业务模块 → `modules/ai/components/*`(组合 AI 组件)
|
||||
|
||||
**文件清单**:
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `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/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 |
|
||||
|
||||
**i18n**:
|
||||
- 翻译文件:`shared/i18n/messages/{locale}/ai.json`
|
||||
- 命名空间:`ai`
|
||||
- 包含:chat/provider/suggestion/grading/errorBook/lessonPrep/exam/error/capability
|
||||
|
||||
---
|
||||
|
||||
# 第三部分:已知架构问题和技术债
|
||||
|
||||
## 3.1 P0 严重问题(必须立即修复)
|
||||
|
||||
@@ -13100,7 +13100,7 @@
|
||||
},
|
||||
"lesson_preparation": {
|
||||
"path": "src/modules/lesson-preparation",
|
||||
"description": "教师备课模块:基于教材章节创建课案(节点图编辑器 React Flow,v2 nodes+edges 数据结构),支持模板、版本管理、知识点标注、题目创建/拉取、作业发布。编辑器从列表式(BlockRenderer + @dnd-kit)升级为节点图式(NodeEditor + @xyflow/react),旧 v1 数据通过 migrateV1ToV2() 自动迁移。V2 审计修复:Server Actions i18n + 错误码模式、SYSTEM_TEMPLATES i18n 化、as unknown as 类型断言清零、a11y 深度修复、Tracker 埋点接入",
|
||||
"description": "教师备课模块(v3 锚点画布):以课文正文为核心主体(textbook_content 节点置于画布中央,draggable=false 可缩放),教学目标/重难点等 11 种定制节点围绕正文组织。两种锚定机制:range(选择一段文本关联节点,未选中 opacity:0)+ point(插入带圈数字占位符,未选中 opacity:0.3)。锚点连线默认 10% 透明度,选中时完整显示。数据模型 v3:LessonPlanDocument 含 textbookContentNodeId、nodes、edges、anchors 数组。anchor-injector 实现 Markdown 纯文本偏移映射与占位符注入。实时拖动(onNodeDrag 替代 onNodeDragStop)。v1→v2→v3 链式迁移。强制选定教材+章节才能备课(template-picker 四步流程)。保留模板、版本管理、知识点标注、题目创建/拉取、作业发布能力",
|
||||
"exports": {
|
||||
"dataAccess": [
|
||||
{
|
||||
@@ -13141,17 +13141,97 @@
|
||||
{
|
||||
"name": "buildInitialContent",
|
||||
"file": "lib/document-migration.ts(data-access.ts re-export)",
|
||||
"purpose": "基于模板构建初始课案内容(v2 nodes+edges)"
|
||||
"purpose": "基于模板构建初始课案内容(v3 nodes+edges+anchors+textbookContentNodeId)"
|
||||
},
|
||||
{
|
||||
"name": "migrateV1ToV2",
|
||||
"file": "lib/document-migration.ts(data-access.ts re-export)",
|
||||
"purpose": "v1→v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges(节点按网格布局),使用类型守卫 isV1Document/isV2Document 替代 as 断言"
|
||||
},
|
||||
{
|
||||
"name": "migrateV2ToV3",
|
||||
"file": "lib/document-migration.ts(data-access.ts re-export)",
|
||||
"purpose": "v2→v3 迁移:注入 textbook_content 节点(draggable=false)、初始化 anchors 数组、设置 textbookContentNodeId;可选传入 chapterId/chapterContent 填充正文"
|
||||
},
|
||||
{
|
||||
"name": "normalizeDocument",
|
||||
"file": "lib/document-migration.ts(data-access.ts re-export)",
|
||||
"purpose": "规范化:确保 content 为 v2 格式,兼容旧 v1 数据(自动调用 migrateV1ToV2)"
|
||||
"purpose": "规范化:确保 content 为 v3 格式,兼容旧 v1/v2 数据(自动调用 migrateV1ToV2 → migrateV2ToV3 链式迁移)"
|
||||
},
|
||||
{
|
||||
"name": "buildDefaultSkeleton",
|
||||
"file": "lib/document-migration.ts",
|
||||
"purpose": "v3 默认骨架:生成 1 个 textbook_content 节点(画布中央)+ 10 个教学节点(objective/key_point/import/new_teaching/consolidation/summary/homework/blackboard/text_study/reflection)+ 默认 flow edges"
|
||||
},
|
||||
{
|
||||
"name": "defaultDataForType",
|
||||
"file": "lib/document-migration.ts",
|
||||
"purpose": "按 BlockType 返回初始 data(11 种定制节点 + rich_text),供 addNode/buildDefaultSkeleton 使用"
|
||||
},
|
||||
{
|
||||
"name": "isTextbookContentNode",
|
||||
"file": "lib/document-migration.ts",
|
||||
"purpose": "类型守卫:判断节点是否为 textbook_content 类型"
|
||||
},
|
||||
{
|
||||
"name": "isAnchorEdge",
|
||||
"file": "lib/document-migration.ts",
|
||||
"purpose": "类型守卫:判断边是否为 anchor 锚点连线(vs flow 流程连线)"
|
||||
},
|
||||
{
|
||||
"name": "getAnchorsForNode",
|
||||
"file": "lib/document-migration.ts",
|
||||
"purpose": "按 nodeId 过滤 anchors 数组"
|
||||
},
|
||||
{
|
||||
"name": "getActiveAnchorIds",
|
||||
"file": "lib/document-migration.ts",
|
||||
"purpose": "获取选中节点关联的 anchor ID 集合(用于正文渲染时高亮)"
|
||||
},
|
||||
{
|
||||
"name": "getAnchorEdges",
|
||||
"file": "lib/document-migration.ts",
|
||||
"purpose": "获取锚点连线列表(用于 rf-mappers 区分透明度策略)"
|
||||
},
|
||||
{
|
||||
"name": "markdownToPlainText",
|
||||
"file": "lib/anchor-injector.ts",
|
||||
"purpose": "Markdown 转纯文本(去除语法标记),用于建立纯文本偏移↔Markdown 偏移映射"
|
||||
},
|
||||
{
|
||||
"name": "injectPlaceholders",
|
||||
"file": "lib/anchor-injector.ts",
|
||||
"purpose": "将 anchors 注入 Markdown 正文:range 锚点包裹为 [[anchor:id]]...[[/anchor:id]],point 锚点插入 ①②③ 带圈数字"
|
||||
},
|
||||
{
|
||||
"name": "parseAnchoredText",
|
||||
"file": "lib/anchor-injector.ts",
|
||||
"purpose": "解析注入后的 Markdown 为段落数组(text/anchor-range/anchor-point),供 textbook-content-node 自定义渲染"
|
||||
},
|
||||
{
|
||||
"name": "getNextPointIndex",
|
||||
"file": "lib/anchor-injector.ts",
|
||||
"purpose": "计算下一个 point 锚点的带圈数字序号"
|
||||
},
|
||||
{
|
||||
"name": "toCircledNumber",
|
||||
"file": "lib/anchor-injector.ts",
|
||||
"purpose": "数字转带圈数字字符(1→①, 2→②, ...)"
|
||||
},
|
||||
{
|
||||
"name": "relocateAnchors",
|
||||
"file": "lib/anchor-injector.ts",
|
||||
"purpose": "正文变更后按文本相似度重定位锚点偏移量"
|
||||
},
|
||||
{
|
||||
"name": "getTextbooksForPicker",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "获取教材列表(id+title),供 template-picker 选择教材"
|
||||
},
|
||||
{
|
||||
"name": "getChaptersForPicker",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "按 textbookId 获取章节树(含 children 嵌套),供 template-picker 选择章节"
|
||||
},
|
||||
{
|
||||
"name": "getLessonPlanVersions",
|
||||
@@ -13304,6 +13384,18 @@
|
||||
"permission": "LESSON_PLAN_READ",
|
||||
"file": "actions-kp.ts",
|
||||
"purpose": "获取知识点选项(委托 textbooks data-access)"
|
||||
},
|
||||
{
|
||||
"name": "getTextbooksForPickerAction",
|
||||
"permission": "LESSON_PLAN_READ",
|
||||
"file": "actions.ts",
|
||||
"purpose": "获取教材列表(供 template-picker 选择教材)"
|
||||
},
|
||||
{
|
||||
"name": "getChaptersForPickerAction",
|
||||
"permission": "LESSON_PLAN_READ",
|
||||
"file": "actions.ts",
|
||||
"purpose": "按 textbookId 获取章节树(供 template-picker 选择章节)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -13322,6 +13414,7 @@
|
||||
"constants.ts",
|
||||
"schema.ts",
|
||||
"lib/document-migration.ts",
|
||||
"lib/anchor-injector.ts",
|
||||
"lib/node-summary.ts",
|
||||
"lib/rf-mappers.ts",
|
||||
"config/block-registry.tsx",
|
||||
@@ -13346,6 +13439,7 @@
|
||||
"components/node-editor.tsx",
|
||||
"components/node-edit-panel.tsx",
|
||||
"components/nodes/lesson-node.tsx",
|
||||
"components/nodes/textbook-content-node.tsx",
|
||||
"components/lesson-plan-error-boundary.tsx",
|
||||
"components/lesson-plan-skeleton.tsx",
|
||||
"components/block-renderer.tsx",
|
||||
@@ -13358,7 +13452,14 @@
|
||||
"components/blocks/rich-text-block.tsx",
|
||||
"components/blocks/text-study-block.tsx",
|
||||
"components/blocks/exercise-block.tsx",
|
||||
"components/blocks/reflection-block.tsx"
|
||||
"components/blocks/reflection-block.tsx",
|
||||
"components/blocks/objective-block.tsx",
|
||||
"components/blocks/key-point-block.tsx",
|
||||
"components/blocks/import-block.tsx",
|
||||
"components/blocks/new-teaching-block.tsx",
|
||||
"components/blocks/summary-block.tsx",
|
||||
"components/blocks/homework-block.tsx",
|
||||
"components/blocks/blackboard-block.tsx"
|
||||
],
|
||||
"i18n": {
|
||||
"namespace": "lessonPreparation",
|
||||
@@ -15082,6 +15183,106 @@
|
||||
"actions.getQuestionsAction"
|
||||
]
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"dependsOn": [
|
||||
"shared"
|
||||
],
|
||||
"uses": {
|
||||
"shared": [
|
||||
"db",
|
||||
"auth-guard.requirePermission",
|
||||
"lib.ai.createAiChatCompletion",
|
||||
"types.permissions",
|
||||
"types.action-state",
|
||||
"lib.track-event.trackEvent",
|
||||
"i18n.messages"
|
||||
]
|
||||
},
|
||||
"exports": {
|
||||
"actions": [
|
||||
"aiChatAction",
|
||||
"suggestSimilarQuestionsAction",
|
||||
"suggestGradingAction",
|
||||
"generateLessonContentAction",
|
||||
"generateQuestionVariantAction",
|
||||
"analyzeWeaknessAction"
|
||||
],
|
||||
"types": [
|
||||
"AiService",
|
||||
"AiClientService",
|
||||
"AiChatMessage",
|
||||
"AiChatResult",
|
||||
"SimilarQuestionInput",
|
||||
"SimilarQuestionResult",
|
||||
"GradingInput",
|
||||
"GradingSuggestion",
|
||||
"LessonContentInput",
|
||||
"LessonContentResult",
|
||||
"QuestionVariantInput",
|
||||
"QuestionVariantResult",
|
||||
"WeaknessAnalysisInput",
|
||||
"WeaknessAnalysisResult",
|
||||
"AiCapability"
|
||||
],
|
||||
"components": [
|
||||
"AiGradingAssist",
|
||||
"AiErrorBookAnalysis",
|
||||
"AiLessonContentGenerator",
|
||||
"AiQuestionVariantGenerator",
|
||||
"AiChatPanel",
|
||||
"AiErrorBoundary",
|
||||
"AiSuggestionSkeleton",
|
||||
"AiProviderSelector"
|
||||
],
|
||||
"hooks": [
|
||||
"useAiClient",
|
||||
"useAiClientOptional",
|
||||
"useAiChat",
|
||||
"useAiSuggestion"
|
||||
],
|
||||
"providers": [
|
||||
"AiClientProvider"
|
||||
],
|
||||
"services": [
|
||||
"DefaultAiService",
|
||||
"createAiService",
|
||||
"safeAiCall"
|
||||
]
|
||||
},
|
||||
"integrations": {
|
||||
"homework": {
|
||||
"component": "AiGradingAssist",
|
||||
"page": "teacher/homework/submissions/[submissionId]",
|
||||
"capability": "grading-assist"
|
||||
},
|
||||
"error-book": {
|
||||
"component": "AiErrorBookAnalysis",
|
||||
"page": "student/error-book",
|
||||
"capability": "similar-question, weakness-analysis"
|
||||
},
|
||||
"lesson-preparation": {
|
||||
"component": "AiLessonContentGenerator",
|
||||
"page": "teacher/lesson-plans/[planId]/edit",
|
||||
"capability": "lesson-content"
|
||||
},
|
||||
"exams": {
|
||||
"component": "AiQuestionVariantGenerator",
|
||||
"page": "teacher/exams/[id]/build",
|
||||
"capability": "question-variant"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"ai:chat",
|
||||
"ai:configure"
|
||||
],
|
||||
"i18n": {
|
||||
"namespace": "ai",
|
||||
"files": [
|
||||
"shared/i18n/messages/zh-CN/ai.json",
|
||||
"shared/i18n/messages/en/ai.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"moduleDependencyGraph": {
|
||||
@@ -15112,7 +15313,8 @@
|
||||
"diagnostic",
|
||||
"elective",
|
||||
"onboarding",
|
||||
"error-book"
|
||||
"error-book",
|
||||
"ai"
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
@@ -15372,6 +15574,36 @@
|
||||
"to": "questions",
|
||||
"type": "data-access",
|
||||
"description": "通过 questions/actions.getQuestionsAction 查询题库(手动添加错题时)"
|
||||
},
|
||||
{
|
||||
"from": "ai",
|
||||
"to": "shared",
|
||||
"type": "normal",
|
||||
"description": "使用 lib/ai.createAiChatCompletion、auth-guard.requirePermission、types.permissions、types.action-state"
|
||||
},
|
||||
{
|
||||
"from": "homework",
|
||||
"to": "ai",
|
||||
"type": "context",
|
||||
"description": "通过 AiClientProvider 注入 AiClientService,使用 AiGradingAssist 组件"
|
||||
},
|
||||
{
|
||||
"from": "error-book",
|
||||
"to": "ai",
|
||||
"type": "context",
|
||||
"description": "通过 AiClientProvider 注入 AiClientService,使用 AiErrorBookAnalysis 组件"
|
||||
},
|
||||
{
|
||||
"from": "lesson-preparation",
|
||||
"to": "ai",
|
||||
"type": "context",
|
||||
"description": "通过 AiClientProvider 注入 AiClientService,使用 AiLessonContentGenerator 组件"
|
||||
},
|
||||
{
|
||||
"from": "exams",
|
||||
"to": "ai",
|
||||
"type": "context",
|
||||
"description": "通过 AiClientProvider 注入 AiClientService,使用 AiQuestionVariantGenerator 组件"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
452
docs/architecture/audit/ai-module-audit-report.md
Normal file
452
docs/architecture/audit/ai-module-audit-report.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# AI 模块审计报告
|
||||
|
||||
> 审计范围:项目中所有与 AI(人工智能)相关的代码,包括底层 SDK 封装、Provider 管理、各业务模块(备课、错题集、试卷、改题等)中的 AI 集成点。
|
||||
> 审计日期:2026-06-23
|
||||
> 审计依据:`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json`、`docs/standards/coding-standards.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
AI 相关代码当前**未形成独立模块**,而是分散在 5 个不同位置:
|
||||
|
||||
| 位置 | 文件 | 行数 | 职责 |
|
||||
|------|------|------|------|
|
||||
| `src/shared/lib/ai/` | `api-key-crypto.ts` | 28 | AES-256-GCM 加密 API Key |
|
||||
| `src/shared/lib/ai/` | `client.ts` | 58 | OpenAI SDK 封装,创建 chat completion |
|
||||
| `src/shared/lib/ai/` | `errors.ts` | 8 | 错误消息格式化 |
|
||||
| `src/shared/lib/ai/` | `payload-parser.ts` | 78 | 请求负载解析与 Zod 守卫 |
|
||||
| `src/shared/lib/ai/` | `provider-config.ts` | 61 | 从 `ai_providers` 表查询 Provider 配置 |
|
||||
| `src/shared/lib/ai/` | `index.ts` | 5 | 聚合导出 |
|
||||
| `src/shared/lib/ai.ts` | — | 9 | 向后兼容重导出 |
|
||||
| `src/app/api/ai/chat/` | `route.ts` | 42 | AI 聊天 REST API 端点 |
|
||||
| `src/modules/exams/ai-pipeline/` | `parse.ts` | 426 | Zod schema、JSON 提取修复、提示词 |
|
||||
| `src/modules/exams/ai-pipeline/` | `request.ts` | 306 | AI 请求构造与发送 |
|
||||
| `src/modules/exams/ai-pipeline/` | `structure.ts` | 209 | 结构生成与预览/草稿转换 |
|
||||
| `src/modules/exams/ai-pipeline/` | `index.ts` | 172 | 高层编排 |
|
||||
| `src/modules/lesson-preparation/` | `actions-ai.ts` | 44 | 知识点推荐 Server Action |
|
||||
| `src/modules/lesson-preparation/` | `ai-suggest.ts` | 65 | 知识点推荐 AI 逻辑 |
|
||||
| `src/modules/settings/` | `actions.ts`(部分) | ~183 | AI Provider CRUD Action |
|
||||
| `src/modules/settings/` | `data-access.ts`(部分) | — | `ai_providers` 表查询 |
|
||||
| `src/modules/exams/components/` | `exam-ai-generator.tsx` | 224 | AI 出题 UI 组件 |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
```
|
||||
前端组件 (exam-ai-generator.tsx)
|
||||
└─▶ Server Action (exams/actions.ts: createAiExamAction)
|
||||
└─▶ ai-pipeline.generateAiCreateDraftFromSource()
|
||||
├─▶ requestAiExamStructureDraft() → createAiChatCompletion()
|
||||
│ └─▶ OpenAI SDK + db.query.aiProviders
|
||||
└─▶ parseQuestionDetail() → createAiChatCompletion()
|
||||
|
||||
前端组件 (lesson-preparation hooks)
|
||||
└─▶ suggestKnowledgePointsAction()
|
||||
└─▶ ai-suggest.suggestKnowledgePoints()
|
||||
├─▶ textbooks/data-access.getKnowledgePointsByTextbookId() [跨模块]
|
||||
└─▶ createAiChatCompletion()
|
||||
|
||||
前端组件 (settings)
|
||||
└─▶ upsertAiProviderAction() / testAiProviderAction()
|
||||
└─▶ settings/data-access (ai_providers 表)
|
||||
```
|
||||
|
||||
### 1.3 架构图记录情况
|
||||
|
||||
- `005_architecture_data.json` 中 `modules` 节点**未将 AI 列为独立模块**。
|
||||
- 仅在 `dbTables.aiProviders` 中记录 `usedBy: ["settings", "ai"]`,但 `ai` 并非真实存在的模块。
|
||||
- `shared` 模块下记录了 `lib/ai/*` 工具函数(`createAiChatCompletion`、`parseAiChatPayload` 等)。
|
||||
- `exams` 模块下记录了 `ai-pipeline` 子目录的导出函数。
|
||||
- `lessonPreparation` 模块下记录了 `suggestKnowledgePointsAction`。
|
||||
- **结论:架构图对 AI 模块的记录不完整,未反映 AI 作为横切关注点的全貌,也未记录 `app/api/ai/chat/route.ts` 端点。**
|
||||
|
||||
### 1.4 权限点
|
||||
|
||||
| 权限常量 | 值 | 用途 |
|
||||
|----------|----|------|
|
||||
| `AI_CHAT` | `ai:chat` | 使用 AI 聊天 |
|
||||
| `AI_CONFIGURE` | `ai:configure` | 配置 AI Provider |
|
||||
| `EXAM_AI_GENERATE` | `exam:ai_generate` | AI 出题 |
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 架构分层问题
|
||||
|
||||
#### 问题 2.1.1:AI 未形成独立模块,逻辑分散在 5 处
|
||||
|
||||
- **位置**:`shared/lib/ai/`、`app/api/ai/chat/`、`modules/exams/ai-pipeline/`、`modules/lesson-preparation/ai-suggest.ts`、`modules/settings/`
|
||||
- **原因**:AI 能力是按业务需求逐步添加的,每次新增场景都在调用方就地实现,未抽象为独立模块。
|
||||
- **后果**:AI 逻辑无法统一治理(限流、监控、成本控制、Prompt 版本管理);新增 AI 场景需要重复编写请求构造与错误处理;测试时无法 Mock AI 层。
|
||||
- **违反规则**:`项目规则 → 架构分层规则 → 模块标准结构`(AI 应作为 `modules/ai/` 独立模块存在)。
|
||||
|
||||
#### 问题 2.1.2:AI 聊天使用 REST API 路由而非 Server Action
|
||||
|
||||
- **位置**:[route.ts](file:///e:/Desktop/CICD/src/app/api/ai/chat/route.ts)
|
||||
- **原因**:早期实现选择了 REST 路由,未遵循项目 Server Action 统一规范。
|
||||
- **后果**:与项目其他数据操作风格不一致;无法复用 `ActionState<T>` 返回类型与 `useActionMutation` Hook;权限校验绕过了 `requirePermission()` 体系。
|
||||
- **违反规则**:`项目规则 → Server Action 规范`(所有数据操作应通过 Server Action,返回 `ActionState<T>`)。
|
||||
|
||||
#### 问题 2.1.3:`lesson-preparation/ai-suggest.ts` 跨模块直接依赖
|
||||
|
||||
- **位置**:[ai-suggest.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/ai-suggest.ts#L6-L8)
|
||||
- **现状**:直接 `import { getKnowledgePointsByTextbookId, getKnowledgePointsByChapterId } from "@/modules/textbooks/data-access"`。
|
||||
- **判定**:模块间通过对方 data-access 通信**符合规则**,但 AI 推荐逻辑本身应属于 AI 模块,而非备课模块。当前 `ai-suggest.ts` 混合了"AI 调用"与"知识点候选获取"两个职责。
|
||||
- **后果**:若其他模块也需要"基于文本推荐知识点",无法复用。
|
||||
- **违反规则**:`项目规则 → 架构分层规则`(职责划分不清)。
|
||||
|
||||
### 2.2 权限问题
|
||||
|
||||
#### 问题 2.2.1:AI 聊天端点缺少 `requirePermission()` 校验
|
||||
|
||||
- **位置**:[route.ts:15-18](file:///e:/Desktop/CICD/src/app/api/ai/chat/route.ts#L15-L18)
|
||||
- **现状**:仅检查 `session?.user?.id` 是否存在,**未调用 `requirePermission(Permissions.AI_CHAT)`**。
|
||||
- **后果**:任何已登录用户(包括学生)都能无限制调用 AI 聊天,绕过了角色权限体系;无法按角色限制 AI 使用场景。
|
||||
- **违反规则**:`项目规则 → Server Action 规范 → 每个 Action 必须调用 requirePermission()`;`项目规则 → 安全规范`。
|
||||
|
||||
#### 问题 2.2.2:AI 出题管线内部无权限二次校验
|
||||
|
||||
- **位置**:`exams/ai-pipeline/index.ts` 的 `generateAiCreateDraftFromSource`
|
||||
- **现状**:依赖调用方 Action 校验权限,管线本身不校验。
|
||||
- **后果**:若未来有新调用方忘记校验,将导致越权调用 AI。
|
||||
- **违反规则**:`项目规则 → 安全规范 → Server Action 二次校验`。
|
||||
|
||||
### 2.3 国际化问题
|
||||
|
||||
#### 问题 2.3.1:`exam-ai-generator.tsx` 大量硬编码文本
|
||||
|
||||
- **位置**:[exam-ai-generator.tsx](file:///e:/Desktop/CICD/src/modules/exams/components/exam-ai-generator.tsx)
|
||||
- **硬编码中文**:第 118 行"新建配置"、第 164 行"加入后台队列(运行 ${...}/3,排队 ${...})"、第 167 行"立即预览"/"Generating..."、第 192 行"后台生成记录"、第 202-207 行"排队中"/"生成中"/"已完成"/"失败:..."、第 211 行"打开预览"。
|
||||
- **硬编码英文**:第 92 行"AI Generation"、第 93-95 行描述、第 104 行"AI Provider"、第 122-124 行对话框标题、第 144 行"Loading providers..."/"Select provider"、第 156 行描述、第 175 行"Source Exam Text"、第 178 行 placeholder、第 184 行描述。
|
||||
- **后果**:无法切换语言;违反 i18n 就绪要求。
|
||||
- **违反规则**:`项目规则 → 所有用户可见文本必须适配 i18n`。
|
||||
|
||||
#### 问题 2.3.2:AI 管线内部硬编码中文错误消息
|
||||
|
||||
- **位置**:[request.ts:152](file:///e:/Desktop/CICD/src/modules/exams/ai-pipeline/request.ts#L152) "请先粘贴试卷文本"、第 172 行"试卷文本校验失败,请重试"、第 177 行"识别为乱码或混乱文本..."。
|
||||
- **后果**:错误消息无法国际化。
|
||||
- **违反规则**:`项目规则 → i18n`。
|
||||
|
||||
#### 问题 2.3.3:无独立 `ai.json` 翻译文件
|
||||
|
||||
- **现状**:AI 相关翻译散落在 `settings.json`(Provider 管理)和 `lesson-preparation.json`(`error.aiSuggest`),无统一命名空间。
|
||||
- **后果**:AI 文本难以维护与查找。
|
||||
|
||||
### 2.4 类型安全问题
|
||||
|
||||
#### 问题 2.4.1:`ai-suggest.ts` 使用 `as` 断言
|
||||
|
||||
- **位置**:[ai-suggest.ts:54](file:///e:/Desktop/CICD/src/modules/lesson-preparation/ai-suggest.ts#L54)
|
||||
- **代码**:`JSON.parse(jsonMatch[0]) as { id: string; name: string; reason: string }[]`
|
||||
- **后果**:AI 返回的 JSON 结构不可信,直接断言可能导致运行时错误。
|
||||
- **违反规则**:`项目规则 → TypeScript 规则 → 禁止 as 断言`。
|
||||
|
||||
#### 问题 2.4.2:`actions-ai.ts` 双重断言
|
||||
|
||||
- **位置**:[actions-ai.ts:34](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions-ai.ts#L34)
|
||||
- **代码**:`parsed.data.doc as unknown as LessonPlanDocument`
|
||||
- **后果**:绕过类型系统,不安全。
|
||||
- **违反规则**:`项目规则 → TypeScript 规则 → 禁止 as 断言`。
|
||||
|
||||
### 2.5 错误处理问题
|
||||
|
||||
#### 问题 2.5.1:`ai-suggest.ts` 静默吞掉错误
|
||||
|
||||
- **位置**:[ai-suggest.ts:50-64](file:///e:/Desktop/CICD/src/modules/lesson-preparation/ai-suggest.ts#L50-L64)
|
||||
- **现状**:`try { JSON.parse(...) } catch { return [] }` — JSON 解析失败时静默返回空数组。
|
||||
- **后果**:教师无法区分"AI 未推荐任何知识点"与"AI 返回格式错误";无法排查问题。
|
||||
- **违反规则**:`项目规则 → 错误处理`。
|
||||
|
||||
#### 问题 2.5.2:无 AI 专用 Error Boundary
|
||||
|
||||
- **现状**:AI 组件(如 `exam-ai-generator`)未用 Error Boundary 包裹。
|
||||
- **后果**:AI 调用失败可能导致整个页面崩溃。
|
||||
- **违反规则**:审计要求 → 每个独立数据区块必须用 React Error Boundary 包裹。
|
||||
|
||||
#### 问题 2.5.3:无 Suspense/骨架屏
|
||||
|
||||
- **现状**:AI 异步操作仅用 `loading` 布尔值切换按钮文字,无骨架屏。
|
||||
- **后果**:用户体验差,无法感知加载进度。
|
||||
|
||||
### 2.6 可复用性问题
|
||||
|
||||
#### 问题 2.6.1:无可复用 AI 组件
|
||||
|
||||
- **现状**:
|
||||
- AI Provider 选择器硬编码在 `exam-ai-generator.tsx` 内部,无法在其他模块复用。
|
||||
- 无通用 AI 聊天面板组件。
|
||||
- 无通用 AI 建议加载器组件。
|
||||
- 无通用 AI 结果预览组件。
|
||||
- **后果**:每个需要 AI 的模块都要从零实现 UI。
|
||||
- **违反规则**:审计要求 → 最大化复用。
|
||||
|
||||
#### 问题 2.6.2:无 AI 服务接口抽象
|
||||
|
||||
- **现状**:所有模块直接 `import { createAiChatCompletion } from "@/shared/lib/ai"`。
|
||||
- **后果**:无法 Mock AI 服务进行单测;无法切换 AI 实现(如本地 mock、不同 SDK)。
|
||||
- **违反规则**:审计要求 → 完全解耦、可测试性。
|
||||
|
||||
### 2.7 功能缺失问题
|
||||
|
||||
#### 问题 2.7.1:错题集无 AI 集成
|
||||
|
||||
- **现状**:`error-book` 模块仅有 SM2 间隔复习算法,无 AI 能力。
|
||||
- **缺失功能**:
|
||||
- AI 相似题推荐(根据错题生成同类练习)
|
||||
- AI 薄弱点分析(根据错题分布分析学生薄弱知识点)
|
||||
- AI 解题思路生成(为错题生成分步骤解析)
|
||||
- AI 复习计划建议(基于错题掌握度智能调整复习节奏)
|
||||
- **后果**:错题本仅是静态记录,无法发挥 AI 的个性化学习价值。
|
||||
|
||||
#### 问题 2.7.2:改题(作业批改)无 AI 集成
|
||||
|
||||
- **现状**:`homework-grading-view.tsx` 仅支持手动评分与自动判分(选择题),无 AI 辅助。
|
||||
- **缺失功能**:
|
||||
- AI 辅助批改主观题(简答题/论述题)
|
||||
- AI 生成评分反馈建议
|
||||
- AI 批改一致性校验(检测人工评分偏差)
|
||||
- **后果**:教师批改主观题负担重,效率低。
|
||||
|
||||
#### 问题 2.7.3:备课 AI 能力单一
|
||||
|
||||
- **现状**:`lesson-preparation` 仅有"知识点推荐"一个 AI 功能。
|
||||
- **缺失功能**:
|
||||
- AI 生成教学活动设计
|
||||
- AI 生成课堂提问
|
||||
- AI 生成形成性评估
|
||||
- AI 生成差异化教学建议
|
||||
- **后果**:AI 价值未充分释放。
|
||||
|
||||
#### 问题 2.7.4:试卷 AI 无题目变体与智能组卷
|
||||
|
||||
- **现状**:`exams/ai-pipeline` 仅支持"从文本解析生成试卷"。
|
||||
- **缺失功能**:
|
||||
- AI 生成题目变体(基于已有题目生成同知识点不同表述的变体)
|
||||
- AI 智能组卷(根据知识点覆盖、难度分布自动组卷)
|
||||
- AI 难度分析(预测题目难度)
|
||||
- **后果**:AI 出题场景受限。
|
||||
|
||||
### 2.8 性能与监控问题
|
||||
|
||||
#### 问题 2.8.1:无流式响应
|
||||
|
||||
- **现状**:所有 AI 调用等待完整响应才返回。
|
||||
- **后果**:长文本生成时用户体验差(等待 10-30 秒)。
|
||||
- **违反规则**:审计要求 → 性能:支持流式渲染。
|
||||
|
||||
#### 问题 2.8.2:无 AI 使用监控
|
||||
|
||||
- **现状**:无 AI 调用埋点、无成本统计、无延迟监控、无错误率监控。
|
||||
- **后果**:无法优化 AI 使用策略,无法发现异常调用。
|
||||
- **违反规则**:审计要求 → 监控:预留关键操作埋点接口。
|
||||
|
||||
### 2.9 可访问性问题
|
||||
|
||||
#### 问题 2.9.1:AI 组件缺少 ARIA 属性
|
||||
|
||||
- **位置**:`exam-ai-generator.tsx` 的后台任务列表无 `aria-live`,屏幕阅读器无法感知状态变化。
|
||||
- **违反规则**:审计要求 → a11y:ARIA 属性。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
### 3.1 与优秀 K12 产品的差距
|
||||
|
||||
| 能力 | 行业主流做法 | 当前状态 | 差距影响 |
|
||||
|------|-------------|---------|---------|
|
||||
| **AI 助手入口** | 全局悬浮按钮/侧边栏,可从任何页面唤起 AI 助手 | 无全局入口,仅嵌入特定页面 | 用户无法在需要时随时获取 AI 帮助 |
|
||||
| **上下文感知** | AI 助手自动感知当前页面上下文(如正在批改的作业) | 无上下文感知 | AI 建议不精准,需用户手动输入上下文 |
|
||||
| **流式输出** | AI 回复逐字流式显示 | 等待完整响应 | 长文本等待体验差 |
|
||||
| **错题 AI 推荐** | 根据错题自动生成同类练习题,支持"再练一题" | 无此功能 | 学生无法针对性巩固薄弱点 |
|
||||
| **AI 辅助批改** | 主观题 AI 预评分 + 教师确认 | 无此功能 | 教师批改负担重 |
|
||||
| **学习路径推荐** | AI 根据错题与掌握度生成个性化学习路径 | 无此功能 | 缺少个性化学习引导 |
|
||||
| **AI 内容安全** | 学生侧 AI 输出经过内容过滤 | 无过滤机制 | 学生可能接触不当内容 |
|
||||
| **AI 使用历史** | 用户可查看自己的 AI 对话历史 | 无此功能 | 无法回顾 AI 建议结果 |
|
||||
| **多 Provider 对比** | 同一 Prompt 可对比不同模型输出 | 仅支持选择单一 Provider | 无法评估最优模型 |
|
||||
| **Prompt 版本管理** | Prompt 模板可配置化、版本化 | Prompt 硬编码在代码中 | 调整 Prompt 需改代码发版 |
|
||||
|
||||
### 3.2 多角色体验差距
|
||||
|
||||
| 角色 | 期望的 AI 能力 | 当前状态 |
|
||||
|------|---------------|---------|
|
||||
| **教师** | 备课内容生成、出题辅助、批改辅助、学情分析 | 仅有知识点推荐 + 试卷解析 |
|
||||
| **学生** | 错题相似题推荐、解题思路、学习路径 | 无任何 AI 能力 |
|
||||
| **家长** | 子女学情 AI 摘要、辅导建议 | 无任何 AI 能力 |
|
||||
| **管理员** | AI 使用统计、成本监控 | 无任何 AI 能力 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急,影响安全与基础架构)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P0-1 | AI 聊天端点缺少权限校验 | 改造为 Server Action,添加 `requirePermission(AI_CHAT)` |
|
||||
| P0-2 | AI 未形成独立模块 | 创建 `src/modules/ai/`,将分散的 AI 逻辑统一收口 |
|
||||
| P0-3 | `exam-ai-generator.tsx` 硬编码文本 | 提取 i18n 键,创建 `ai.json` 翻译文件 |
|
||||
| P0-4 | AI 管线硬编码错误消息 | 通过 Server Action 层返回 i18n 错误键 |
|
||||
| P0-5 | `ai-suggest.ts` 使用 `as` 断言 | 用 Zod schema 校验 AI 返回 |
|
||||
|
||||
### P1(重要,影响功能完整性与可维护性)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P1-1 | 无 AI 服务接口抽象 | 定义 `AiService` 接口,通过 React Context 注入 |
|
||||
| P1-2 | 无可复用 AI 组件 | 抽象 `AiChatPanel`、`AiProviderSelector`、`AiSuggestionCard`、`AiErrorBoundary` |
|
||||
| P1-3 | 无 AI Error Boundary | 创建 `AiErrorBoundary` 包裹所有 AI 区块 |
|
||||
| P1-4 | 错题集无 AI 集成 | 新增相似题推荐、薄弱点分析 Server Action |
|
||||
| P1-5 | 改题无 AI 集成 | 新增 AI 辅助批改 Action |
|
||||
| P1-6 | 无 AI 使用监控 | 预留 `trackAiUsage()` 埋点接口 |
|
||||
| P1-7 | 备课 AI 能力单一 | 新增内容生成、活动建议 Action |
|
||||
|
||||
### P2(优化,提升体验与扩展性)
|
||||
|
||||
| 编号 | 问题 | 改进方向 |
|
||||
|------|------|---------|
|
||||
| P2-1 | 无流式响应 | 支持 SSE 流式输出 |
|
||||
| P2-2 | 无 AI 对话历史 | 持久化用户 AI 对话记录 |
|
||||
| P2-3 | Prompt 硬编码 | 抽取为可配置 Prompt 模板 |
|
||||
| P2-4 | 试卷 AI 无变体生成 | 新增题目变体生成 Action |
|
||||
| P2-5 | 无多 Provider 对比 | 支持并行调用多 Provider 对比 |
|
||||
| P2-6 | 无内容安全过滤 | 学生侧 AI 输出添加内容过滤 |
|
||||
| P2-7 | 架构图未记录 AI 模块 | 同步更新 004/005 文档 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现架构图存在以下遗漏与不一致,需在实现后同步更新:
|
||||
|
||||
### 5.1 需新增的节点
|
||||
|
||||
| 文档 | 节点路径 | 内容 |
|
||||
|------|---------|------|
|
||||
| `005_architecture_data.json` | `modules.ai` | 新增 AI 模块定义:path、description、exports(AiService 接口、Actions、组件) |
|
||||
| `005_architecture_data.json` | `modules.ai.exports.functions` | `createAiChatAction`、`suggestSimilarQuestionsAction`、`suggestGradingAction`、`generateLessonContentAction`、`generateQuestionVariantAction` |
|
||||
| `005_architecture_data.json` | `modules.ai.exports.components` | `AiChatPanel`、`AiProviderSelector`、`AiSuggestionCard`、`AiErrorBoundary` |
|
||||
| `005_architecture_data.json` | `modules.ai.exports.hooks` | `useAiChat`、`useAiSuggestion` |
|
||||
| `005_architecture_data.json` | `dependencyMatrix.ai` | ai → shared、ai → settings(data-access);exams/lesson-preparation/error-book/homework → ai |
|
||||
| `004_architecture_impact_map.md` | 模块清单 | 新增"AI 模块"章节 |
|
||||
| `004_architecture_impact_map.md` | 文件清单 | 新增 `modules/ai/` 下所有文件 |
|
||||
|
||||
### 5.2 需修改的节点
|
||||
|
||||
| 文档 | 节点 | 修改内容 |
|
||||
|------|------|---------|
|
||||
| `005_architecture_data.json` | `dbTables.aiProviders.usedBy` | 从 `["settings", "ai"]` 改为 `["ai"]`(AI 模块收口后由 AI 模块负责) |
|
||||
| `005_architecture_data.json` | `modules.shared.exports` | 标注 `lib/ai/*` 为"底层 SDK 封装,业务层应调用 `modules/ai`" |
|
||||
| `005_architecture_data.json` | `modules.exams.ai-pipeline` | 标注依赖关系变更为"通过 ai 模块服务调用" |
|
||||
| `005_architecture_data.json` | `routes` | 移除 `app/api/ai/chat/route.ts`(改造为 Server Action 后删除) |
|
||||
| `004_architecture_impact_map.md` | 调用链路图 | 更新 AI 调用链路:业务模块 → ai/actions → ai/services → shared/lib/ai |
|
||||
|
||||
### 5.3 需删除的节点
|
||||
|
||||
| 文档 | 节点 | 原因 |
|
||||
|------|------|------|
|
||||
| `005_architecture_data.json` | `routes./api/ai/chat` | 改造为 Server Action 后该 REST 路由删除 |
|
||||
|
||||
---
|
||||
|
||||
## 六、重构方案设计(概要)
|
||||
|
||||
> 详细实现见代码提交,此处仅列出设计要点。
|
||||
|
||||
### 6.1 模块结构
|
||||
|
||||
```
|
||||
src/modules/ai/
|
||||
├─ types.ts # AiService 接口、AiChatMessage、AiSuggestion 等类型
|
||||
├─ schema.ts # Zod 校验(chat、suggest、grading 等)
|
||||
├─ data-access.ts # ai_providers 表查询(从 settings 迁移)
|
||||
├─ services/
|
||||
│ ├─ ai-service.ts # AiService 接口实现(封装 createAiChatCompletion)
|
||||
│ ├─ prompt-templates.ts # 可配置 Prompt 模板
|
||||
│ └─ usage-tracker.ts # AI 使用埋点
|
||||
├─ actions.ts # Server Actions(chat、suggestSimilar、suggestGrading、generateLessonContent)
|
||||
├─ context/
|
||||
│ └─ ai-provider.tsx # React Context + Provider(依赖注入 AiService)
|
||||
├─ components/
|
||||
│ ├─ ai-chat-panel.tsx # 通用 AI 聊天面板(支持流式)
|
||||
│ ├─ ai-provider-selector.tsx # Provider 选择器(复用)
|
||||
│ ├─ ai-suggestion-card.tsx # 建议卡片
|
||||
│ ├─ ai-error-boundary.tsx # AI 专用 Error Boundary
|
||||
│ └─ ai-skeleton.tsx # AI 加载骨架屏
|
||||
└─ hooks/
|
||||
├─ use-ai-chat.ts # AI 聊天 Hook
|
||||
└─ use-ai-suggestion.ts # AI 建议 Hook
|
||||
```
|
||||
|
||||
### 6.2 依赖注入
|
||||
|
||||
```typescript
|
||||
// types.ts
|
||||
export interface AiService {
|
||||
chat(messages: AiChatMessage[], options?: AiChatOptions): Promise<AiChatResult>
|
||||
suggestSimilarQuestions(input: SimilarQuestionInput): Promise<SimilarQuestionResult[]>
|
||||
suggestGrading(input: GradingInput): Promise<GradingSuggestion>
|
||||
generateLessonContent(input: LessonContentInput): Promise<LessonContentResult>
|
||||
}
|
||||
|
||||
// context/ai-provider.tsx
|
||||
const AiContext = createContext<AiService | null>(null)
|
||||
export function AiServiceProvider({ children, service }: { children: ReactNode; service: AiService }) { ... }
|
||||
export function useAiService(): AiService { ... }
|
||||
```
|
||||
|
||||
### 6.3 i18n 结构
|
||||
|
||||
```json
|
||||
// ai.json
|
||||
{
|
||||
"chat": {
|
||||
"title": "AI Assistant",
|
||||
"placeholder": "Ask anything...",
|
||||
"sending": "Sending...",
|
||||
"error": "AI request failed"
|
||||
},
|
||||
"provider": {
|
||||
"selector": { "label": "AI Provider", "placeholder": "Select provider" },
|
||||
"manage": { "label": "Manage", "title": "AI Provider Settings" }
|
||||
},
|
||||
"suggestion": {
|
||||
"loading": "AI is thinking...",
|
||||
"empty": "No suggestions",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"errorBook": {
|
||||
"similarQuestions": "Similar Questions",
|
||||
"weaknessAnalysis": "Weakness Analysis"
|
||||
},
|
||||
"grading": {
|
||||
"aiSuggest": "AI Grading Suggestion",
|
||||
"applyScore": "Apply Score",
|
||||
"applyFeedback": "Apply Feedback"
|
||||
},
|
||||
"lessonPrep": {
|
||||
"generateContent": "Generate Content",
|
||||
"generateActivity": "Suggest Activity"
|
||||
},
|
||||
"exam": {
|
||||
"generate": "Generate",
|
||||
"queue": "Add to Queue",
|
||||
"preview": "Preview"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 配置驱动
|
||||
|
||||
```typescript
|
||||
// 角色配置决定可用 AI 能力
|
||||
const AI_CAPABILITY_CONFIG: Record<Role, AiCapability[]> = {
|
||||
admin: ["chat", "usage-stats"],
|
||||
teacher: ["chat", "exam-generate", "grading-assist", "lesson-content", "question-variant"],
|
||||
student: ["chat", "similar-question", "study-path"],
|
||||
parent: ["chat", "child-summary"],
|
||||
}
|
||||
```
|
||||
127
src/app/(dashboard)/student/error-book/page.tsx
Normal file
127
src/app/(dashboard)/student/error-book/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
import { getErrorBookItems, getErrorBookStats } from "@/modules/error-book/data-access"
|
||||
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
|
||||
import { ErrorBookFilters } from "@/modules/error-book/components/error-book-filters"
|
||||
import { ErrorBookList } from "@/modules/error-book/components/error-book-list"
|
||||
import { AddErrorBookDialog } from "@/modules/error-book/components/add-error-book-dialog"
|
||||
import type { ErrorBookStatusValue, ErrorBookSourceTypeValue } from "@/modules/error-book/types"
|
||||
import {
|
||||
AiClientProvider,
|
||||
type AiClientService,
|
||||
} from "@/modules/ai/context/ai-client-provider"
|
||||
import {
|
||||
aiChatAction,
|
||||
suggestSimilarQuestionsAction,
|
||||
suggestGradingAction,
|
||||
generateLessonContentAction,
|
||||
generateQuestionVariantAction,
|
||||
analyzeWeaknessAction,
|
||||
} from "@/modules/ai/actions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const VALID_STATUS = new Set(["new", "learning", "mastered", "archived"])
|
||||
const VALID_SOURCE = new Set(["exam", "homework", "manual"])
|
||||
|
||||
function parseStatus(v?: string): ErrorBookStatusValue | undefined {
|
||||
return v && VALID_STATUS.has(v) ? (v as ErrorBookStatusValue) : undefined
|
||||
}
|
||||
|
||||
function parseSource(v?: string): ErrorBookSourceTypeValue | undefined {
|
||||
return v && VALID_SOURCE.has(v) ? (v as ErrorBookSourceTypeValue) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 AI 客户端服务(Server Action 引用集合)
|
||||
*
|
||||
* 通过 React Context 注入,客户端组件不直接 import actions,
|
||||
* 遵循依赖注入模式,便于测试时替换为 mock。
|
||||
*/
|
||||
function createAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
}
|
||||
}
|
||||
|
||||
async function ErrorBookResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||
const params = await searchParams
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||
|
||||
const q = getParam(params, "q")
|
||||
const status = parseStatus(getParam(params, "status"))
|
||||
const sourceType = parseSource(getParam(params, "source"))
|
||||
const dueOnly = getParam(params, "due") === "due"
|
||||
|
||||
const { data: items } = await getErrorBookItems({
|
||||
studentId: ctx.userId,
|
||||
q: q || undefined,
|
||||
status,
|
||||
sourceType,
|
||||
dueOnly,
|
||||
pageSize: 50,
|
||||
})
|
||||
|
||||
return <ErrorBookList items={items} studentId={ctx.userId} errorItems={items} />
|
||||
}
|
||||
|
||||
function ErrorBookResultsFallback() {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<Skeleton key={idx} className="h-[180px] w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function StudentErrorBookPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||
const stats = await getErrorBookStats(ctx.userId)
|
||||
const aiClientService = createAiClientService()
|
||||
|
||||
return (
|
||||
<AiClientProvider service={aiClientService}>
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">错题本</h1>
|
||||
<p className="text-muted-foreground">
|
||||
自动收录考试与作业中的错题,科学复习,攻克薄弱点。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AddErrorBookDialog />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBookStatsCards stats={stats} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<ErrorBookFilters />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<ErrorBookResultsFallback />}>
|
||||
<ErrorBookResults searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</AiClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -7,13 +7,45 @@ import { normalizeStructure } from "@/modules/exams/utils/normalize-structure"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import type { ExamNode } from "@/modules/exams/components/assembly/selected-question-list"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import {
|
||||
AiClientProvider,
|
||||
type AiClientService,
|
||||
} from "@/modules/ai/context/ai-client-provider"
|
||||
import {
|
||||
aiChatAction,
|
||||
suggestSimilarQuestionsAction,
|
||||
suggestGradingAction,
|
||||
generateLessonContentAction,
|
||||
generateQuestionVariantAction,
|
||||
analyzeWeaknessAction,
|
||||
} from "@/modules/ai/actions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* 构建 AI 客户端服务(Server Action 引用集合)
|
||||
*
|
||||
* 通过 React Context 注入,客户端组件不直接 import actions,
|
||||
* 遵循依赖注入模式,便于测试时替换为 mock。
|
||||
*/
|
||||
function createAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
|
||||
const { id } = await params
|
||||
|
||||
const exam = await getExamById(id)
|
||||
const ctx = await requirePermission(Permissions.EXAM_READ)
|
||||
const exam = await getExamById(id, ctx.dataScope)
|
||||
if (!exam) return notFound()
|
||||
|
||||
// Fetch initial questions for the bank (pagination handled by client)
|
||||
@@ -69,24 +101,28 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id:
|
||||
}))
|
||||
}
|
||||
|
||||
const aiClientService = createAiClientService()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Build Exam</h1>
|
||||
<p className="text-muted-foreground">Assemble questions for your exam.</p>
|
||||
<AiClientProvider service={aiClientService}>
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Build Exam</h1>
|
||||
<p className="text-muted-foreground">Assemble questions for your exam.</p>
|
||||
</div>
|
||||
<ExamAssembly
|
||||
examId={exam.id}
|
||||
title={exam.title}
|
||||
subject={exam.subject}
|
||||
grade={exam.grade}
|
||||
difficulty={exam.difficulty}
|
||||
totalScore={exam.totalScore}
|
||||
durationMin={exam.durationMin}
|
||||
initialSelected={initialSelected}
|
||||
initialStructure={initialStructure}
|
||||
questionOptions={questionOptions}
|
||||
/>
|
||||
</div>
|
||||
<ExamAssembly
|
||||
examId={exam.id}
|
||||
title={exam.title}
|
||||
subject={exam.subject}
|
||||
grade={exam.grade}
|
||||
difficulty={exam.difficulty}
|
||||
totalScore={exam.totalScore}
|
||||
durationMin={exam.durationMin}
|
||||
initialSelected={initialSelected}
|
||||
initialStructure={initialStructure}
|
||||
questionOptions={questionOptions}
|
||||
/>
|
||||
</div>
|
||||
</AiClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,46 @@ import { notFound } from "next/navigation"
|
||||
import { getHomeworkSubmissionDetails } from "@/modules/homework/data-access"
|
||||
import { HomeworkGradingView } from "@/modules/homework/components/homework-grading-view"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import {
|
||||
AiClientProvider,
|
||||
type AiClientService,
|
||||
} from "@/modules/ai/context/ai-client-provider"
|
||||
import {
|
||||
suggestGradingAction,
|
||||
aiChatAction,
|
||||
suggestSimilarQuestionsAction,
|
||||
generateLessonContentAction,
|
||||
generateQuestionVariantAction,
|
||||
analyzeWeaknessAction,
|
||||
} from "@/modules/ai/actions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* 构建 AI 客户端服务(Server Action 引用集合)
|
||||
*
|
||||
* 通过 React Context 注入,客户端组件不直接 import actions,
|
||||
* 遵循依赖注入模式,便于测试时替换为 mock。
|
||||
*/
|
||||
function createAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }): Promise<JSX.Element> {
|
||||
const { submissionId } = await params
|
||||
const submission = await getHomeworkSubmissionDetails(submissionId)
|
||||
|
||||
if (!submission) return notFound()
|
||||
|
||||
const aiClientService = createAiClientService()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -29,17 +60,19 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeworkGradingView
|
||||
submissionId={submission.id}
|
||||
studentName={submission.studentName}
|
||||
assignmentTitle={submission.assignmentTitle}
|
||||
submittedAt={submission.submittedAt}
|
||||
status={submission.status}
|
||||
totalScore={submission.totalScore}
|
||||
answers={submission.answers}
|
||||
prevSubmissionId={submission.prevSubmissionId}
|
||||
nextSubmissionId={submission.nextSubmissionId}
|
||||
/>
|
||||
<AiClientProvider service={aiClientService}>
|
||||
<HomeworkGradingView
|
||||
submissionId={submission.id}
|
||||
studentName={submission.studentName}
|
||||
assignmentTitle={submission.assignmentTitle}
|
||||
submittedAt={submission.submittedAt}
|
||||
status={submission.status}
|
||||
totalScore={submission.totalScore}
|
||||
answers={submission.answers}
|
||||
prevSubmissionId={submission.prevSubmissionId}
|
||||
nextSubmissionId={submission.nextSubmissionId}
|
||||
/>
|
||||
</AiClientProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
|
||||
import { LessonPlanEditor } from "@/modules/lesson-preparation/components/lesson-plan-editor"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import {
|
||||
AiClientProvider,
|
||||
type AiClientService,
|
||||
} from "@/modules/ai/context/ai-client-provider"
|
||||
import {
|
||||
aiChatAction,
|
||||
suggestSimilarQuestionsAction,
|
||||
suggestGradingAction,
|
||||
generateLessonContentAction,
|
||||
generateQuestionVariantAction,
|
||||
analyzeWeaknessAction,
|
||||
} from "@/modules/ai/actions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* 构建 AI 客户端服务(Server Action 引用集合)
|
||||
*
|
||||
* 通过 React Context 注入,客户端组件不直接 import actions,
|
||||
* 遵循依赖注入模式,便于测试时替换为 mock。
|
||||
*/
|
||||
function createAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EditLessonPlanPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -23,16 +55,53 @@ export default async function EditLessonPlanPage({
|
||||
|
||||
const classes = teacherClasses.map((c) => ({ id: c.id, name: c.name }))
|
||||
|
||||
// 拉取教材/章节标题用于工具栏显示
|
||||
let textbookTitle: string | undefined
|
||||
let chapterTitle: string | undefined
|
||||
if (plan.textbookId) {
|
||||
const textbook = await getTextbookById(plan.textbookId)
|
||||
textbookTitle = textbook?.title
|
||||
if (plan.chapterId) {
|
||||
const chapters = await getChaptersByTextbookId(plan.textbookId)
|
||||
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
|
||||
for (const ch of list) {
|
||||
if (ch.id === plan.chapterId) return ch
|
||||
if (ch.children && ch.children.length > 0) {
|
||||
const found = findChapter(ch.children as typeof chapters)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const chapter = findChapter(chapters)
|
||||
chapterTitle = chapter?.title
|
||||
}
|
||||
}
|
||||
|
||||
const aiClientService = createAiClientService()
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<LessonPlanEditor
|
||||
planId={plan.id}
|
||||
initialTitle={plan.title}
|
||||
initialDoc={plan.content}
|
||||
textbookId={plan.textbookId ?? undefined}
|
||||
chapterId={plan.chapterId ?? undefined}
|
||||
classes={classes}
|
||||
/>
|
||||
</div>
|
||||
<AiClientProvider service={aiClientService}>
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanEditor
|
||||
planId={plan.id}
|
||||
initialTitle={plan.title}
|
||||
initialDoc={plan.content}
|
||||
textbookId={plan.textbookId ?? undefined}
|
||||
chapterId={plan.chapterId ?? undefined}
|
||||
textbookTitle={textbookTitle}
|
||||
chapterTitle={chapterTitle}
|
||||
classes={classes}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</AiClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { createAiChatCompletion, getAiErrorMessage, parseAiChatPayload } from "@/shared/lib/ai"
|
||||
import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -13,21 +14,20 @@ const getStatusFromError = (message: string) => {
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth()
|
||||
const userId = String(session?.user?.id ?? "").trim()
|
||||
if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
|
||||
|
||||
// Rate limit AI chat per user
|
||||
const limitKey = rateLimitKey("ai-chat", userId)
|
||||
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.AI_CHAT })
|
||||
if (!limit.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Rate limit exceeded. Please slow down." },
|
||||
{ status: 429, headers: rateLimitHeaders(limit) }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.AI_CHAT)
|
||||
const userId = ctx.userId
|
||||
|
||||
// Rate limit AI chat per user
|
||||
const limitKey = rateLimitKey("ai-chat", userId)
|
||||
const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.AI_CHAT })
|
||||
if (!limit.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Rate limit exceeded. Please slow down." },
|
||||
{ status: 429, headers: rateLimitHeaders(limit) }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
const input = parseAiChatPayload(body)
|
||||
const result = await createAiChatCompletion(input)
|
||||
@@ -36,6 +36,12 @@ export async function POST(req: Request) {
|
||||
{ headers: rateLimitHeaders(limit) }
|
||||
)
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: e.message },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
const message = getAiErrorMessage(e)
|
||||
return NextResponse.json({ success: false, message }, { status: getStatusFromError(message) })
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export default getRequestConfig(async () => {
|
||||
attendance,
|
||||
elective,
|
||||
school,
|
||||
ai,
|
||||
] = await Promise.all([
|
||||
import(`@/shared/i18n/messages/${locale}/common.json`),
|
||||
import(`@/shared/i18n/messages/${locale}/auth.json`),
|
||||
@@ -59,6 +60,7 @@ export default getRequestConfig(async () => {
|
||||
import(`@/shared/i18n/messages/${locale}/attendance.json`),
|
||||
import(`@/shared/i18n/messages/${locale}/elective.json`),
|
||||
import(`@/shared/i18n/messages/${locale}/school.json`),
|
||||
import(`@/shared/i18n/messages/${locale}/ai.json`),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -83,6 +85,7 @@ export default getRequestConfig(async () => {
|
||||
attendance: attendance.default,
|
||||
elective: elective.default,
|
||||
school: school.default,
|
||||
ai: ai.default,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
244
src/modules/ai/actions.ts
Normal file
244
src/modules/ai/actions.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
"use server"
|
||||
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import { createAiService, safeAiCall } from "./services/ai-service"
|
||||
import {
|
||||
AiChatInputSchema,
|
||||
GradingInputSchema,
|
||||
LessonContentInputSchema,
|
||||
QuestionVariantInputSchema,
|
||||
SimilarQuestionInputSchema,
|
||||
WeaknessAnalysisInputSchema,
|
||||
} from "./schema"
|
||||
import type {
|
||||
AiChatMessage,
|
||||
AiChatResult,
|
||||
GradingInput,
|
||||
GradingSuggestion,
|
||||
LessonContentInput,
|
||||
LessonContentResult,
|
||||
QuestionVariantInput,
|
||||
QuestionVariantResult,
|
||||
SimilarQuestionInput,
|
||||
SimilarQuestionResult,
|
||||
WeaknessAnalysisInput,
|
||||
WeaknessAnalysisResult,
|
||||
} from "./types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 辅助:并行校验多个权限
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const requireAiPermission = async (
|
||||
...permissions: readonly string[]
|
||||
): Promise<{ userId: string }> => {
|
||||
const results = await Promise.all(
|
||||
permissions.map((p) => requirePermission(p as never))
|
||||
)
|
||||
return { userId: results[0].userId }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 聊天
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function aiChatAction(input: {
|
||||
messages: AiChatMessage[]
|
||||
providerId?: string
|
||||
}): Promise<ActionState<AiChatResult>> {
|
||||
const t = await getTranslations("ai")
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.AI_CHAT)
|
||||
const parsed = AiChatInputSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: t("error.invalidInput") }
|
||||
}
|
||||
|
||||
const service = createAiService(ctx.userId)
|
||||
const result = await safeAiCall(() =>
|
||||
service.chat(parsed.data.messages, {
|
||||
providerId: parsed.data.providerId,
|
||||
})
|
||||
)
|
||||
if (!result.ok) {
|
||||
return { success: false, message: result.message }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
return { success: false, message: t("error.chatFailed") }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 相似题推荐
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function suggestSimilarQuestionsAction(
|
||||
input: SimilarQuestionInput
|
||||
): Promise<ActionState<SimilarQuestionResult[]>> {
|
||||
const t = await getTranslations("ai")
|
||||
try {
|
||||
const ctx = await requireAiPermission(
|
||||
Permissions.AI_CHAT,
|
||||
Permissions.ERROR_BOOK_READ
|
||||
)
|
||||
const parsed = SimilarQuestionInputSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: t("error.invalidInput") }
|
||||
}
|
||||
|
||||
const service = createAiService(ctx.userId)
|
||||
const result = await safeAiCall(() =>
|
||||
service.suggestSimilarQuestions(parsed.data)
|
||||
)
|
||||
if (!result.ok) {
|
||||
return { success: false, message: result.message }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
return { success: false, message: t("error.suggestionFailed") }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 辅助批改
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function suggestGradingAction(
|
||||
input: GradingInput
|
||||
): Promise<ActionState<GradingSuggestion>> {
|
||||
const t = await getTranslations("ai")
|
||||
try {
|
||||
const ctx = await requireAiPermission(
|
||||
Permissions.AI_CHAT,
|
||||
Permissions.HOMEWORK_GRADE
|
||||
)
|
||||
const parsed = GradingInputSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: t("error.invalidInput") }
|
||||
}
|
||||
|
||||
const service = createAiService(ctx.userId)
|
||||
const result = await safeAiCall(() => service.suggestGrading(parsed.data))
|
||||
if (!result.ok) {
|
||||
return { success: false, message: result.message }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
return { success: false, message: t("error.gradingFailed") }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 备课内容生成
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateLessonContentAction(
|
||||
input: LessonContentInput
|
||||
): Promise<ActionState<LessonContentResult>> {
|
||||
const t = await getTranslations("ai")
|
||||
try {
|
||||
const ctx = await requireAiPermission(
|
||||
Permissions.AI_CHAT,
|
||||
Permissions.LESSON_PLAN_READ
|
||||
)
|
||||
const parsed = LessonContentInputSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: t("error.invalidInput") }
|
||||
}
|
||||
|
||||
const service = createAiService(ctx.userId)
|
||||
const result = await safeAiCall(() =>
|
||||
service.generateLessonContent(parsed.data)
|
||||
)
|
||||
if (!result.ok) {
|
||||
return { success: false, message: result.message }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
return { success: false, message: t("error.contentFailed") }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 题目变体生成
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function generateQuestionVariantAction(
|
||||
input: QuestionVariantInput
|
||||
): Promise<ActionState<QuestionVariantResult>> {
|
||||
const t = await getTranslations("ai")
|
||||
try {
|
||||
const ctx = await requireAiPermission(
|
||||
Permissions.AI_CHAT,
|
||||
Permissions.EXAM_AI_GENERATE
|
||||
)
|
||||
const parsed = QuestionVariantInputSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: t("error.invalidInput") }
|
||||
}
|
||||
|
||||
const service = createAiService(ctx.userId)
|
||||
const result = await safeAiCall(() =>
|
||||
service.generateQuestionVariant(parsed.data)
|
||||
)
|
||||
if (!result.ok) {
|
||||
return { success: false, message: result.message }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
return { success: false, message: t("error.variantFailed") }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 薄弱点分析
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function analyzeWeaknessAction(
|
||||
input: WeaknessAnalysisInput
|
||||
): Promise<ActionState<WeaknessAnalysisResult>> {
|
||||
const t = await getTranslations("ai")
|
||||
try {
|
||||
const ctx = await requireAiPermission(
|
||||
Permissions.AI_CHAT,
|
||||
Permissions.ERROR_BOOK_READ
|
||||
)
|
||||
const parsed = WeaknessAnalysisInputSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: t("error.invalidInput") }
|
||||
}
|
||||
|
||||
const service = createAiService(ctx.userId)
|
||||
const result = await safeAiCall(() => service.analyzeWeakness(parsed.data))
|
||||
if (!result.ok) {
|
||||
return { success: false, message: result.message }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
return { success: false, message: t("error.analysisFailed") }
|
||||
}
|
||||
}
|
||||
182
src/modules/ai/components/ai-chat-panel.tsx
Normal file
182
src/modules/ai/components/ai-chat-panel.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Send, Bot, User } 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 { AiChatSkeleton } from "./ai-skeleton"
|
||||
import { useAiClient } from "../context/ai-client-provider"
|
||||
import type { AiChatMessage } from "../types"
|
||||
|
||||
type AiChatPanelProps = {
|
||||
/** 初始系统提示词 */
|
||||
systemPrompt?: string
|
||||
/** 上下文信息(注入到 user message 前面) */
|
||||
contextMessage?: string
|
||||
/** 占位提示文本 */
|
||||
placeholder?: string
|
||||
/** 标题 */
|
||||
title?: string
|
||||
/** 最大消息数 */
|
||||
maxMessages?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 聊天面板
|
||||
*
|
||||
* 通用 AI 对话组件,可嵌入任何页面。
|
||||
* 通过 useAiClient() 获取 Server Action 引用,不直接 import actions。
|
||||
*/
|
||||
export function AiChatPanel({
|
||||
systemPrompt,
|
||||
contextMessage,
|
||||
placeholder,
|
||||
title,
|
||||
maxMessages = 50,
|
||||
}: AiChatPanelProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
const aiClient = useAiClient()
|
||||
const [messages, setMessages] = useState<AiChatMessage[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
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 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 requestMessages: AiChatMessage[] = [
|
||||
...(systemMessage ? [systemMessage] : []),
|
||||
...messages,
|
||||
{ role: "user" as const, 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])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && messages.length === 0) {
|
||||
return <AiChatSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
{title ?? t("chat.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{messages.length > 0 ? (
|
||||
<ScrollArea className="h-[300px] w-full rounded-md border p-3">
|
||||
<div className="space-y-3" ref={scrollRef}>
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex gap-2 ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<Bot className="h-5 w-5 shrink-0 text-primary mt-0.5" />
|
||||
) : (
|
||||
<User className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
|
||||
)}
|
||||
<div
|
||||
className={`rounded-md px-3 py-2 text-sm max-w-[80%] ${
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading ? (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? t("chat.placeholder")}
|
||||
className="min-h-[60px] resize-none"
|
||||
disabled={loading || 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>
|
||||
</div>
|
||||
{messages.length >= maxMessages ? (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("chat.maxReached")}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
246
src/modules/ai/components/ai-error-book-analysis.tsx
Normal file
246
src/modules/ai/components/ai-error-book-analysis.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Sparkles, Lightbulb, BookOpen, TrendingDown } 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 { WeaknessAnalysisResult, SimilarQuestionResult } from "@/modules/ai/types"
|
||||
|
||||
type AiErrorBookAnalysisProps = {
|
||||
/** 错题列表(用于薄弱点分析) */
|
||||
errorItems: Array<{
|
||||
questionText: string
|
||||
questionType: string
|
||||
knowledgePointIds?: string[]
|
||||
errorCount: number
|
||||
masteryLevel: number
|
||||
}>
|
||||
/** 学生 ID */
|
||||
studentId: string
|
||||
/** 学科 ID */
|
||||
subjectId?: string
|
||||
/** 当前错题的题目文本(用于相似题推荐) */
|
||||
currentQuestionText?: string
|
||||
/** 当前题目类型 */
|
||||
currentQuestionType?: string
|
||||
/** 选中相似题后的回调 */
|
||||
onSelectSimilarQuestion?: (question: SimilarQuestionResult) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 错题本 AI 分析组件
|
||||
*
|
||||
* 集成两个 AI 能力:
|
||||
* 1. 相似题推荐:根据当前错题生成同类练习
|
||||
* 2. 薄弱点分析:分析错题分布,生成学习建议
|
||||
*
|
||||
* 通过 AiClientProvider 注入服务,不直接 import actions。
|
||||
*/
|
||||
export function AiErrorBookAnalysis({
|
||||
errorItems,
|
||||
studentId,
|
||||
subjectId,
|
||||
currentQuestionText,
|
||||
currentQuestionType,
|
||||
onSelectSimilarQuestion,
|
||||
}: AiErrorBookAnalysisProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
const aiClient = useAiClient()
|
||||
const [similarLoading, setSimilarLoading] = useState(false)
|
||||
const [weaknessLoading, setWeaknessLoading] = useState(false)
|
||||
const [similarQuestions, setSimilarQuestions] = useState<SimilarQuestionResult[]>([])
|
||||
const [weaknessResult, setWeaknessResult] = useState<WeaknessAnalysisResult | null>(null)
|
||||
|
||||
const handleGenerateSimilar = async (): Promise<void> => {
|
||||
if (!currentQuestionText || !currentQuestionType) return
|
||||
setSimilarLoading(true)
|
||||
try {
|
||||
const result = await aiClient.suggestSimilarQuestions({
|
||||
questionText: currentQuestionText,
|
||||
questionType: currentQuestionType,
|
||||
subject: subjectId,
|
||||
count: 3,
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setSimilarQuestions(result.data)
|
||||
toast.success(t("suggestion.loaded"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("suggestion.error"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("suggestion.error"))
|
||||
} finally {
|
||||
setSimilarLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnalyzeWeakness = async (): Promise<void> => {
|
||||
if (errorItems.length === 0) return
|
||||
setWeaknessLoading(true)
|
||||
try {
|
||||
const result = await aiClient.analyzeWeakness({
|
||||
studentId,
|
||||
subjectId,
|
||||
errorItems,
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setWeaknessResult(result.data)
|
||||
toast.success(t("errorBook.weaknessAnalysis"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("error.analysisFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error.analysisFailed"))
|
||||
} finally {
|
||||
setWeaknessLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const severityVariant = (severity: "high" | "medium" | "low"): "destructive" | "secondary" | "outline" => {
|
||||
if (severity === "high") return "destructive"
|
||||
if (severity === "medium") return "secondary"
|
||||
return "outline"
|
||||
}
|
||||
|
||||
return (
|
||||
<AiErrorBoundary>
|
||||
<div className="space-y-4">
|
||||
{/* 相似题推荐 */}
|
||||
{currentQuestionText ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("errorBook.similarQuestions")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("suggestion.title")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{similarLoading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
) : similarQuestions.length > 0 ? (
|
||||
<>
|
||||
{similarQuestions.map((question, index) => (
|
||||
<div key={index} className="rounded-md border p-3 space-y-2">
|
||||
<p className="text-sm">{question.text}</p>
|
||||
{question.difficulty ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("suggestion.difficulty")}: {question.difficulty}
|
||||
</Badge>
|
||||
) : null}
|
||||
{question.options && question.options.length > 0 ? (
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
{question.options.map((opt, optIndex) => (
|
||||
<li key={optIndex}>
|
||||
<span className="font-medium">{opt.id}.</span> {opt.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{question.explanation ? (
|
||||
<p className="text-xs text-muted-foreground italic">{question.explanation}</p>
|
||||
) : null}
|
||||
{onSelectSimilarQuestion ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSelectSimilarQuestion(question)}
|
||||
>
|
||||
{t("suggestion.select")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleGenerateSimilar} className="w-full">
|
||||
{t("suggestion.regenerate")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleGenerateSimilar} className="w-full">
|
||||
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
||||
{t("suggestion.generate")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* 薄弱点分析 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingDown className="h-4 w-4 text-primary" />
|
||||
{t("errorBook.weaknessAnalysis")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("errorBook.weakAreas")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{weaknessLoading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
) : weaknessResult ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{t("errorBook.weakAreas")}</h4>
|
||||
{weaknessResult.weakAreas.map((area, index) => (
|
||||
<div key={index} className="rounded-md border p-3 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{area.area}</span>
|
||||
<Badge variant={severityVariant(area.severity)}>
|
||||
{t(`errorBook.severity.${area.severity}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{area.suggestion}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium flex items-center gap-1">
|
||||
<Lightbulb className="h-3.5 w-3.5" />
|
||||
{t("errorBook.studyPlan")}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">{weaknessResult.studyPlan}</p>
|
||||
</div>
|
||||
{weaknessResult.recommendedResources.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium flex items-center gap-1">
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
{t("errorBook.recommendedResources")}
|
||||
</h4>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
{weaknessResult.recommendedResources.map((resource, index) => (
|
||||
<li key={index}>{resource}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAnalyzeWeakness} className="w-full">
|
||||
{t("suggestion.regenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAnalyzeWeakness}
|
||||
disabled={errorItems.length === 0}
|
||||
className="w-full"
|
||||
>
|
||||
<TrendingDown className="mr-1 h-3.5 w-3.5" />
|
||||
{t("suggestion.generate")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AiErrorBoundary>
|
||||
)
|
||||
}
|
||||
88
src/modules/ai/components/ai-error-boundary.tsx
Normal file
88
src/modules/ai/components/ai-error-boundary.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import { Component, type ReactNode } from "react"
|
||||
import { AlertCircle, RefreshCw } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
type AiErrorBoundaryProps = {
|
||||
children: ReactNode
|
||||
/** 自定义 fallback 渲染 */
|
||||
fallback?: (error: Error, reset: () => void) => ReactNode
|
||||
/** 错误回调(用于埋点) */
|
||||
onError?: (error: Error, info: unknown) => void
|
||||
}
|
||||
|
||||
type AiErrorBoundaryState = {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 专用 Error Boundary
|
||||
*
|
||||
* 包裹所有 AI 数据区块,防止单个 AI 调用失败导致整页崩溃。
|
||||
* 提供重试按钮与友好的错误提示。
|
||||
*/
|
||||
export class AiErrorBoundary extends Component<
|
||||
AiErrorBoundaryProps,
|
||||
AiErrorBoundaryState
|
||||
> {
|
||||
constructor(props: AiErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): AiErrorBoundaryState {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: unknown): void {
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, info)
|
||||
}
|
||||
}
|
||||
|
||||
private handleReset = (): void => {
|
||||
this.setState({ error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error, this.handleReset)
|
||||
}
|
||||
return <DefaultAiErrorFallback error={this.state.error} onReset={this.handleReset} />
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function DefaultAiErrorFallback({
|
||||
error,
|
||||
onReset,
|
||||
}: {
|
||||
error: Error
|
||||
onReset: () => void
|
||||
}): ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
return (
|
||||
<Card className="border-destructive/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{t("error.boundaryTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("error.boundaryDescription")}</p>
|
||||
<p className="text-xs text-muted-foreground/70 font-mono">{error.message}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onReset}>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
173
src/modules/ai/components/ai-grading-assist.tsx
Normal file
173
src/modules/ai/components/ai-grading-assist.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Sparkles, Check } 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 { Progress } from "@/shared/components/ui/progress"
|
||||
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 { GradingSuggestion } from "@/modules/ai/types"
|
||||
|
||||
type AiGradingAssistProps = {
|
||||
/** 题目文本 */
|
||||
questionText: string
|
||||
/** 题目类型 */
|
||||
questionType: string
|
||||
/** 学生答案 */
|
||||
studentAnswer: string
|
||||
/** 正确答案(可选) */
|
||||
correctAnswer?: string
|
||||
/** 最大分值 */
|
||||
maxScore: number
|
||||
/** 学科 */
|
||||
subject?: string
|
||||
/** 应用建议分数 */
|
||||
onApplyScore?: (score: number) => void
|
||||
/** 应用建议反馈 */
|
||||
onApplyFeedback?: (feedback: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 批改辅助组件
|
||||
*
|
||||
* 为教师提供 AI 预评分与反馈建议。
|
||||
* 仅用于主观题(text/essay),客观题由系统自动判分。
|
||||
*/
|
||||
export function AiGradingAssist({
|
||||
questionText,
|
||||
questionType,
|
||||
studentAnswer,
|
||||
correctAnswer,
|
||||
maxScore,
|
||||
subject,
|
||||
onApplyScore,
|
||||
onApplyFeedback,
|
||||
}: AiGradingAssistProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
const aiClient = useAiClient()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [suggestion, setSuggestion] = useState<GradingSuggestion | null>(null)
|
||||
|
||||
// 仅对主观题提供 AI 批改
|
||||
const isAutoGradable = questionType === "single_choice" || questionType === "multiple_choice" || questionType === "judgment"
|
||||
if (isAutoGradable) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await aiClient.suggestGrading({
|
||||
questionText,
|
||||
questionType,
|
||||
studentAnswer,
|
||||
correctAnswer,
|
||||
maxScore,
|
||||
subject,
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setSuggestion(result.data)
|
||||
toast.success(t("grading.title"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("grading.error"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("grading.error"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confidencePercent = suggestion ? Math.round(suggestion.confidence * 100) : 0
|
||||
|
||||
return (
|
||||
<AiErrorBoundary>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("grading.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("grading.title")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{loading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
) : suggestion ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t("grading.suggestedScore")}</span>
|
||||
<Badge variant="secondary" className="text-base">
|
||||
{suggestion.suggestedScore} / {maxScore}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{t("grading.confidence")}</span>
|
||||
<span>{confidencePercent}%</span>
|
||||
</div>
|
||||
<Progress value={confidencePercent} className="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium">{t("grading.feedback")}</h4>
|
||||
<p className="text-sm text-muted-foreground rounded-md bg-muted p-2">
|
||||
{suggestion.feedback}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-medium">{t("grading.reasoning")}</h4>
|
||||
<p className="text-xs text-muted-foreground">{suggestion.reasoning}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{onApplyScore ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onApplyScore(suggestion.suggestedScore)
|
||||
toast.success(t("grading.suggestedScore"))
|
||||
}}
|
||||
>
|
||||
<Check className="mr-1 h-3.5 w-3.5" />
|
||||
{t("grading.applyScore")}
|
||||
</Button>
|
||||
) : null}
|
||||
{onApplyFeedback ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onApplyFeedback(suggestion.feedback)
|
||||
toast.success(t("grading.feedback"))
|
||||
}}
|
||||
>
|
||||
<Check className="mr-1 h-3.5 w-3.5" />
|
||||
{t("grading.applyFeedback")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={handleGenerate}>
|
||||
{t("suggestion.regenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleGenerate} className="w-full">
|
||||
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
||||
{t("grading.title")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AiErrorBoundary>
|
||||
)
|
||||
}
|
||||
187
src/modules/ai/components/ai-lesson-content-generator.tsx
Normal file
187
src/modules/ai/components/ai-lesson-content-generator.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Sparkles, BookOpen, Lightbulb, HelpCircle, FileText } 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 { Textarea } from "@/shared/components/ui/textarea"
|
||||
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 { LessonContentResult } from "@/modules/ai/types"
|
||||
|
||||
type ContentType = "activity" | "assessment" | "question" | "material"
|
||||
|
||||
type AiLessonContentGeneratorProps = {
|
||||
/** 备课主题 */
|
||||
topic: string
|
||||
/** 学科 */
|
||||
subject?: string
|
||||
/** 年级 */
|
||||
grade?: string
|
||||
/** 教材 ID */
|
||||
textbookId?: string
|
||||
/** 章节 ID */
|
||||
chapterId?: string
|
||||
/** 生成内容后的回调 */
|
||||
onInsertContent?: (result: LessonContentResult) => void
|
||||
}
|
||||
|
||||
const CONTENT_TYPE_ICONS: Record<ContentType, typeof Sparkles> = {
|
||||
activity: Lightbulb,
|
||||
assessment: FileText,
|
||||
question: HelpCircle,
|
||||
material: BookOpen,
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 备课内容生成器
|
||||
*
|
||||
* 为教师提供 AI 生成教学活动、评估题、讨论题、教学素材的能力。
|
||||
* 通过 AiClientProvider 注入服务,不直接 import actions。
|
||||
*
|
||||
* 使用场景:在备课编辑器侧边栏中作为辅助工具使用。
|
||||
*/
|
||||
export function AiLessonContentGenerator({
|
||||
topic,
|
||||
subject,
|
||||
grade,
|
||||
textbookId,
|
||||
chapterId,
|
||||
onInsertContent,
|
||||
}: AiLessonContentGeneratorProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
const aiClient = useAiClient()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<LessonContentResult | null>(null)
|
||||
const [activeType, setActiveType] = useState<ContentType>("activity")
|
||||
const [additionalContext, setAdditionalContext] = useState("")
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
if (!topic.trim()) {
|
||||
toast.error(t("lessonPrep.error"))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await aiClient.generateLessonContent({
|
||||
topic,
|
||||
subject,
|
||||
grade,
|
||||
textbookId,
|
||||
chapterId,
|
||||
contentType: activeType,
|
||||
additionalContext: additionalContext.trim() || undefined,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
setResult(response.data)
|
||||
toast.success(t("lessonPrep.generateContent"))
|
||||
} else {
|
||||
toast.error(response.message ?? t("lessonPrep.error"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("lessonPrep.error"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const contentTypes: Array<{ type: ContentType; label: string; icon: typeof Sparkles }> = [
|
||||
{ type: "activity", label: t("lessonPrep.generateActivity"), icon: Lightbulb },
|
||||
{ type: "assessment", label: t("lessonPrep.generateAssessment"), icon: FileText },
|
||||
{ type: "question", label: t("lessonPrep.generateQuestion"), icon: HelpCircle },
|
||||
{ type: "material", label: t("lessonPrep.generateContent"), icon: BookOpen },
|
||||
]
|
||||
|
||||
return (
|
||||
<AiErrorBoundary>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("lessonPrep.generateContent")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("lessonPrep.generateContent")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 内容类型选择 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{contentTypes.map(({ type, label, icon: Icon }) => (
|
||||
<Button
|
||||
key={type}
|
||||
type="button"
|
||||
variant={activeType === type ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={() => setActiveType(type)}
|
||||
>
|
||||
<Icon className="mr-1 h-3.5 w-3.5" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 附加上下文 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor="ai-additional-context">
|
||||
{t("lessonPrep.generateContent")}
|
||||
</label>
|
||||
<Textarea
|
||||
id="ai-additional-context"
|
||||
value={additionalContext}
|
||||
onChange={(e) => setAdditionalContext(e.target.value)}
|
||||
placeholder={t("lessonPrep.generateContent")}
|
||||
className="min-h-[60px] text-sm"
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !topic.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
||||
{loading ? t("lessonPrep.loading") : t("lessonPrep.generateContent")}
|
||||
</Button>
|
||||
|
||||
{/* 生成结果 */}
|
||||
{loading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
) : result ? (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="text-sm font-medium">{result.title}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{activeType}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{result.content}
|
||||
</p>
|
||||
{onInsertContent ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onInsertContent(result)
|
||||
toast.success(t("lessonPrep.generateContent"))
|
||||
}}
|
||||
>
|
||||
{t("lessonPrep.generateContent")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AiErrorBoundary>
|
||||
)
|
||||
}
|
||||
129
src/modules/ai/components/ai-provider-selector.tsx
Normal file
129
src/modules/ai/components/ai-provider-selector.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Settings } from "lucide-react"
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/shared/components/ui/form"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import type { Control } from "react-hook-form"
|
||||
|
||||
/** AI Provider 摘要信息(与 settings 模块类型兼容) */
|
||||
export type AiProviderOption = {
|
||||
id: string
|
||||
provider: string
|
||||
model: string
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
type AiProviderSelectorProps = {
|
||||
/** react-hook-form control */
|
||||
control: Control<Record<string, unknown>>
|
||||
/** 表单字段名 */
|
||||
name: string
|
||||
/** Provider 列表 */
|
||||
providers: AiProviderOption[]
|
||||
/** 是否加载中 */
|
||||
loading?: boolean
|
||||
/** Provider 标签映射 */
|
||||
providerLabels?: Record<string, string>
|
||||
/** 管理面板触发器 */
|
||||
managePanel?: React.ReactNode
|
||||
/** 管理面板打开状态 */
|
||||
manageOpen?: boolean
|
||||
onManageOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Provider 选择器
|
||||
*
|
||||
* 可复用的表单字段组件,用于选择 AI Provider。
|
||||
* 从 exam-ai-generator.tsx 抽取,支持在任何需要 AI Provider 选择的表单中复用。
|
||||
*/
|
||||
export function AiProviderSelector({
|
||||
control,
|
||||
name,
|
||||
providers,
|
||||
loading = false,
|
||||
providerLabels,
|
||||
managePanel,
|
||||
manageOpen,
|
||||
onManageOpenChange,
|
||||
}: AiProviderSelectorProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormLabel>{t("provider.label")}</FormLabel>
|
||||
{managePanel ? (
|
||||
<Dialog open={manageOpen} onOpenChange={onManageOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||
{t("provider.manage")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[960px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("provider.manageTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("provider.manageDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{managePanel}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</div>
|
||||
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={loading ? t("provider.loading") : t("provider.placeholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{providers.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{providerLabels?.[item.provider] ?? item.provider} · {item.model}
|
||||
{item.isDefault ? ` (${t("provider.default")})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>{t("provider.description")}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
208
src/modules/ai/components/ai-question-variant-generator.tsx
Normal file
208
src/modules/ai/components/ai-question-variant-generator.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Sparkles, RefreshCw, Plus } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
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 { QuestionVariantResult } from "@/modules/ai/types"
|
||||
|
||||
type VariantType = "same_knowledge_point" | "different_difficulty" | "different_format"
|
||||
|
||||
type AiQuestionVariantGeneratorProps = {
|
||||
/** 原始题目 */
|
||||
originalQuestion: {
|
||||
text: string
|
||||
type: string
|
||||
difficulty?: number
|
||||
options?: Array<{ id: string; text: string; isCorrect?: boolean }>
|
||||
answer?: string
|
||||
}
|
||||
/** 学科 */
|
||||
subject?: string
|
||||
/** 生成变体后的回调 */
|
||||
onAddVariant?: (variant: QuestionVariantResult) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 题目变体生成器
|
||||
*
|
||||
* 为教师提供从现有题目生成变体的能力:
|
||||
* - same_knowledge_point: 同知识点不同表述
|
||||
* - different_difficulty: 调整难度
|
||||
* - different_format: 转换题型
|
||||
*
|
||||
* 通过 AiClientProvider 注入服务,不直接 import actions。
|
||||
*/
|
||||
export function AiQuestionVariantGenerator({
|
||||
originalQuestion,
|
||||
subject,
|
||||
onAddVariant,
|
||||
}: AiQuestionVariantGeneratorProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
const aiClient = useAiClient()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [variant, setVariant] = useState<QuestionVariantResult | null>(null)
|
||||
const [variantType, setVariantType] = useState<VariantType>("same_knowledge_point")
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
if (!originalQuestion.text.trim()) {
|
||||
toast.error(t("error.invalidInput"))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await aiClient.generateQuestionVariant({
|
||||
originalQuestion,
|
||||
subject,
|
||||
variantType,
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setVariant(result.data)
|
||||
toast.success(t("exam.generate"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("error.variantFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error.variantFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const variantTypeLabels: Record<VariantType, string> = {
|
||||
same_knowledge_point: t("exam.generate"),
|
||||
different_difficulty: t("exam.generate"),
|
||||
different_format: t("exam.generate"),
|
||||
}
|
||||
|
||||
return (
|
||||
<AiErrorBoundary>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("capability.questionVariant")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("exam.generate")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 变体类型选择 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor="variant-type">
|
||||
{t("exam.generate")}
|
||||
</label>
|
||||
<Select
|
||||
value={variantType}
|
||||
onValueChange={(value) => setVariantType(value as VariantType)}
|
||||
>
|
||||
<SelectTrigger id="variant-type" className="w-full">
|
||||
<SelectValue placeholder={t("exam.generate")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="same_knowledge_point">
|
||||
{variantTypeLabels.same_knowledge_point}
|
||||
</SelectItem>
|
||||
<SelectItem value="different_difficulty">
|
||||
{variantTypeLabels.different_difficulty}
|
||||
</SelectItem>
|
||||
<SelectItem value="different_format">
|
||||
{variantTypeLabels.different_format}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !originalQuestion.text.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
||||
{loading ? t("exam.generating") : t("exam.generate")}
|
||||
</Button>
|
||||
|
||||
{/* 生成结果 */}
|
||||
{loading ? (
|
||||
<AiSuggestionSkeleton />
|
||||
) : variant ? (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="text-sm font-medium">{variant.text}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("suggestion.difficulty")}: {variant.difficulty}
|
||||
</Badge>
|
||||
</div>
|
||||
{variant.options && variant.options.length > 0 ? (
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
{variant.options.map((opt, index) => (
|
||||
<li key={index} className="flex items-center gap-1">
|
||||
<span className="font-medium">{opt.id}.</span>
|
||||
<span>{opt.text}</span>
|
||||
{opt.isCorrect ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
✓
|
||||
</Badge>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{variant.answer ? (
|
||||
<div className="text-xs">
|
||||
<span className="font-medium">{t("exam.sourceText")}:</span>{" "}
|
||||
<span className="text-muted-foreground">{variant.answer}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{variant.explanation ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{variant.explanation}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
{onAddVariant ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onAddVariant(variant)
|
||||
toast.success(t("exam.generate"))
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
{t("exam.generate")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
{t("suggestion.regenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AiErrorBoundary>
|
||||
)
|
||||
}
|
||||
47
src/modules/ai/components/ai-skeleton.tsx
Normal file
47
src/modules/ai/components/ai-skeleton.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* AI 建议加载骨架屏
|
||||
*
|
||||
* 在 AI 异步调用期间显示,提供视觉反馈。
|
||||
*/
|
||||
export function AiSuggestionSkeleton(): React.ReactNode {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 聊天加载骨架屏
|
||||
*/
|
||||
export function AiChatSkeleton(): React.ReactNode {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
164
src/modules/ai/components/ai-suggestion-card.tsx
Normal file
164
src/modules/ai/components/ai-suggestion-card.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Sparkles, Check, X, RefreshCw } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { AiSuggestionSkeleton } from "./ai-skeleton"
|
||||
import { useAiClient } from "../context/ai-client-provider"
|
||||
import type { SimilarQuestionResult } from "../types"
|
||||
|
||||
type AiSuggestionCardProps = {
|
||||
/** 原始题目文本 */
|
||||
questionText: string
|
||||
/** 题目类型 */
|
||||
questionType: string
|
||||
/** 学科 */
|
||||
subject?: string
|
||||
/** 知识点 ID 列表 */
|
||||
knowledgePointIds?: string[]
|
||||
/** 需要生成的题目数量 */
|
||||
count?: number
|
||||
/** 选中题目后的回调 */
|
||||
onSelectQuestion?: (question: SimilarQuestionResult) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 相似题建议卡片
|
||||
*
|
||||
* 可复用组件,展示 AI 生成的相似练习题。
|
||||
* 用于错题本、作业练习等场景。
|
||||
*/
|
||||
export function AiSuggestionCard({
|
||||
questionText,
|
||||
questionType,
|
||||
subject,
|
||||
knowledgePointIds,
|
||||
count = 3,
|
||||
onSelectQuestion,
|
||||
}: AiSuggestionCardProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
const aiClient = useAiClient()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [questions, setQuestions] = useState<SimilarQuestionResult[]>([])
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await aiClient.suggestSimilarQuestions({
|
||||
questionText,
|
||||
questionType,
|
||||
subject,
|
||||
knowledgePointIds,
|
||||
count,
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setQuestions(result.data)
|
||||
setHasLoaded(true)
|
||||
toast.success(t("suggestion.loaded"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("suggestion.error"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("suggestion.error"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (question: SimilarQuestionResult): void => {
|
||||
onSelectQuestion?.(question)
|
||||
toast.success(t("suggestion.selected"))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <AiSuggestionSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
{t("suggestion.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{hasLoaded && questions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("suggestion.empty")}</p>
|
||||
) : questions.length > 0 ? (
|
||||
<>
|
||||
{questions.map((question, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-md border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm flex-1">{question.text}</p>
|
||||
{question.difficulty ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{t("suggestion.difficulty")}: {question.difficulty}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{question.options && question.options.length > 0 ? (
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
{question.options.map((opt, optIndex) => (
|
||||
<li key={optIndex}>
|
||||
<span className="font-medium">{opt.id}.</span> {opt.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{question.explanation ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{question.explanation}
|
||||
</p>
|
||||
) : null}
|
||||
{onSelectQuestion ? (
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSelect(question)}
|
||||
>
|
||||
<Check className="mr-1 h-3.5 w-3.5" />
|
||||
{t("suggestion.select")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
{t("suggestion.regenerate")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
className="w-full"
|
||||
>
|
||||
<Sparkles className="mr-1 h-3.5 w-3.5" />
|
||||
{t("suggestion.generate")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
62
src/modules/ai/context/ai-client-provider.tsx
Normal file
62
src/modules/ai/context/ai-client-provider.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react"
|
||||
|
||||
import type { AiClientService } from "../types"
|
||||
|
||||
/**
|
||||
* AI 客户端服务 Context
|
||||
*
|
||||
* 通过 React Context 注入 AiClientService(Server Action 引用集合),
|
||||
* 客户端组件通过 useAiClient() 消费,不直接 import actions。
|
||||
*
|
||||
* 遵循 settings 模块的依赖注入模式:
|
||||
* - 页面层(Server Component)创建 service 对象并注入 Provider
|
||||
* - 组件层通过 Hook 消费
|
||||
* - 测试时可注入 mock service
|
||||
*/
|
||||
|
||||
// 重新导出 AiClientService 类型,方便调用方从单一入口导入
|
||||
export type { AiClientService } from "../types"
|
||||
|
||||
const AiClientContext = createContext<AiClientService | null>(null)
|
||||
|
||||
export function AiClientProvider({
|
||||
children,
|
||||
service,
|
||||
}: {
|
||||
children: ReactNode
|
||||
service: AiClientService
|
||||
}) {
|
||||
return (
|
||||
<AiClientContext.Provider value={service}>
|
||||
{children}
|
||||
</AiClientContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 AI 客户端服务
|
||||
*
|
||||
* 必须在 AiClientProvider 内部使用。
|
||||
* 若未注入,抛出错误以防止静默失败。
|
||||
*/
|
||||
export function useAiClient(): AiClientService {
|
||||
const service = useContext(AiClientContext)
|
||||
if (!service) {
|
||||
throw new Error(
|
||||
"useAiClient must be used within an AiClientProvider. " +
|
||||
"Wrap your component tree with <AiClientProvider service={...}>."
|
||||
)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取 AI 客户端服务(未注入时返回 null)
|
||||
*
|
||||
* 用于可选 AI 功能的场景,组件需自行处理 null 情况。
|
||||
*/
|
||||
export function useAiClientOptional(): AiClientService | null {
|
||||
return useContext(AiClientContext)
|
||||
}
|
||||
57
src/modules/ai/hooks/use-ai-chat.ts
Normal file
57
src/modules/ai/hooks/use-ai-chat.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { useAiClient } from "../context/ai-client-provider"
|
||||
import type { AiChatMessage, AiChatResult } from "../types"
|
||||
|
||||
/**
|
||||
* AI 聊天 Hook
|
||||
*
|
||||
* 封装 AI 聊天逻辑,与 UI 分离。
|
||||
* 通过 useAiClient() 获取 Server Action 引用。
|
||||
*/
|
||||
export function useAiChat(): {
|
||||
messages: AiChatMessage[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
send: (messages: AiChatMessage[], providerId?: string) => Promise<AiChatResult | null>
|
||||
clear: () => void
|
||||
} {
|
||||
const aiClient = useAiClient()
|
||||
const [messages, setMessages] = useState<AiChatMessage[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const send = useCallback(
|
||||
async (input: AiChatMessage[], providerId?: string): Promise<AiChatResult | null> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await aiClient.chat({ messages: input, providerId })
|
||||
if (result.success && result.data) {
|
||||
const assistantContent = result.data.content
|
||||
setMessages((prev) => [...prev, ...input, {
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
}])
|
||||
return result.data
|
||||
}
|
||||
setError(result.message ?? "AI request failed")
|
||||
return null
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[aiClient]
|
||||
)
|
||||
|
||||
const clear = useCallback((): void => {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return { messages, loading, error, send, clear }
|
||||
}
|
||||
72
src/modules/ai/hooks/use-ai-suggestion.ts
Normal file
72
src/modules/ai/hooks/use-ai-suggestion.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { useAiClient } from "../context/ai-client-provider"
|
||||
import type {
|
||||
SimilarQuestionInput,
|
||||
SimilarQuestionResult,
|
||||
GradingInput,
|
||||
GradingSuggestion,
|
||||
} from "../types"
|
||||
|
||||
/**
|
||||
* AI 建议 Hook
|
||||
*
|
||||
* 封装 AI 建议调用逻辑(相似题、批改建议等),与 UI 分离。
|
||||
*/
|
||||
export function useAiSuggestion(): {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
suggestSimilarQuestions: (
|
||||
input: SimilarQuestionInput
|
||||
) => Promise<SimilarQuestionResult[] | null>
|
||||
suggestGrading: (input: GradingInput) => Promise<GradingSuggestion | null>
|
||||
} {
|
||||
const aiClient = useAiClient()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const suggestSimilarQuestions = useCallback(
|
||||
async (input: SimilarQuestionInput): Promise<SimilarQuestionResult[] | null> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await aiClient.suggestSimilarQuestions(input)
|
||||
if (result.success && result.data) {
|
||||
return result.data
|
||||
}
|
||||
setError(result.message ?? "AI suggestion failed")
|
||||
return null
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[aiClient]
|
||||
)
|
||||
|
||||
const suggestGrading = useCallback(
|
||||
async (input: GradingInput): Promise<GradingSuggestion | null> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await aiClient.suggestGrading(input)
|
||||
if (result.success && result.data) {
|
||||
return result.data
|
||||
}
|
||||
setError(result.message ?? "AI grading failed")
|
||||
return null
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[aiClient]
|
||||
)
|
||||
|
||||
return { loading, error, suggestSimilarQuestions, suggestGrading }
|
||||
}
|
||||
134
src/modules/ai/schema.ts
Normal file
134
src/modules/ai/schema.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { z } from "zod"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 基础校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AiChatMessageSchema = z.object({
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
content: z.string().min(1).max(8000),
|
||||
})
|
||||
|
||||
export const AiChatInputSchema = z.object({
|
||||
messages: z.array(AiChatMessageSchema).min(1).max(50),
|
||||
providerId: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 业务场景校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SimilarQuestionInputSchema = z.object({
|
||||
questionText: z.string().min(1).max(4000),
|
||||
questionType: z.string().min(1),
|
||||
subject: z.string().optional(),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
count: z.number().int().min(1).max(10).optional(),
|
||||
})
|
||||
|
||||
export const GradingInputSchema = z.object({
|
||||
questionText: z.string().min(1).max(4000),
|
||||
questionType: z.string().min(1),
|
||||
studentAnswer: z.string().min(1).max(8000),
|
||||
correctAnswer: z.string().optional(),
|
||||
maxScore: z.number().int().min(1).max(100),
|
||||
subject: z.string().optional(),
|
||||
})
|
||||
|
||||
export const LessonContentInputSchema = z.object({
|
||||
topic: z.string().min(1).max(500),
|
||||
subject: z.string().optional(),
|
||||
grade: z.string().optional(),
|
||||
textbookId: z.string().optional(),
|
||||
chapterId: z.string().optional(),
|
||||
contentType: z.enum(["activity", "assessment", "question", "material"]),
|
||||
additionalContext: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
export const QuestionVariantInputSchema = z.object({
|
||||
originalQuestion: z.object({
|
||||
text: z.string().min(1).max(4000),
|
||||
type: z.string().min(1),
|
||||
difficulty: z.number().int().min(1).max(5).optional(),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
isCorrect: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
answer: z.string().optional(),
|
||||
}),
|
||||
subject: z.string().optional(),
|
||||
variantType: z.enum(["same_knowledge_point", "different_difficulty", "different_format"]),
|
||||
})
|
||||
|
||||
export const WeaknessAnalysisInputSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
subjectId: z.string().optional(),
|
||||
errorItems: z
|
||||
.array(
|
||||
z.object({
|
||||
questionText: z.string().min(1),
|
||||
questionType: z.string().min(1),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
errorCount: z.number().int().min(1),
|
||||
masteryLevel: z.number().int().min(0).max(5),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 返回结果校验(用于解析 AI JSON 输出)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SimilarQuestionResultSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
difficulty: z.number().int().min(1).max(5).optional(),
|
||||
options: z.array(z.object({ id: z.string(), text: z.string() })).optional(),
|
||||
answer: z.string().optional(),
|
||||
explanation: z.string().optional(),
|
||||
})
|
||||
|
||||
export const SimilarQuestionListSchema = z.array(SimilarQuestionResultSchema)
|
||||
|
||||
export const GradingSuggestionSchema = z.object({
|
||||
suggestedScore: z.number().min(0),
|
||||
confidence: z.number().min(0).max(1),
|
||||
feedback: z.string(),
|
||||
reasoning: z.string(),
|
||||
})
|
||||
|
||||
export const LessonContentResultSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export const QuestionVariantResultSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
difficulty: z.number().int().min(1).max(5),
|
||||
options: z
|
||||
.array(z.object({ id: z.string(), text: z.string(), isCorrect: z.boolean() }))
|
||||
.optional(),
|
||||
answer: z.string().optional(),
|
||||
explanation: z.string().optional(),
|
||||
})
|
||||
|
||||
export const WeaknessAnalysisResultSchema = z.object({
|
||||
weakAreas: z.array(
|
||||
z.object({
|
||||
area: z.string().min(1),
|
||||
severity: z.enum(["high", "medium", "low"]),
|
||||
suggestion: z.string().min(1),
|
||||
})
|
||||
),
|
||||
studyPlan: z.string().min(1),
|
||||
recommendedResources: z.array(z.string()),
|
||||
})
|
||||
346
src/modules/ai/services/ai-service.ts
Normal file
346
src/modules/ai/services/ai-service.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import "server-only"
|
||||
|
||||
import { env } from "@/env.mjs"
|
||||
import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai"
|
||||
|
||||
import {
|
||||
GRADING_ASSIST_SYSTEM_PROMPT,
|
||||
LESSON_CONTENT_SYSTEM_PROMPT,
|
||||
QUESTION_VARIANT_SYSTEM_PROMPT,
|
||||
SIMILAR_QUESTION_SYSTEM_PROMPT,
|
||||
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
|
||||
} from "./prompt-templates"
|
||||
import { withAiTracking } from "./usage-tracker"
|
||||
import {
|
||||
GradingSuggestionSchema,
|
||||
LessonContentResultSchema,
|
||||
QuestionVariantResultSchema,
|
||||
SimilarQuestionListSchema,
|
||||
WeaknessAnalysisResultSchema,
|
||||
} from "../schema"
|
||||
import type {
|
||||
AiChatMessage,
|
||||
AiChatOptions,
|
||||
AiChatResult,
|
||||
AiService,
|
||||
GradingInput,
|
||||
GradingSuggestion,
|
||||
LessonContentInput,
|
||||
LessonContentResult,
|
||||
QuestionVariantInput,
|
||||
QuestionVariantResult,
|
||||
SimilarQuestionInput,
|
||||
SimilarQuestionResult,
|
||||
WeaknessAnalysisInput,
|
||||
WeaknessAnalysisResult,
|
||||
} from "../types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON 提取工具(从 AI 返回文本中提取 JSON)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const extractBalancedJsonSegment = (value: string): string | null => {
|
||||
const startBrace = value.indexOf("{")
|
||||
const startBracket = value.indexOf("[")
|
||||
const start =
|
||||
startBrace === -1
|
||||
? startBracket
|
||||
: startBracket === -1
|
||||
? startBrace
|
||||
: Math.min(startBrace, startBracket)
|
||||
if (start === -1) return null
|
||||
const opening = value[start]
|
||||
const closing = opening === "{" ? "}" : "]"
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
for (let i = start; i < value.length; i += 1) {
|
||||
const char = value[i]
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
} else if (char === "\\") {
|
||||
escaped = true
|
||||
} else if (char === '"') {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
if (char === opening) {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (char === closing) {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return value.slice(start, i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const tryParseJson = (value: string): unknown | null => {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const extractJson = (raw: string): unknown => {
|
||||
const trimmed = raw.trim()
|
||||
const candidates: string[] = []
|
||||
const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)]
|
||||
if (fencedMatches.length > 0) {
|
||||
candidates.push(...fencedMatches.map((match) => (match[1] ?? "").trim()))
|
||||
}
|
||||
candidates.push(trimmed)
|
||||
for (const candidate of candidates) {
|
||||
const direct = tryParseJson(candidate)
|
||||
if (direct !== null) return direct
|
||||
const segment = extractBalancedJsonSegment(candidate)
|
||||
if (!segment) continue
|
||||
const parsed = tryParseJson(segment)
|
||||
if (parsed !== null) return parsed
|
||||
}
|
||||
throw new Error("Invalid AI response: cannot parse JSON")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AiService 实现
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_MODEL = () => String(env.AI_MODEL ?? "gpt-4o-mini")
|
||||
|
||||
const buildChatMessages = (
|
||||
systemPrompt: string,
|
||||
userContent: string
|
||||
): AiChatMessage[] => [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userContent },
|
||||
]
|
||||
|
||||
const callAi = async (
|
||||
messages: AiChatMessage[],
|
||||
options?: AiChatOptions
|
||||
): Promise<{ content: string; model?: string; tokenUsage?: number }> => {
|
||||
const result = await createAiChatCompletion({
|
||||
messages,
|
||||
model: options?.model ?? DEFAULT_MODEL(),
|
||||
temperature: options?.temperature ?? 0.3,
|
||||
...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}),
|
||||
...(options?.providerId ? { providerId: options.providerId } : {}),
|
||||
})
|
||||
const tokenUsage =
|
||||
result.usage && typeof result.usage === "object" && "total_tokens" in result.usage
|
||||
? Number((result.usage as unknown as Record<string, unknown>).total_tokens ?? 0)
|
||||
: undefined
|
||||
return { content: result.content, tokenUsage }
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认 AI 服务实现
|
||||
*
|
||||
* 封装 shared/lib/ai 的底层 SDK 调用,提供业务语义化接口。
|
||||
* 所有业务模块通过此服务调用 AI,不直接 import shared/lib/ai。
|
||||
*/
|
||||
export class DefaultAiService implements AiService {
|
||||
constructor(private readonly userId: string) {}
|
||||
|
||||
async chat(
|
||||
messages: AiChatMessage[],
|
||||
options?: AiChatOptions
|
||||
): Promise<AiChatResult> {
|
||||
return withAiTracking(this.userId, "chat", options?.providerId, async () => {
|
||||
const { content, tokenUsage } = await callAi(messages, {
|
||||
...options,
|
||||
temperature: options?.temperature ?? 0.7,
|
||||
})
|
||||
return { result: { content, usage: null }, tokenUsage }
|
||||
})
|
||||
}
|
||||
|
||||
async suggestSimilarQuestions(
|
||||
input: SimilarQuestionInput
|
||||
): Promise<SimilarQuestionResult[]> {
|
||||
return withAiTracking(this.userId, "similar_question", undefined, async () => {
|
||||
const count = input.count ?? 3
|
||||
const userLines = [
|
||||
`Question Type: ${input.questionType}`,
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
input.knowledgePointIds?.length
|
||||
? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
|
||||
: "",
|
||||
`Generate ${count} similar questions.`,
|
||||
`Original Question:\n${input.questionText}`,
|
||||
].filter((line) => line.length > 0)
|
||||
const { content } = await callAi(
|
||||
buildChatMessages(SIMILAR_QUESTION_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||
{ temperature: 0.5, maxTokens: 3000 }
|
||||
)
|
||||
const parsed = extractJson(content)
|
||||
const list =
|
||||
parsed && typeof parsed === "object" && "questions" in parsed
|
||||
? (parsed as Record<string, unknown>).questions
|
||||
: parsed
|
||||
const validated = SimilarQuestionListSchema.safeParse(list)
|
||||
if (!validated.success) return { result: [] }
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
|
||||
async suggestGrading(input: GradingInput): Promise<GradingSuggestion> {
|
||||
return withAiTracking(this.userId, "grading_assist", undefined, async () => {
|
||||
const userLines = [
|
||||
`Question Type: ${input.questionType}`,
|
||||
`Max Score: ${input.maxScore}`,
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
`Question:\n${input.questionText}`,
|
||||
`Student Answer:\n${input.studentAnswer}`,
|
||||
input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
|
||||
].filter((line) => line.length > 0)
|
||||
const { content } = await callAi(
|
||||
buildChatMessages(GRADING_ASSIST_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||
{ temperature: 0.2, maxTokens: 1000 }
|
||||
)
|
||||
const parsed = extractJson(content)
|
||||
const validated = GradingSuggestionSchema.safeParse(parsed)
|
||||
if (!validated.success) {
|
||||
return {
|
||||
result: {
|
||||
suggestedScore: 0,
|
||||
confidence: 0,
|
||||
feedback: "AI grading unavailable",
|
||||
reasoning: "AI response format invalid",
|
||||
},
|
||||
}
|
||||
}
|
||||
const data = validated.data
|
||||
return {
|
||||
result: {
|
||||
suggestedScore: Math.min(Math.max(data.suggestedScore, 0), input.maxScore),
|
||||
confidence: data.confidence,
|
||||
feedback: data.feedback,
|
||||
reasoning: data.reasoning,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async generateLessonContent(
|
||||
input: LessonContentInput
|
||||
): Promise<LessonContentResult> {
|
||||
return withAiTracking(this.userId, "lesson_content", undefined, async () => {
|
||||
const userLines = [
|
||||
`Topic: ${input.topic}`,
|
||||
`Content Type: ${input.contentType}`,
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
input.grade ? `Grade: ${input.grade}` : "",
|
||||
input.additionalContext ? `Additional Context:\n${input.additionalContext}` : "",
|
||||
].filter((line) => line.length > 0)
|
||||
const { content } = await callAi(
|
||||
buildChatMessages(LESSON_CONTENT_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||
{ temperature: 0.7, maxTokens: 4000 }
|
||||
)
|
||||
const parsed = extractJson(content)
|
||||
const validated = LessonContentResultSchema.safeParse(parsed)
|
||||
if (!validated.success) {
|
||||
return {
|
||||
result: {
|
||||
title: input.topic,
|
||||
content: content,
|
||||
},
|
||||
}
|
||||
}
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
|
||||
async generateQuestionVariant(
|
||||
input: QuestionVariantInput
|
||||
): Promise<QuestionVariantResult> {
|
||||
return withAiTracking(this.userId, "question_variant", undefined, async () => {
|
||||
const userLines = [
|
||||
`Variant Type: ${input.variantType}`,
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
`Original Question:\n${JSON.stringify(input.originalQuestion, null, 2)}`,
|
||||
].filter((line) => line.length > 0)
|
||||
const { content } = await callAi(
|
||||
buildChatMessages(QUESTION_VARIANT_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||
{ temperature: 0.6, maxTokens: 2000 }
|
||||
)
|
||||
const parsed = extractJson(content)
|
||||
const validated = QuestionVariantResultSchema.safeParse(parsed)
|
||||
if (!validated.success) {
|
||||
throw new Error("AI question variant format invalid")
|
||||
}
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
|
||||
async analyzeWeakness(
|
||||
input: WeaknessAnalysisInput
|
||||
): Promise<WeaknessAnalysisResult> {
|
||||
return withAiTracking(this.userId, "weakness_analysis", undefined, async () => {
|
||||
const userLines = [
|
||||
`Student ID: ${input.studentId}`,
|
||||
input.subjectId ? `Subject ID: ${input.subjectId}` : "",
|
||||
`Error Items (${input.errorItems.length}):`,
|
||||
JSON.stringify(
|
||||
input.errorItems.map((item) => ({
|
||||
questionText: item.questionText,
|
||||
questionType: item.questionType,
|
||||
errorCount: item.errorCount,
|
||||
masteryLevel: item.masteryLevel,
|
||||
})),
|
||||
null,
|
||||
2
|
||||
),
|
||||
].filter((line) => line.length > 0)
|
||||
const { content } = await callAi(
|
||||
buildChatMessages(WEAKNESS_ANALYSIS_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||
{ temperature: 0.3, maxTokens: 2000 }
|
||||
)
|
||||
const parsed = extractJson(content)
|
||||
const validated = WeaknessAnalysisResultSchema.safeParse(parsed)
|
||||
if (!validated.success) {
|
||||
return {
|
||||
result: {
|
||||
weakAreas: [],
|
||||
studyPlan: "Analysis unavailable",
|
||||
recommendedResources: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 AI 服务实例
|
||||
*
|
||||
* 在 Server Action 中调用,传入当前用户 ID。
|
||||
* 测试时可替换为 mock 实现。
|
||||
*/
|
||||
export const createAiService = (userId: string): AiService =>
|
||||
new DefaultAiService(userId)
|
||||
|
||||
/**
|
||||
* 安全执行 AI 调用,捕获异常并返回错误消息
|
||||
*/
|
||||
export const safeAiCall = async <T>(
|
||||
fn: () => Promise<T>
|
||||
): Promise<{ ok: true; data: T } | { ok: false; message: string }> => {
|
||||
try {
|
||||
const data = await fn()
|
||||
return { ok: true, data }
|
||||
} catch (error) {
|
||||
return { ok: false, message: getAiErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
154
src/modules/ai/services/prompt-templates.ts
Normal file
154
src/modules/ai/services/prompt-templates.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* AI Prompt 模板
|
||||
*
|
||||
* 集中管理所有业务场景的 Prompt,便于版本管理与调优。
|
||||
* 所有 Prompt 使用英文以获得最佳模型兼容性,业务文本通过 user message 注入。
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 相似题推荐
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SIMILAR_QUESTION_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 education question generator.",
|
||||
"Given a question, generate similar practice questions that test the same knowledge points.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "questions": [',
|
||||
" {",
|
||||
' "text": "question text",',
|
||||
' "type": "single_choice | multiple_choice | judgment | text",',
|
||||
' "difficulty": 3,',
|
||||
' "options": [{ "id": "A", "text": "option text" }],',
|
||||
' "answer": "correct answer",',
|
||||
' "explanation": "brief explanation"',
|
||||
" }",
|
||||
" ]",
|
||||
"}",
|
||||
"Rules:",
|
||||
"- Generate 1-5 similar questions based on the count parameter.",
|
||||
"- Keep the same knowledge points but vary the context and numbers.",
|
||||
"- For choice questions, always include 4 options.",
|
||||
"- For text questions, omit options and include the answer.",
|
||||
"- Difficulty should be 1-5, matching the original.",
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 辅助批改
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GRADING_ASSIST_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 teacher assistant for grading subjective questions.",
|
||||
"Given a question, the student's answer, and the correct answer (if available),",
|
||||
"evaluate the student's answer and suggest a score with feedback.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "suggestedScore": 4,',
|
||||
' "confidence": 0.85,',
|
||||
' "feedback": "constructive feedback in the student\'s language",',
|
||||
' "reasoning": "why this score was assigned"',
|
||||
"}",
|
||||
"Rules:",
|
||||
"- suggestedScore must be between 0 and maxScore.",
|
||||
"- confidence is between 0 and 1 (higher means more certain).",
|
||||
"- feedback should be encouraging and specific.",
|
||||
"- If the answer is completely wrong, suggestedScore should be 0.",
|
||||
"- If the answer is partially correct, give partial credit.",
|
||||
"- Consider alternative correct answers if the question allows.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 备课内容生成
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LESSON_CONTENT_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 instructional designer.",
|
||||
"Generate teaching content based on the given topic and context.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "title": "content title",',
|
||||
' "content": "detailed content in markdown format",',
|
||||
' "metadata": { "duration": "15 min", "materials": ["..."] }',
|
||||
"}",
|
||||
"Rules:",
|
||||
"- Content should be age-appropriate for the specified grade.",
|
||||
"- For 'activity' type: generate an interactive classroom activity.",
|
||||
"- For 'assessment' type: generate a formative assessment.",
|
||||
"- For 'question' type: generate discussion questions.",
|
||||
"- For 'material' type: generate teaching material outline.",
|
||||
"- Content should align with the subject curriculum.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 题目变体生成
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const QUESTION_VARIANT_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 question variation generator.",
|
||||
"Given an original question, generate a variant based on the specified type.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "text": "variant question text",',
|
||||
' "type": "single_choice | multiple_choice | judgment | text",',
|
||||
' "difficulty": 3,',
|
||||
' "options": [{ "id": "A", "text": "option", "isCorrect": true }],',
|
||||
' "answer": "correct answer",',
|
||||
' "explanation": "brief explanation"',
|
||||
"}",
|
||||
"Variant types:",
|
||||
"- same_knowledge_point: test the same concept with different context.",
|
||||
"- different_difficulty: make it easier or harder.",
|
||||
"- different_format: change the question type (e.g., choice to text).",
|
||||
"Rules:",
|
||||
"- For choice questions, always include 4 options with exactly one correct.",
|
||||
"- Difficulty must be 1-5.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 薄弱点分析
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const WEAKNESS_ANALYSIS_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 learning analyst.",
|
||||
"Analyze the student's error patterns and identify weak areas.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "weakAreas": [',
|
||||
" {",
|
||||
' "area": "knowledge area name",',
|
||||
' "severity": "high | medium | low",',
|
||||
' "suggestion": "specific improvement suggestion"',
|
||||
" }",
|
||||
" ],",
|
||||
' "studyPlan": "personalized study plan summary",',
|
||||
' "recommendedResources": ["resource 1", "resource 2"]',
|
||||
"}",
|
||||
"Rules:",
|
||||
"- Identify 2-5 weak areas based on error frequency and mastery level.",
|
||||
"- severity: high = mastery < 2, medium = mastery 2-3, low = mastery 3-4.",
|
||||
"- Suggestions should be actionable and specific.",
|
||||
"- Study plan should be concise (3-5 sentences).",
|
||||
"- Recommended resources can be topic names or study strategies.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 通用 JSON 提取提示词(用于修复 AI 返回的无效 JSON)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const JSON_REPAIR_SYSTEM_PROMPT = [
|
||||
"You are a JSON repair engine.",
|
||||
"Fix the provided invalid JSON into valid JSON only.",
|
||||
"Keep the original structure and values as much as possible.",
|
||||
"Do not use placeholders such as ... or [...].",
|
||||
"Return JSON only without markdown.",
|
||||
].join("\n")
|
||||
83
src/modules/ai/services/usage-tracker.ts
Normal file
83
src/modules/ai/services/usage-tracker.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import "server-only"
|
||||
|
||||
import { trackEvent, type EventName } from "@/shared/lib/track-event"
|
||||
|
||||
export type AiUsageEvent = {
|
||||
userId: string
|
||||
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis"
|
||||
providerId?: string
|
||||
model?: string
|
||||
success: boolean
|
||||
durationMs: number
|
||||
tokenUsage?: number
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
|
||||
chat: "ai.chat",
|
||||
similar_question: "ai.similar_question",
|
||||
grading_assist: "ai.grading_assist",
|
||||
lesson_content: "ai.lesson_content",
|
||||
question_variant: "ai.question_variant",
|
||||
weakness_analysis: "ai.weakness_analysis",
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 使用埋点
|
||||
*
|
||||
* 记录每次 AI 调用的元数据,用于监控、成本分析与异常排查。
|
||||
* 非阻塞,失败不影响主流程。
|
||||
*/
|
||||
export const trackAiUsage = (event: AiUsageEvent): void => {
|
||||
const eventName = AI_EVENT_MAP[event.capability]
|
||||
void trackEvent({
|
||||
event: eventName,
|
||||
userId: event.userId,
|
||||
targetType: event.capability,
|
||||
properties: {
|
||||
providerId: event.providerId,
|
||||
model: event.model,
|
||||
success: event.success,
|
||||
durationMs: event.durationMs,
|
||||
tokenUsage: event.tokenUsage,
|
||||
errorMessage: event.errorMessage,
|
||||
},
|
||||
}).catch(() => {
|
||||
// 静默失败:埋点不应影响业务流程
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 测量 AI 调用耗时并自动埋点
|
||||
*/
|
||||
export const withAiTracking = async <T>(
|
||||
userId: string,
|
||||
capability: AiUsageEvent["capability"],
|
||||
providerId: string | undefined,
|
||||
fn: () => Promise<{ result: T; model?: string; tokenUsage?: number }>
|
||||
): Promise<T> => {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const { result, model, tokenUsage } = await fn()
|
||||
trackAiUsage({
|
||||
userId,
|
||||
capability,
|
||||
providerId,
|
||||
model,
|
||||
success: true,
|
||||
durationMs: Date.now() - start,
|
||||
tokenUsage,
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
trackAiUsage({
|
||||
userId,
|
||||
capability,
|
||||
providerId,
|
||||
success: false,
|
||||
durationMs: Date.now() - start,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
194
src/modules/ai/types.ts
Normal file
194
src/modules/ai/types.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 基础类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AiChatRole = "system" | "user" | "assistant"
|
||||
|
||||
export type AiChatMessage = {
|
||||
role: AiChatRole
|
||||
content: string
|
||||
}
|
||||
|
||||
export type AiChatOptions = {
|
||||
providerId?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
model?: string
|
||||
}
|
||||
|
||||
export type AiChatResult = {
|
||||
content: string
|
||||
usage: unknown
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 业务场景类型
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 相似题推荐输入 */
|
||||
export type SimilarQuestionInput = {
|
||||
questionText: string
|
||||
questionType: string
|
||||
subject?: string
|
||||
knowledgePointIds?: string[]
|
||||
count?: number
|
||||
}
|
||||
|
||||
/** 相似题推荐结果 */
|
||||
export type SimilarQuestionResult = {
|
||||
text: string
|
||||
type: string
|
||||
difficulty?: number
|
||||
options?: Array<{ id: string; text: string }>
|
||||
answer?: string
|
||||
explanation?: string
|
||||
}
|
||||
|
||||
/** AI 辅助批改输入 */
|
||||
export type GradingInput = {
|
||||
questionText: string
|
||||
questionType: string
|
||||
studentAnswer: string
|
||||
correctAnswer?: string
|
||||
maxScore: number
|
||||
subject?: string
|
||||
}
|
||||
|
||||
/** AI 辅助批改建议 */
|
||||
export type GradingSuggestion = {
|
||||
suggestedScore: number
|
||||
confidence: number
|
||||
feedback: string
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
/** 备课内容生成输入 */
|
||||
export type LessonContentInput = {
|
||||
topic: string
|
||||
subject?: string
|
||||
grade?: string
|
||||
textbookId?: string
|
||||
chapterId?: string
|
||||
contentType: "activity" | "assessment" | "question" | "material"
|
||||
additionalContext?: string
|
||||
}
|
||||
|
||||
/** 备课内容生成结果 */
|
||||
export type LessonContentResult = {
|
||||
title: string
|
||||
content: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 题目变体生成输入 */
|
||||
export type QuestionVariantInput = {
|
||||
originalQuestion: {
|
||||
text: string
|
||||
type: string
|
||||
difficulty?: number
|
||||
options?: Array<{ id: string; text: string; isCorrect?: boolean }>
|
||||
answer?: string
|
||||
}
|
||||
subject?: string
|
||||
variantType: "same_knowledge_point" | "different_difficulty" | "different_format"
|
||||
}
|
||||
|
||||
/** 题目变体生成结果 */
|
||||
export type QuestionVariantResult = {
|
||||
text: string
|
||||
type: string
|
||||
difficulty: number
|
||||
options?: Array<{ id: string; text: string; isCorrect: boolean }>
|
||||
answer?: string
|
||||
explanation?: string
|
||||
}
|
||||
|
||||
/** 薄弱点分析输入 */
|
||||
export type WeaknessAnalysisInput = {
|
||||
studentId: string
|
||||
subjectId?: string
|
||||
errorItems: Array<{
|
||||
questionText: string
|
||||
questionType: string
|
||||
knowledgePointIds?: string[]
|
||||
errorCount: number
|
||||
masteryLevel: number
|
||||
}>
|
||||
}
|
||||
|
||||
/** 薄弱点分析结果 */
|
||||
export type WeaknessAnalysisResult = {
|
||||
weakAreas: Array<{
|
||||
area: string
|
||||
severity: "high" | "medium" | "low"
|
||||
suggestion: string
|
||||
}>
|
||||
studyPlan: string
|
||||
recommendedResources: string[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 能力配置(角色驱动)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AiCapability =
|
||||
| "chat"
|
||||
| "exam-generate"
|
||||
| "grading-assist"
|
||||
| "lesson-content"
|
||||
| "question-variant"
|
||||
| "similar-question"
|
||||
| "weakness-analysis"
|
||||
| "study-path"
|
||||
| "child-summary"
|
||||
| "usage-stats"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 服务接口
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* AI 服务接口(服务端)
|
||||
*
|
||||
* 业务模块的 data-access 或 actions 通过此接口调用 AI 能力,
|
||||
* 不直接 import shared/lib/ai。
|
||||
* 测试时可注入 mock 实现。
|
||||
*/
|
||||
export interface AiService {
|
||||
chat(messages: AiChatMessage[], options?: AiChatOptions): Promise<AiChatResult>
|
||||
suggestSimilarQuestions(input: SimilarQuestionInput): Promise<SimilarQuestionResult[]>
|
||||
suggestGrading(input: GradingInput): Promise<GradingSuggestion>
|
||||
generateLessonContent(input: LessonContentInput): Promise<LessonContentResult>
|
||||
generateQuestionVariant(input: QuestionVariantInput): Promise<QuestionVariantResult>
|
||||
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 客户端服务接口
|
||||
*
|
||||
* 注入 Server Action 引用,客户端组件通过此接口触发 AI 操作。
|
||||
* 遵循 settings 模块的依赖注入模式。
|
||||
*/
|
||||
export interface AiClientService {
|
||||
chat: (input: {
|
||||
messages: AiChatMessage[]
|
||||
providerId?: string
|
||||
}) => Promise<ActionState<AiChatResult>>
|
||||
suggestSimilarQuestions: (
|
||||
input: SimilarQuestionInput
|
||||
) => Promise<ActionState<SimilarQuestionResult[]>>
|
||||
suggestGrading: (input: GradingInput) => Promise<ActionState<GradingSuggestion>>
|
||||
generateLessonContent: (
|
||||
input: LessonContentInput
|
||||
) => Promise<ActionState<LessonContentResult>>
|
||||
generateQuestionVariant: (
|
||||
input: QuestionVariantInput
|
||||
) => Promise<ActionState<QuestionVariantResult>>
|
||||
analyzeWeakness: (
|
||||
input: WeaknessAnalysisInput
|
||||
) => Promise<ActionState<WeaknessAnalysisResult>>
|
||||
/** 预留埋点接口 */
|
||||
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
|
||||
}
|
||||
353
src/modules/error-book/components/error-book-detail-dialog.tsx
Normal file
353
src/modules/error-book/components/error-book-detail-dialog.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { Archive, Trash2, FileText, Calendar, History } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { formatDate, formatDateTime } from "@/shared/lib/utils"
|
||||
import {
|
||||
archiveErrorBookItemAction,
|
||||
deleteErrorBookItemAction,
|
||||
updateErrorBookNoteAction,
|
||||
} from "../actions"
|
||||
import {
|
||||
ERROR_BOOK_SOURCE_LABEL,
|
||||
ERROR_BOOK_SOURCE_VARIANT,
|
||||
ERROR_BOOK_STATUS_LABEL,
|
||||
ERROR_BOOK_STATUS_VARIANT,
|
||||
REVIEW_RESULT_LABEL,
|
||||
REVIEW_RESULT_VARIANT,
|
||||
COMMON_ERROR_TAGS,
|
||||
type ErrorBookItemDetail,
|
||||
type ErrorBookItem,
|
||||
} from "../types"
|
||||
import { ReviewButtons } from "./review-buttons"
|
||||
import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis"
|
||||
|
||||
interface ErrorBookDetailDialogProps {
|
||||
item: ErrorBookItemDetail | (Omit<ErrorBookItemDetail, "reviews"> & { reviews?: ErrorBookItemDetail["reviews"] })
|
||||
trigger: React.ReactNode
|
||||
/** 当前学生 ID(用于 AI 薄弱点分析) */
|
||||
studentId?: string
|
||||
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析) */
|
||||
errorItems?: ErrorBookItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取纯文本(用于 AI 相似题推荐)
|
||||
*
|
||||
* 类型收窄:从 unknown 逐步缩小到具体类型,避免使用 as 断言。
|
||||
*/
|
||||
function extractQuestionText(content: unknown): string {
|
||||
if (!content) return ""
|
||||
if (typeof content === "string") return content
|
||||
if (typeof content === "object" && content !== null && "text" in content) {
|
||||
const textValue = (content as Record<string, unknown>).text
|
||||
if (typeof textValue === "string") return textValue
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(content)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将错题条目转换为 AI 薄弱点分析所需的输入格式
|
||||
*/
|
||||
function mapErrorItemsForAnalysis(items: ErrorBookItem[]): Array<{
|
||||
questionText: string
|
||||
questionType: string
|
||||
knowledgePointIds?: string[]
|
||||
errorCount: number
|
||||
masteryLevel: number
|
||||
}> {
|
||||
return items.map((it) => ({
|
||||
questionText: extractQuestionText(it.question?.content),
|
||||
questionType: it.question?.type ?? "unknown",
|
||||
knowledgePointIds: it.knowledgePointIds ?? undefined,
|
||||
errorCount: it.reviewCount > 0 ? it.reviewCount : 1,
|
||||
masteryLevel: it.masteryLevel,
|
||||
}))
|
||||
}
|
||||
|
||||
export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }: ErrorBookDetailDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [note, setNote] = useState(item.note ?? "")
|
||||
const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? [])
|
||||
|
||||
function handleSaveNote() {
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append(
|
||||
"json",
|
||||
JSON.stringify({ itemId: item.id, note, errorTags })
|
||||
)
|
||||
const res = await updateErrorBookNoteAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success("笔记已保存")
|
||||
} else {
|
||||
toast.error(res.message ?? "保存失败")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleArchive() {
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append("itemId", item.id)
|
||||
const res = await archiveErrorBookItemAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? "已归档")
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(res.message ?? "归档失败")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append("itemId", item.id)
|
||||
const res = await deleteErrorBookItemAction(undefined, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? "已删除")
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(res.message ?? "删除失败")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
setErrorTags((prev) =>
|
||||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||
)
|
||||
}
|
||||
|
||||
// AI 分析所需数据
|
||||
const currentQuestionText = extractQuestionText(item.question?.content)
|
||||
const currentQuestionType = item.question?.type
|
||||
const aiErrorItems = errorItems ? mapErrorItemsForAnalysis(errorItems) : []
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 flex-wrap">
|
||||
<StatusBadge
|
||||
status={item.status}
|
||||
variantMap={ERROR_BOOK_STATUS_VARIANT}
|
||||
labelMap={ERROR_BOOK_STATUS_LABEL}
|
||||
capitalize={false}
|
||||
/>
|
||||
<StatusBadge
|
||||
status={item.sourceType}
|
||||
variantMap={ERROR_BOOK_SOURCE_VARIANT}
|
||||
labelMap={ERROR_BOOK_SOURCE_LABEL}
|
||||
capitalize={false}
|
||||
/>
|
||||
{item.subjectName ? (
|
||||
<Badge variant="outline">{item.subjectName}</Badge>
|
||||
) : null}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
添加于 {formatDate(item.createdAt)}
|
||||
</span>
|
||||
<span>掌握度: {item.masteryLevel}/5</span>
|
||||
<span>复习 {item.reviewCount} 次</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-6 px-6">
|
||||
<div className="space-y-4 pb-4">
|
||||
{/* 题目内容 */}
|
||||
<section>
|
||||
<h4 className="mb-2 text-sm font-medium">题目</h4>
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm">
|
||||
{item.question ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||
{typeof item.question.content === "string"
|
||||
? item.question.content
|
||||
: JSON.stringify(item.question.content, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<span className="text-muted-foreground">题目已删除</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 作答对比 */}
|
||||
{(item.studentAnswer !== null && item.studentAnswer !== undefined) || (item.correctAnswer !== null && item.correctAnswer !== undefined) ? (
|
||||
<section className="grid gap-3 sm:grid-cols-2">
|
||||
{item.studentAnswer !== null && item.studentAnswer !== undefined ? (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-rose-600 dark:text-rose-400">
|
||||
我的答案
|
||||
</h4>
|
||||
<div className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm dark:border-rose-900 dark:bg-rose-950/20">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||
{typeof item.studentAnswer === "string"
|
||||
? item.studentAnswer
|
||||
: JSON.stringify(item.studentAnswer, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{item.correctAnswer !== null && item.correctAnswer !== undefined ? (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
正确答案
|
||||
</h4>
|
||||
<div className="rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||
{typeof item.correctAnswer === "string"
|
||||
? item.correctAnswer
|
||||
: JSON.stringify(item.correctAnswer, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* AI 分析区(相似题推荐 + 薄弱点分析) */}
|
||||
{studentId && currentQuestionText ? (
|
||||
<section>
|
||||
<h4 className="mb-2 text-sm font-medium">AI 智能分析</h4>
|
||||
<AiErrorBookAnalysis
|
||||
studentId={studentId}
|
||||
subjectId={item.subjectId ?? undefined}
|
||||
currentQuestionText={currentQuestionText}
|
||||
currentQuestionType={currentQuestionType}
|
||||
errorItems={aiErrorItems}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* 复习区 */}
|
||||
{item.status !== "mastered" && item.status !== "archived" ? (
|
||||
<section>
|
||||
<h4 className="mb-2 text-sm font-medium">复习自评</h4>
|
||||
<ReviewButtons
|
||||
itemId={item.id}
|
||||
onReviewed={() => setOpen(false)}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* 笔记编辑 */}
|
||||
<section>
|
||||
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
|
||||
<FileText className="h-4 w-4" />
|
||||
学习笔记
|
||||
</h4>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="记录你的反思、解题思路、易错点..."
|
||||
className="w-full min-h-[80px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<p className="mb-1 text-xs text-muted-foreground">错误原因标签</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{COMMON_ERROR_TAGS.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={errorTags.includes(tag) ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
disabled={isPending}
|
||||
onClick={handleSaveNote}
|
||||
>
|
||||
保存笔记
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{/* 复习历史 */}
|
||||
{item.reviews && item.reviews.length > 0 ? (
|
||||
<section>
|
||||
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
|
||||
<History className="h-4 w-4" />
|
||||
复习历史
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{item.reviews.slice(0, 10).map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-center justify-between rounded-md border px-3 py-1.5 text-xs"
|
||||
>
|
||||
<StatusBadge
|
||||
status={r.result}
|
||||
variantMap={REVIEW_RESULT_VARIANT}
|
||||
labelMap={REVIEW_RESULT_LABEL}
|
||||
capitalize={false}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDateTime(r.reviewedAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={handleArchive}
|
||||
>
|
||||
<Archive className="h-4 w-4" data-icon="inline-start" />
|
||||
归档
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={handleDelete}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" data-icon="inline-start" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
47
src/modules/error-book/components/error-book-list.tsx
Normal file
47
src/modules/error-book/components/error-book-list.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { BookX } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ErrorBookItemCard } from "./error-book-item-card"
|
||||
import { ErrorBookDetailDialog } from "./error-book-detail-dialog"
|
||||
import type { ErrorBookItem } from "../types"
|
||||
|
||||
interface ErrorBookListProps {
|
||||
items: ErrorBookItem[]
|
||||
/** 当前学生 ID(用于 AI 薄弱点分析) */
|
||||
studentId?: string
|
||||
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析) */
|
||||
errorItems?: ErrorBookItem[]
|
||||
}
|
||||
|
||||
export function ErrorBookList({ items, studentId, errorItems }: ErrorBookListProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={BookX}
|
||||
title="错题本为空"
|
||||
description="完成考试或作业后,错题会自动收录到这里。你也可以手动添加错题。"
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<ErrorBookItemCard key={item.id} item={item}>
|
||||
<ErrorBookDetailDialog
|
||||
item={item}
|
||||
studentId={studentId}
|
||||
errorItems={errorItems}
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
查看详情
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</ErrorBookItemCard>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -149,11 +149,11 @@ export const requestAiExamStructureDraft = async (input: {
|
||||
export const validateExamSourceText = async (input: { sourceText: string; aiProviderId?: string }) => {
|
||||
const text = input.sourceText.trim()
|
||||
if (!text) {
|
||||
return { ok: false as const, message: "请先粘贴试卷文本" }
|
||||
return { ok: false as const, message: "Source text is required" }
|
||||
}
|
||||
const userContent = [
|
||||
"请判断下面文本是否是可读、正常的题目/试卷内容(不是乱码、随机字符或混乱文本)。",
|
||||
`文本内容:\n${text}`,
|
||||
"Judge whether the following text is readable and resembles normal exam/question content (not garbled, random characters, or disordered text).",
|
||||
`Text content:\n${text}`,
|
||||
].join("\n\n")
|
||||
try {
|
||||
const aiResult = await createAiChatCompletion({
|
||||
@@ -169,12 +169,12 @@ export const validateExamSourceText = async (input: { sourceText: string; aiProv
|
||||
const parsed = await parseAiResponse(aiResult.content, input.aiProviderId)
|
||||
const validated = AiSourceValidationSchema.safeParse(parsed)
|
||||
if (!validated.success) {
|
||||
return { ok: false as const, message: "试卷文本校验失败,请重试" }
|
||||
return { ok: false as const, message: "Source text validation failed, please retry" }
|
||||
}
|
||||
if (!validated.data.valid) {
|
||||
return {
|
||||
ok: false as const,
|
||||
message: validated.data.reason?.trim() || "识别为乱码或混乱文本,请粘贴清晰完整的题目内容",
|
||||
message: validated.data.reason?.trim() || "Text appears garbled or disordered, please paste clear and complete question content",
|
||||
}
|
||||
}
|
||||
return { ok: true as const }
|
||||
@@ -245,7 +245,8 @@ export const parseQuestionDetail = async (input: {
|
||||
content: q.content,
|
||||
} satisfies z.infer<typeof AiQuestionSchema>
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.warn("[parseQuestionDetail] Falling back to text question:", error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { Plus } from "lucide-react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Plus, Sparkles } from "lucide-react"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import { AiQuestionVariantGenerator } from "@/modules/ai/components/ai-question-variant-generator"
|
||||
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider"
|
||||
|
||||
type QuestionBankListProps = {
|
||||
questions: Question[]
|
||||
@@ -13,9 +23,32 @@ type QuestionBankListProps = {
|
||||
onLoadMore?: () => void
|
||||
hasMore?: boolean
|
||||
isLoading?: boolean
|
||||
/** 学科(用于 AI 题目变体生成) */
|
||||
subject?: string
|
||||
}
|
||||
|
||||
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading }: QuestionBankListProps) {
|
||||
/**
|
||||
* 从题目内容中提取纯文本(用于 AI 变体生成)
|
||||
*
|
||||
* 类型收窄:从 unknown 逐步缩小到具体类型,避免使用 as 断言。
|
||||
*/
|
||||
function extractQuestionText(content: unknown): string {
|
||||
if (!content) return ""
|
||||
if (typeof content === "string") return content
|
||||
if (typeof content === "object" && content !== null && "text" in content) {
|
||||
const textValue = (content as Record<string, unknown>).text
|
||||
if (typeof textValue === "string") return textValue
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(content)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMore, isLoading, subject }: QuestionBankListProps) {
|
||||
const aiClient = useAiClientOptional()
|
||||
|
||||
if (questions.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
@@ -42,6 +75,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
|
||||
return { text: "" }
|
||||
})()
|
||||
const typeLabel = typeof q.type === "string" ? q.type.replace("_", " ") : "unknown"
|
||||
const questionText = extractQuestionText(q.content)
|
||||
return (
|
||||
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex-1 space-y-2">
|
||||
@@ -62,7 +96,16 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
|
||||
{parsedContent.text || "No content preview"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* AI 题目变体生成(仅在 AiClientProvider 注入时显示) */}
|
||||
{aiClient && questionText ? (
|
||||
<AiVariantDialog
|
||||
questionText={questionText}
|
||||
questionType={q.type}
|
||||
difficulty={q.difficulty}
|
||||
subject={subject}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={added ? "secondary" : "default"}
|
||||
@@ -101,3 +144,53 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 题目变体生成对话框
|
||||
*
|
||||
* 独立组件,仅在用户点击时挂载,避免不必要的渲染。
|
||||
*/
|
||||
function AiVariantDialog({
|
||||
questionText,
|
||||
questionType,
|
||||
difficulty,
|
||||
subject,
|
||||
}: {
|
||||
questionText: string
|
||||
questionType: string
|
||||
difficulty: number
|
||||
subject?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
title="AI variant"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
AI Variant
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AiQuestionVariantGenerator
|
||||
originalQuestion={{
|
||||
text: questionText,
|
||||
type: questionType,
|
||||
difficulty,
|
||||
}}
|
||||
subject={subject}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import type { Control, UseFormReturn } from "react-hook-form"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Settings } from "lucide-react"
|
||||
import {
|
||||
FormField,
|
||||
@@ -79,14 +80,20 @@ export function ExamAiGenerator({
|
||||
runningPreviewTaskCount,
|
||||
queuedPreviewTaskCount,
|
||||
}: ExamAiGeneratorProps) {
|
||||
const formatTaskTime = (value: number) => formatDateTime(new Date(value))
|
||||
const t = useTranslations("ai")
|
||||
const formatTaskTime = (value: number) => {
|
||||
if (!Number.isFinite(value)) return ""
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ""
|
||||
return formatDateTime(date)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI Generation</CardTitle>
|
||||
<CardTitle>{t("exam.generationTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
Paste the exam text and generate a structured preview.
|
||||
{t("exam.generationDesc")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
@@ -96,7 +103,7 @@ export function ExamAiGenerator({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormLabel>AI Provider</FormLabel>
|
||||
<FormLabel>{t("provider.label")}</FormLabel>
|
||||
<Dialog
|
||||
open={providerDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -109,14 +116,14 @@ export function ExamAiGenerator({
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
|
||||
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||
新建配置
|
||||
{t("provider.manage")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[960px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Provider Settings</DialogTitle>
|
||||
<DialogTitle>{t("provider.manageTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new provider or update existing configuration.
|
||||
{t("provider.manageDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AiProviderSettingsCard
|
||||
@@ -136,19 +143,19 @@ export function ExamAiGenerator({
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAiProviders}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingAiProviders ? "Loading providers..." : "Select provider"} />
|
||||
<SelectValue placeholder={loadingAiProviders ? t("provider.loading") : t("provider.placeholder")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{aiProviders.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{aiProviderLabels[item.provider] ?? item.provider} · {item.model}{item.isDefault ? " (Default)" : ""}
|
||||
{aiProviderLabels[item.provider] ?? item.provider} · {item.model}{item.isDefault ? ` (${t("provider.default")})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the AI configuration for this generation.
|
||||
{t("provider.description")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -156,10 +163,10 @@ export function ExamAiGenerator({
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleBackgroundPreview}>
|
||||
{`加入后台队列(运行 ${runningPreviewTaskCount}/3,排队 ${queuedPreviewTaskCount})`}
|
||||
{t("exam.queue")} ({t("exam.queueRunning")} {runningPreviewTaskCount}/3, {t("exam.queueQueued")} {queuedPreviewTaskCount})
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handlePreview} disabled={previewLoading || activePreviewTaskCount > 0}>
|
||||
{previewLoading ? "Generating..." : "立即预览"}
|
||||
{previewLoading ? t("exam.generating") : t("exam.preview")}
|
||||
</Button>
|
||||
</div>
|
||||
<FormField
|
||||
@@ -167,16 +174,16 @@ export function ExamAiGenerator({
|
||||
name="aiSourceText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Source Exam Text</FormLabel>
|
||||
<FormLabel>{t("exam.sourceText")}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Paste the full exam text to parse into questions."
|
||||
placeholder={t("exam.sourceTextPlaceholder")}
|
||||
className="min-h-[200px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
AI will extract questions and structure from this text.
|
||||
{t("exam.sourceTextDesc")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -184,7 +191,7 @@ export function ExamAiGenerator({
|
||||
/>
|
||||
{previewTasks.length > 0 ? (
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<div className="text-sm font-medium">后台生成记录</div>
|
||||
<div className="text-sm font-medium">{t("exam.backgroundTasks")}</div>
|
||||
<div className="space-y-2">
|
||||
{previewTasks.slice(0, 6).map((task) => (
|
||||
<div key={task.id} className="rounded-md border p-2">
|
||||
@@ -194,17 +201,17 @@ export function ExamAiGenerator({
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{task.status === "queued"
|
||||
? "排队中"
|
||||
? t("exam.taskStatus.queued")
|
||||
: task.status === "running"
|
||||
? "生成中"
|
||||
? t("exam.taskStatus.running")
|
||||
: task.status === "success"
|
||||
? "已完成"
|
||||
: `失败:${task.message || "生成失败"}`}
|
||||
? t("exam.taskStatus.success")
|
||||
: `${t("exam.taskStatus.failed")}:${task.message || ""}`}
|
||||
</div>
|
||||
{task.status === "success" && task.result ? (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => handleOpenPreviewTask(task.id)}>
|
||||
打开预览
|
||||
{t("exam.openPreview")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
|
||||
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import type { QuestionType } from "@/modules/questions/types"
|
||||
import { updateExamAction } from "@/modules/exams/actions"
|
||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||
import { StructureEditor } from "./assembly/structure-editor"
|
||||
@@ -19,6 +20,18 @@ import type { ExamNode } from "./assembly/selected-question-list"
|
||||
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
const QUESTION_TYPES: readonly QuestionType[] = [
|
||||
"single_choice",
|
||||
"multiple_choice",
|
||||
"text",
|
||||
"judgment",
|
||||
"composite",
|
||||
] as const
|
||||
|
||||
function isQuestionType(value: string): value is QuestionType {
|
||||
return (QUESTION_TYPES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
type ExamAssemblyProps = {
|
||||
examId: string
|
||||
title: string
|
||||
@@ -76,11 +89,11 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
startBankTransition(async () => {
|
||||
const nextPage = reset ? 1 : page + 1
|
||||
try {
|
||||
const difficultyNum = difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter, 10)
|
||||
const result = await getQuestionsAction({
|
||||
q: deferredSearch,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type: typeFilter === 'all' ? undefined : typeFilter as any,
|
||||
difficulty: difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter),
|
||||
type: typeFilter === 'all' ? undefined : (isQuestionType(typeFilter) ? typeFilter : undefined),
|
||||
difficulty: difficultyNum === undefined || Number.isNaN(difficultyNum) ? undefined : difficultyNum,
|
||||
page: nextPage,
|
||||
pageSize: 20
|
||||
})
|
||||
@@ -97,7 +110,8 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
setHasMore(questionsList.length === 20)
|
||||
setPage(nextPage)
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
|
||||
toast.error("Failed to load questions")
|
||||
}
|
||||
})
|
||||
@@ -127,7 +141,9 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
return calc(structure)
|
||||
}, [structure])
|
||||
|
||||
const progress = Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
|
||||
const progress = props.totalScore > 0
|
||||
? Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
|
||||
: 0
|
||||
|
||||
const addedQuestionIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
@@ -256,11 +272,16 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
||||
formData.set("structureJson", JSON.stringify(getCleanStructure()))
|
||||
|
||||
const result = await updateExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success("Exam draft saved")
|
||||
} else {
|
||||
toast.error(result.message || "Save failed")
|
||||
try {
|
||||
const result = await updateExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success("Exam draft saved")
|
||||
} else {
|
||||
toast.error(result.message || "Save failed")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
|
||||
toast.error("Save failed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,12 +291,17 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
formData.set("structureJson", JSON.stringify(getCleanStructure()))
|
||||
formData.set("status", "published")
|
||||
|
||||
const result = await updateExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success("Published exam")
|
||||
router.push("/teacher/exams/all")
|
||||
} else {
|
||||
toast.error(result.message || "Publish failed")
|
||||
try {
|
||||
const result = await updateExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success("Published exam")
|
||||
router.push("/teacher/exams/all")
|
||||
} else {
|
||||
toast.error(result.message || "Publish failed")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ExamAssembly]", error instanceof Error ? error.message : String(error))
|
||||
toast.error("Publish failed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +429,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
onLoadMore={() => fetchQuestions(false)}
|
||||
hasMore={hasMore}
|
||||
isLoading={isBankLoading}
|
||||
subject={props.subject}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/
|
||||
import { gradeHomeworkSubmissionAction } from "../actions"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { QuestionRenderer } from "./question-renderer"
|
||||
import { AiGradingAssist } from "@/modules/ai/components/ai-grading-assist"
|
||||
import {
|
||||
applyAutoGrades as applyAutoGradesUtil,
|
||||
extractAnswerValue,
|
||||
@@ -136,16 +137,21 @@ export function HomeworkGradingView({
|
||||
formData.set("submissionId", submissionId)
|
||||
formData.set("answersJson", JSON.stringify(payload))
|
||||
|
||||
const result = await gradeHomeworkSubmissionAction(null, formData)
|
||||
try {
|
||||
const result = await gradeHomeworkSubmissionAction(null, formData)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(t("homework.grade.gradesSaved"))
|
||||
// Optionally redirect or stay
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
|
||||
if (result.success) {
|
||||
toast.success(t("homework.grade.gradesSaved"))
|
||||
// Optionally redirect or stay
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("homework.grade.gradesSaveFailed"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const handleScrollToQuestion = (id: string) => {
|
||||
@@ -337,6 +343,22 @@ export function HomeworkGradingView({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Grading Assist (subjective questions only) */}
|
||||
{!isAutoGradable(ans) && (
|
||||
<AiGradingAssist
|
||||
questionText={extractQuestionText(ans.questionContent)}
|
||||
questionType={ans.questionType}
|
||||
studentAnswer={formatStudentAnswer(ans.studentAnswer)}
|
||||
correctAnswer={getTextCorrectAnswers(ans.questionContent).join(" / ")}
|
||||
maxScore={ans.maxScore}
|
||||
onApplyScore={(score) => handleManualScoreChange(ans.id, String(score))}
|
||||
onApplyFeedback={(feedback) => {
|
||||
handleFeedbackChange(ans.id, feedback)
|
||||
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: true }))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
@@ -520,3 +542,21 @@ const formatStudentAnswer = (studentAnswer: unknown): string => {
|
||||
if (v == null) return "—"
|
||||
return JSON.stringify(v)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取纯文本(用于 AI 批改输入)
|
||||
*
|
||||
* 优先使用 `text` 字段;若不存在则回退到 JSON 字符串,
|
||||
* 保证 AI 服务能拿到可读的题目描述。
|
||||
*/
|
||||
const extractQuestionText = (content: QuestionContent | null): string => {
|
||||
if (!content) return ""
|
||||
if (typeof content.text === "string" && content.text.trim().length > 0) {
|
||||
return content.text
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(content)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { getTranslations } from "next-intl/server";
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import { suggestKnowledgePoints } from "./ai-suggest";
|
||||
import type { ActionState, LessonPlanDocument } from "./types";
|
||||
import { suggestKnowledgePointsSchema } from "./schema";
|
||||
import type { ActionState } from "@/shared/types/action-state";
|
||||
|
||||
export async function suggestKnowledgePointsAction(input: {
|
||||
doc: LessonPlanDocument;
|
||||
doc: unknown;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
}): Promise<
|
||||
@@ -17,12 +18,24 @@ export async function suggestKnowledgePointsAction(input: {
|
||||
> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
try {
|
||||
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
await requirePermission(Permissions.AI_CHAT);
|
||||
const parsed = suggestKnowledgePointsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
// 并行校验两个权限点
|
||||
await Promise.all([
|
||||
requirePermission(Permissions.LESSON_PLAN_READ),
|
||||
requirePermission(Permissions.AI_CHAT),
|
||||
]);
|
||||
|
||||
// 从 unknown 安全提取 nodes 数组:Zod 已校验 doc 是对象
|
||||
const doc = parsed.data.doc;
|
||||
const nodes = Array.isArray(doc.nodes) ? doc.nodes : [];
|
||||
const suggestions = await suggestKnowledgePoints(
|
||||
input.doc,
|
||||
input.textbookId,
|
||||
input.chapterId,
|
||||
{ nodes },
|
||||
parsed.data.textbookId,
|
||||
parsed.data.chapterId,
|
||||
);
|
||||
return { success: true, data: { suggestions } };
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
import { env } from "@/env.mjs";
|
||||
import { createAiChatCompletion } from "@/shared/lib/ai";
|
||||
import {
|
||||
getKnowledgePointsByTextbookId,
|
||||
getKnowledgePointsByChapterId,
|
||||
} from "@/modules/textbooks/data-access";
|
||||
import type { LessonPlanDocument } from "./types";
|
||||
|
||||
const SuggestedKpSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
reason: z.string(),
|
||||
});
|
||||
|
||||
const SuggestedKpListSchema = z.array(SuggestedKpSchema);
|
||||
|
||||
/** 从 unknown 节点安全提取文本(类型守卫从 unknown 收窄) */
|
||||
const extractNodeText = (node: unknown): string => {
|
||||
if (!node || typeof node !== "object") return ""
|
||||
// 从 unknown 收窄为 Record<string, unknown> 以进行字段检查
|
||||
const record = node as Record<string, unknown>
|
||||
const data = record.data
|
||||
if (!data || typeof data !== "object") return ""
|
||||
// 从 unknown 收窄为 Record<string, unknown> 以进行字段检查
|
||||
const dataRecord = data as Record<string, unknown>
|
||||
const html = typeof dataRecord.html === "string" ? dataRecord.html : ""
|
||||
const sourceText = typeof dataRecord.sourceText === "string" ? dataRecord.sourceText : ""
|
||||
return html || sourceText || ""
|
||||
}
|
||||
|
||||
export async function suggestKnowledgePoints(
|
||||
doc: LessonPlanDocument,
|
||||
doc: { nodes: unknown[] },
|
||||
textbookId?: string,
|
||||
chapterId?: string,
|
||||
): Promise<{ id: string; name: string; reason: string }[]> {
|
||||
// 1. 提取课案纯文本
|
||||
const text = doc.nodes
|
||||
.map((b) => {
|
||||
const d = b.data as { html?: string; sourceText?: string };
|
||||
return d.html ?? d.sourceText ?? "";
|
||||
})
|
||||
.map((b) => extractNodeText(b))
|
||||
.join("\n")
|
||||
.slice(0, 3000);
|
||||
|
||||
@@ -51,14 +70,12 @@ ${text}
|
||||
// 尝试从返回内容中提取 JSON 数组
|
||||
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) return [];
|
||||
const parsed = JSON.parse(jsonMatch[0]) as {
|
||||
id: string;
|
||||
name: string;
|
||||
reason: string;
|
||||
}[];
|
||||
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||
const validated = SuggestedKpListSchema.safeParse(parsed);
|
||||
if (!validated.success) return [];
|
||||
// 过滤掉不在候选池中的 id
|
||||
const validIds = new Set(kpList.map((k) => k.id));
|
||||
return parsed.filter((p) => validIds.has(p.id));
|
||||
return validated.data.filter((p) => validIds.has(p.id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sparkles, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { BlockRenderer } from "../config/block-registry";
|
||||
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Trash2, X } from "lucide-react";
|
||||
import { AiLessonContentGenerator } from "@/modules/ai/components/ai-lesson-content-generator";
|
||||
import { useAiClientOptional } from "@/modules/ai/context/ai-client-provider";
|
||||
|
||||
interface Props {
|
||||
textbookId?: string;
|
||||
@@ -15,8 +19,11 @@ interface Props {
|
||||
|
||||
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const tAi = useTranslations("ai");
|
||||
const { doc, selectedNodeId, updateNode, removeNode, selectNode } =
|
||||
useLessonPlanEditor();
|
||||
const aiClient = useAiClientOptional();
|
||||
const [showAiPanel, setShowAiPanel] = useState(false);
|
||||
|
||||
const node = doc.nodes.find((n) => n.id === selectedNodeId);
|
||||
|
||||
@@ -28,15 +35,45 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// 正文节点不在侧边面板编辑(直接在画布上交互)
|
||||
if (node.type === "textbook_content") {
|
||||
return (
|
||||
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
|
||||
<span className="flex-1 font-title-md text-title-md">
|
||||
{t("editor.textbookContent")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => selectNode(null)}
|
||||
aria-label={t("action.close")}
|
||||
>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 text-sm text-on-surface-variant">
|
||||
{t("editor.textbookContentEmpty")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 教学节点:通过类型守卫收窄为 LessonPlanNode
|
||||
const lessonNode = node as import("../types").LessonPlanNode;
|
||||
|
||||
// 从节点标题提取主题用于 AI 内容生成
|
||||
const aiTopic = lessonNode.title || t("editor.textbookContent");
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
|
||||
{/* 面板头部 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
|
||||
<input
|
||||
value={node.title}
|
||||
onChange={(e) => updateNode(node.id, { title: e.target.value })}
|
||||
value={lessonNode.title}
|
||||
onChange={(e) => updateNode(lessonNode.id, { title: e.target.value })}
|
||||
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
|
||||
aria-label={node.title}
|
||||
aria-label={lessonNode.title}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -52,17 +89,49 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<LessonPlanErrorBoundary>
|
||||
<BlockRenderer
|
||||
type={node.type}
|
||||
blockId={node.id}
|
||||
data={node.data}
|
||||
type={lessonNode.type}
|
||||
blockId={lessonNode.id}
|
||||
data={lessonNode.data}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
classes={classes}
|
||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||
onUpdate={(d) => updateNode(lessonNode.id, { data: d })}
|
||||
/>
|
||||
{/* BlockRenderer 返回 null 时显示未知类型提示 */}
|
||||
<UnknownBlockHint type={node.type} t={t} />
|
||||
<UnknownBlockHint type={lessonNode.type} t={t} />
|
||||
</LessonPlanErrorBoundary>
|
||||
|
||||
{/* AI 内容生成区(可折叠) */}
|
||||
{aiClient ? (
|
||||
<div className="mt-4 border-t border-outline-variant pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between"
|
||||
onClick={() => setShowAiPanel(!showAiPanel)}
|
||||
aria-expanded={showAiPanel}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<Sparkles className="w-3.5 h-3.5 text-primary" />
|
||||
{tAi("lessonPrep.generateContent")}
|
||||
</span>
|
||||
{showAiPanel ? (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
{showAiPanel ? (
|
||||
<div className="mt-2">
|
||||
<AiLessonContentGenerator
|
||||
topic={aiTopic}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
@@ -71,7 +140,7 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-error"
|
||||
onClick={() => removeNode(node.id)}
|
||||
onClick={() => removeNode(lessonNode.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||
{t("action.delete")}
|
||||
|
||||
109
src/shared/i18n/messages/en/ai.json
Normal file
109
src/shared/i18n/messages/en/ai.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"chat": {
|
||||
"title": "AI Assistant",
|
||||
"placeholder": "Ask anything...",
|
||||
"inputLabel": "Message input",
|
||||
"send": "Send",
|
||||
"thinking": "AI is thinking...",
|
||||
"maxReached": "Maximum messages reached",
|
||||
"clear": "Clear conversation"
|
||||
},
|
||||
"provider": {
|
||||
"label": "AI Provider",
|
||||
"placeholder": "Select provider",
|
||||
"loading": "Loading providers...",
|
||||
"default": "Default",
|
||||
"description": "Select the AI configuration for this operation.",
|
||||
"manage": "Manage",
|
||||
"manageTitle": "AI Provider Settings",
|
||||
"manageDescription": "Create a new provider or update existing configuration."
|
||||
},
|
||||
"suggestion": {
|
||||
"title": "AI Suggestions",
|
||||
"generate": "Generate Suggestions",
|
||||
"regenerate": "Regenerate",
|
||||
"loading": "AI is thinking...",
|
||||
"empty": "No suggestions available",
|
||||
"error": "Failed to generate suggestions",
|
||||
"loaded": "Suggestions loaded",
|
||||
"selected": "Suggestion selected",
|
||||
"select": "Select",
|
||||
"difficulty": "Difficulty"
|
||||
},
|
||||
"grading": {
|
||||
"title": "AI Grading Suggestion",
|
||||
"suggestedScore": "Suggested Score",
|
||||
"confidence": "Confidence",
|
||||
"feedback": "Feedback",
|
||||
"reasoning": "Reasoning",
|
||||
"applyScore": "Apply Score",
|
||||
"applyFeedback": "Apply Feedback",
|
||||
"loading": "AI is grading...",
|
||||
"error": "AI grading failed",
|
||||
"notAvailable": "AI grading not available for this question type"
|
||||
},
|
||||
"errorBook": {
|
||||
"similarQuestions": "Similar Questions",
|
||||
"weaknessAnalysis": "Weakness Analysis",
|
||||
"studyPlan": "Study Plan",
|
||||
"recommendedResources": "Recommended Resources",
|
||||
"weakAreas": "Weak Areas",
|
||||
"severity": {
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"lessonPrep": {
|
||||
"generateContent": "Generate Content",
|
||||
"generateActivity": "Suggest Activity",
|
||||
"generateAssessment": "Generate Assessment",
|
||||
"generateQuestion": "Generate Discussion Question",
|
||||
"loading": "Generating...",
|
||||
"error": "Content generation failed"
|
||||
},
|
||||
"exam": {
|
||||
"generate": "Generate",
|
||||
"generating": "Generating...",
|
||||
"preview": "Preview",
|
||||
"queue": "Add to Queue",
|
||||
"queueRunning": "Running",
|
||||
"queueQueued": "Queued",
|
||||
"backgroundTasks": "Background Tasks",
|
||||
"taskStatus": {
|
||||
"queued": "Queued",
|
||||
"running": "Running",
|
||||
"success": "Completed",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"openPreview": "Open Preview",
|
||||
"sourceText": "Source Exam Text",
|
||||
"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."
|
||||
},
|
||||
"error": {
|
||||
"invalidInput": "Invalid input data",
|
||||
"chatFailed": "AI request failed",
|
||||
"suggestionFailed": "AI suggestion failed",
|
||||
"gradingFailed": "AI grading failed",
|
||||
"contentFailed": "Content generation failed",
|
||||
"variantFailed": "Question variant generation failed",
|
||||
"analysisFailed": "Weakness analysis failed",
|
||||
"boundaryTitle": "AI Feature Error",
|
||||
"boundaryDescription": "An error occurred while processing AI request. Please try again.",
|
||||
"retry": "Retry",
|
||||
"unauthorized": "You do not have permission to use AI features",
|
||||
"providerNotConfigured": "AI provider not configured. Please contact administrator."
|
||||
},
|
||||
"capability": {
|
||||
"chat": "AI Chat",
|
||||
"examGenerate": "AI Exam Generation",
|
||||
"gradingAssist": "AI Grading Assist",
|
||||
"lessonContent": "AI Lesson Content",
|
||||
"questionVariant": "AI Question Variant",
|
||||
"similarQuestion": "AI Similar Questions",
|
||||
"weaknessAnalysis": "AI Weakness Analysis"
|
||||
}
|
||||
}
|
||||
109
src/shared/i18n/messages/zh-CN/ai.json
Normal file
109
src/shared/i18n/messages/zh-CN/ai.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"chat": {
|
||||
"title": "AI 助手",
|
||||
"placeholder": "请输入您的问题...",
|
||||
"inputLabel": "消息输入",
|
||||
"send": "发送",
|
||||
"thinking": "AI 正在思考...",
|
||||
"maxReached": "已达到最大消息数",
|
||||
"clear": "清空对话"
|
||||
},
|
||||
"provider": {
|
||||
"label": "AI 服务商",
|
||||
"placeholder": "选择服务商",
|
||||
"loading": "加载服务商中...",
|
||||
"default": "默认",
|
||||
"description": "选择本次操作使用的 AI 配置。",
|
||||
"manage": "管理",
|
||||
"manageTitle": "AI 服务商设置",
|
||||
"manageDescription": "新建服务商或更新已有配置。"
|
||||
},
|
||||
"suggestion": {
|
||||
"title": "AI 建议",
|
||||
"generate": "生成建议",
|
||||
"regenerate": "重新生成",
|
||||
"loading": "AI 思考中...",
|
||||
"empty": "暂无建议",
|
||||
"error": "生成建议失败",
|
||||
"loaded": "建议已加载",
|
||||
"selected": "已选择建议",
|
||||
"select": "选择",
|
||||
"difficulty": "难度"
|
||||
},
|
||||
"grading": {
|
||||
"title": "AI 批改建议",
|
||||
"suggestedScore": "建议分数",
|
||||
"confidence": "置信度",
|
||||
"feedback": "反馈",
|
||||
"reasoning": "评分依据",
|
||||
"applyScore": "应用分数",
|
||||
"applyFeedback": "应用反馈",
|
||||
"loading": "AI 批改中...",
|
||||
"error": "AI 批改失败",
|
||||
"notAvailable": "此题型不支持 AI 批改"
|
||||
},
|
||||
"errorBook": {
|
||||
"similarQuestions": "相似题目",
|
||||
"weaknessAnalysis": "薄弱点分析",
|
||||
"studyPlan": "学习计划",
|
||||
"recommendedResources": "推荐资源",
|
||||
"weakAreas": "薄弱领域",
|
||||
"severity": {
|
||||
"high": "高",
|
||||
"medium": "中",
|
||||
"low": "低"
|
||||
}
|
||||
},
|
||||
"lessonPrep": {
|
||||
"generateContent": "生成内容",
|
||||
"generateActivity": "建议活动",
|
||||
"generateAssessment": "生成评估",
|
||||
"generateQuestion": "生成讨论题",
|
||||
"loading": "生成中...",
|
||||
"error": "内容生成失败"
|
||||
},
|
||||
"exam": {
|
||||
"generate": "生成",
|
||||
"generating": "生成中...",
|
||||
"preview": "预览",
|
||||
"queue": "加入队列",
|
||||
"queueRunning": "运行中",
|
||||
"queueQueued": "排队中",
|
||||
"backgroundTasks": "后台任务",
|
||||
"taskStatus": {
|
||||
"queued": "排队中",
|
||||
"running": "生成中",
|
||||
"success": "已完成",
|
||||
"failed": "失败"
|
||||
},
|
||||
"openPreview": "打开预览",
|
||||
"sourceText": "试卷原文",
|
||||
"sourceTextPlaceholder": "粘贴试卷文本以解析为题目",
|
||||
"sourceTextDesc": "AI 将从文本中提取题目和结构。",
|
||||
"generationTitle": "AI 生成",
|
||||
"generationDesc": "粘贴试卷文本并生成结构化预览。"
|
||||
},
|
||||
"error": {
|
||||
"invalidInput": "输入数据无效",
|
||||
"chatFailed": "AI 请求失败",
|
||||
"suggestionFailed": "AI 建议失败",
|
||||
"gradingFailed": "AI 批改失败",
|
||||
"contentFailed": "内容生成失败",
|
||||
"variantFailed": "题目变体生成失败",
|
||||
"analysisFailed": "薄弱点分析失败",
|
||||
"boundaryTitle": "AI 功能错误",
|
||||
"boundaryDescription": "处理 AI 请求时发生错误,请重试。",
|
||||
"retry": "重试",
|
||||
"unauthorized": "您没有使用 AI 功能的权限",
|
||||
"providerNotConfigured": "AI 服务商未配置,请联系管理员。"
|
||||
},
|
||||
"capability": {
|
||||
"chat": "AI 对话",
|
||||
"examGenerate": "AI 出题",
|
||||
"gradingAssist": "AI 辅助批改",
|
||||
"lessonContent": "AI 备课内容",
|
||||
"questionVariant": "AI 题目变体",
|
||||
"similarQuestion": "AI 相似题",
|
||||
"weaknessAnalysis": "AI 薄弱点分析"
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,13 @@ export type EventName =
|
||||
| "homework.submitted"
|
||||
| "homework.graded"
|
||||
| "homework.auto_save_failed"
|
||||
// AI 模块监控事件
|
||||
| "ai.chat"
|
||||
| "ai.similar_question"
|
||||
| "ai.grading_assist"
|
||||
| "ai.lesson_content"
|
||||
| "ai.question_variant"
|
||||
| "ai.weakness_analysis"
|
||||
|
||||
/** 埋点事件负载 */
|
||||
export interface TrackEventPayload {
|
||||
|
||||
Reference in New Issue
Block a user