diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 68fafbf..7b82d13 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -2056,33 +2056,41 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | **student** | `AiStudyPath` | `student/*` | 学习路径推荐 — V2 新增 | **依赖关系**: -- `modules/ai` → `shared/lib/ai`(AI SDK 封装,含流式 `createAiChatCompletionStream`) +- `modules/ai` → `shared/lib/ai`(AI SDK 封装,含流式 `createAiChatCompletionStream`,V3 增加 `withRetry` 重试机制) - `modules/ai` → `shared/lib/auth-guard`(权限校验) - `modules/ai` → `shared/lib/track-event`(使用量埋点) - `modules/ai` → `shared/types/permissions`(权限常量) - `modules/ai` → `shared/types/action-state`(返回值类型) +- `modules/ai` → `modules/textbooks/data-access-graph`(知识图谱+掌握度查询)— V3 新增 - `app/(dashboard)/layout` → `modules/ai`(全局 Provider + Widget)— V2 新增 +- `app/api/ai/chat/stream` → `modules/ai/services/content-safety`(SSE 端点安全过滤)— V3 加固 - 业务模块 → `modules/ai/context/ai-client-provider`(通过 Context 注入) - 业务模块 → `modules/ai/components/*`(组合 AI 组件) -**安全机制(V2 新增)**: +**安全机制(V2 新增 / V3 加固)**: - 输入过滤:`filterUserInput` 拦截暴力/自残/色情/PII - 输出过滤:`filterAiOutput` 扫描 AI 回复 - 每日限制:学生 50 次/天,教师 200 次/天,家长 30 次/天 - 学生 Socratic 模式:system prompt 强制不直接给答案 - SSE 端点权限校验:`requirePermission(AI_CHAT)` +- V3 原子配额:`tryConsumeDailyQuota` 解决 TOCTOU 竞态,`refundDailyQuota` 过滤/失败时回退 +- V3 苏格拉底校验:`validateSocraticOutput` 检查问号结尾+连续陈述句限制,`SOCRATIC_TUTOR_SYSTEM_PROMPT` 3 级提示升级 +- V3 SSE 端点加固:Zod 校验 + rate limit + 服务端强制 systemPrompt(忽略客户端) +- V3 重试机制:`withRetry` 指数退避(429/5xx,2 次重试,1s/2s/4s 延迟) +- V3 知识图谱集成:`recommendStudyPathAction` 自动从 textbooks 模块获取图谱+掌握度注入 prompt **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| -| `modules/ai/types.ts` | ~270 | 类型定义(8 个业务场景类型 + AiService/AiClientService) | -| `modules/ai/schema.ts` | ~205 | Zod 验证 schema(8 个输入 + 8 个输出) | -| `modules/ai/actions.ts` | ~340 | 9 个 Server Actions(含权限校验) | -| `modules/ai/services/ai-service.ts` | ~400 | DefaultAiService 实现(8 个方法) | -| `modules/ai/services/prompt-templates.ts` | ~210 | 8 个系统提示词模板 | -| `modules/ai/services/usage-tracker.ts` | ~83 | AI 使用量埋点 | -| `modules/ai/services/content-safety.ts` | ~130 | 内容安全过滤(输入/输出/每日限制)— V2 新增 | +| `modules/ai/types.ts` | ~290 | 类型定义(8 个业务场景类型 + AiService/AiClientService,V3 新增 knowledgeGraph/textbookId) | +| `modules/ai/schema.ts` | ~230 | Zod 验证 schema(8 个输入 + 8 个输出,V3 新增 knowledgeGraph/textbookId) | +| `modules/ai/actions.ts` | ~400 | 9 个 Server Actions(含权限校验,V3 新增知识图谱获取) | +| `modules/ai/data-access.ts` | ~138 | AI 事件存储+统计聚合 — V3 新增 | +| `modules/ai/services/ai-service.ts` | ~430 | DefaultAiService 实现(8 个方法,V3 新增知识图谱上下文注入) | +| `modules/ai/services/prompt-templates.ts` | ~250 | 9 个系统提示词模板(V3 新增 SOCRATIC_TUTOR_SYSTEM_PROMPT) | +| `modules/ai/services/usage-tracker.ts` | ~90 | AI 使用量埋点(V3 新增 child_summary/study_path 能力) | +| `modules/ai/services/content-safety.ts` | ~290 | 内容安全过滤(输入/输出/每日限制/原子配额/苏格拉底校验)— V2 新增 / V3 加固 | | `modules/ai/context/ai-client-provider.tsx` | ~62 | React Context Provider + Hooks | | `modules/ai/components/ai-assistant-widget.tsx` | ~170 | 全局 AI 助手悬浮按钮 — V2 新增 | | `modules/ai/components/ai-chat-panel.tsx` | ~305 | AI 对话面板(流式 + Markdown)— V2 增强 | @@ -2097,7 +2105,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `modules/ai/components/ai-error-boundary.tsx` | ~88 | AI 错误边界 | | `modules/ai/components/ai-skeleton.tsx` | ~47 | AI 骨架屏 | | `modules/ai/components/ai-provider-selector.tsx` | ~129 | 服务商选择器 | -| `modules/ai/hooks/use-ai-chat-stream.ts` | ~170 | 流式 AI 对话 Hook — V2 新增 | +| `modules/ai/hooks/use-ai-chat-stream.ts` | ~107 | 流式 AI 对话 Hook(V3 拆分至 ≤80 行函数体)— V2 新增 / V3 重构 | +| `modules/ai/hooks/stream-utils.ts` | ~120 | SSE 流解析工具(consumeSseStream/getStreamErrorKey 等)— V3 新增 | | `modules/ai/hooks/use-ai-chat.ts` | ~57 | 非流式 AI 对话 Hook | | `modules/ai/hooks/use-ai-suggestion.ts` | ~72 | AI 建议 Hook | | `app/api/ai/chat/stream/route.ts` | ~160 | SSE 流式端点 — V2 新增 | diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index e42ef01..489a8fb 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -16341,7 +16341,8 @@ }, "ai": { "dependsOn": [ - "shared" + "shared", + "textbooks" ], "uses": { "shared": [ @@ -16353,6 +16354,10 @@ "types.action-state", "lib.track-event.trackEvent", "i18n.messages" + ], + "textbooks": [ + "data-access-graph.getKnowledgePointsWithRelations", + "data-access-graph.getStudentKpMastery" ] }, "exports": { @@ -16422,11 +16427,27 @@ "filterAiOutput", "checkDailyLimit", "incrementDailyUsage", - "getDailyLimit" + "getDailyLimit", + "tryConsumeDailyQuota", + "refundDailyQuota", + "validateSocraticOutput" + ], + "dataAccess": [ + "recordAiEvent", + "getAiUsageStats" + ], + "streamUtils": [ + "consumeSseStream", + "getStreamErrorKey", + "extractErrorMessage", + "removeTrailingEmptyAssistant", + "appendTokenToLastAssistant" ], "promptTemplates": [ "CHILD_SUMMARY_SYSTEM_PROMPT", - "STUDY_PATH_SYSTEM_PROMPT" + "STUDY_PATH_SYSTEM_PROMPT", + "SOCRATIC_TUTOR_SYSTEM_PROMPT", + "CHAT_SYSTEM_PROMPT" ] }, "integrations": { @@ -16841,6 +16862,12 @@ "type": "normal", "description": "使用 lib/ai.createAiChatCompletion、auth-guard.requirePermission、types.permissions、types.action-state" }, + { + "from": "ai", + "to": "textbooks", + "type": "normal", + "description": "V3 新增:使用 data-access-graph.getKnowledgePointsWithRelations/getStudentKpMastery 获取知识图谱与掌握度,注入学习路径推荐" + }, { "from": "homework", "to": "ai", diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 4273b25..54680e4 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -34,7 +34,7 @@ export default function DashboardLayout({ children, }: { children: React.ReactNode -}) { +}): React.ReactNode { return ( }> diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts index 55d881a..1c0ada6 100644 --- a/src/app/api/ai/chat/route.ts +++ b/src/app/api/ai/chat/route.ts @@ -6,14 +6,14 @@ import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" -const getStatusFromError = (message: string) => { +const getStatusFromError = (message: string): number => { if (message === "Invalid payload" || message === "Messages are required") return 400 if (message === "AI API key missing") return 500 if (message === "Empty response") return 502 return 502 } -export async function POST(req: Request) { +export async function POST(req: Request): Promise { try { const ctx = await requirePermission(Permissions.AI_CHAT) const userId = ctx.userId diff --git a/src/app/api/ai/chat/stream/route.ts b/src/app/api/ai/chat/stream/route.ts index 2a46e6d..c63d9c4 100644 --- a/src/app/api/ai/chat/stream/route.ts +++ b/src/app/api/ai/chat/stream/route.ts @@ -6,15 +6,22 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar import { createAiChatCompletionStream } from "@/shared/lib/ai/client" import { getAiErrorMessage } from "@/shared/lib/ai" import { trackEvent } from "@/shared/lib/track-event" +import { + rateLimit, + rateLimitKey, + RATE_LIMIT_RULES, +} from "@/shared/lib/rate-limit" import { env } from "@/env.mjs" -import { CHAT_SYSTEM_PROMPT } from "@/modules/ai/services/prompt-templates" +import { CHAT_SYSTEM_PROMPT, SOCRATIC_TUTOR_SYSTEM_PROMPT } from "@/modules/ai/services/prompt-templates" import { filterUserInput, filterAiOutput, - checkDailyLimit, - incrementDailyUsage, + tryConsumeDailyQuota, + refundDailyQuota, + validateSocraticOutput, } from "@/modules/ai/services/content-safety" +import { AiChatInputSchema } from "@/modules/ai/schema" import type { AiChatMessage } from "@/modules/ai/types" /** @@ -23,11 +30,14 @@ import type { AiChatMessage } from "@/modules/ai/types" * 使用 Server-Sent Events 逐 token 推送 AI 回复, * 降低用户感知延迟。 * - * 安全: + * 安全策略(与非流式端点一致): * - requirePermission(AI_CHAT) 权限校验 + * - rateLimit 每分钟限流(防高频滥用) + * - Zod 校验输入(AiChatInputSchema,限制消息数/长度) + * - tryConsumeDailyQuota 原子化每日限额(防 TOCTOU 竞态) * - 输入/输出内容安全过滤 - * - 每日交互限制 - * - 学生侧 Socratic 模式 + * - 学生侧 Socratic 模式(服务端强制,忽略客户端 systemPrompt) + * - 过滤/失败时 refundDailyQuota(不惩罚用户) */ const formatEvent = (data: unknown): string => { @@ -50,34 +60,45 @@ export async function POST(request: NextRequest): Promise { const userRole = session?.user?.role ?? "student" const isStudent = userRole === "student" - // 2. 每日限制 - const limitCheck = checkDailyLimit(ctx.userId, userRole) - if (limitCheck.blocked) { + // 2. Rate limit(与非流式端点一致) + const limitResult = rateLimit({ + key: rateLimitKey("ai-chat-stream", ctx.userId), + ...RATE_LIMIT_RULES.AI_CHAT, + }) + if (!limitResult.success) { + return new Response(formatError("Rate limit exceeded"), { + status: 429, + headers: { "Content-Type": "text/event-stream" }, + }) + } + + // 3. Zod 校验输入(防止超大 message 导致 token 爆炸) + const rawBody = await request.json().catch(() => null) + const parseResult = AiChatInputSchema.safeParse(rawBody) + if (!parseResult.success) { + return new Response(formatError("Invalid input"), { + status: 400, + headers: { "Content-Type": "text/event-stream" }, + }) + } + const body = parseResult.data + + // 4. 原子化每日限额检查(防 TOCTOU 竞态) + const quotaResult = tryConsumeDailyQuota(ctx.userId, userRole) + if (quotaResult.blocked) { return new Response(formatError("Daily limit reached"), { status: 429, headers: { "Content-Type": "text/event-stream" }, }) } - // 3. 解析请求 - const body = (await request.json()) as { - messages?: AiChatMessage[] - providerId?: string - systemPrompt?: string - } - - if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) { - return new Response(formatError("Messages are required"), { - status: 400, - headers: { "Content-Type": "text/event-stream" }, - }) - } - - // 4. 输入安全过滤 + // 5. 输入安全过滤 for (const msg of body.messages) { if (msg.role === "user") { const filterResult = filterUserInput(msg.content, { isStudent }) if (filterResult.blocked) { + // 输入被过滤,回退配额 + refundDailyQuota(ctx.userId) return new Response(formatError("Input blocked by safety filter"), { status: 400, headers: { "Content-Type": "text/event-stream" }, @@ -86,24 +107,35 @@ export async function POST(request: NextRequest): Promise { } } - // 5. 构建 system prompt(学生侧 Socratic 模式) - const baseSystemPrompt = body.systemPrompt ?? CHAT_SYSTEM_PROMPT - const studentSystemPrompt = isStudent - ? `${baseSystemPrompt}\n\nIMPORTANT: You are in student mode. Use the Socratic method. Do NOT give direct answers. Guide the student to find the answer themselves through questions and hints.` - : baseSystemPrompt + // 6. 构建 system prompt + // 安全:服务端强制使用系统提示词,忽略客户端传入的 systemPrompt, + // 防止学生绕过 Socratic 模式获取直接答案。 + const baseSystemPrompt = isStudent + ? SOCRATIC_TUTOR_SYSTEM_PROMPT + : CHAT_SYSTEM_PROMPT const messages: AiChatMessage[] = [ - { role: "system", content: studentSystemPrompt }, + { role: "system", content: baseSystemPrompt }, ...body.messages, ] - // 6. 流式调用 AI + // 7. 流式调用 AI const stream = new ReadableStream({ async start(controller) { const startTime = Date.now() let fullContent = "" let success = true let errorMessage: string | undefined + let wasFiltered = false + + const safeEnqueue = (text: string): void => { + // 防止 controller 已关闭时 enqueue 抛错 + try { + controller.enqueue(encoder.encode(text)) + } catch { + // controller 已关闭,忽略 + } + } try { const aiStream = createAiChatCompletionStream({ @@ -119,27 +151,49 @@ export async function POST(request: NextRequest): Promise { // 输出安全过滤(逐 chunk 检查关键词) const outputFilter = filterAiOutput(chunk, { isStudent }) if (outputFilter.blocked) { - controller.enqueue(encoder.encode(formatEvent({ + safeEnqueue(formatEvent({ type: "filtered", message: "Content filtered for safety", - }))) + })) success = false + wasFiltered = true break } - controller.enqueue(encoder.encode(formatEvent({ type: "token", content: chunk }))) + safeEnqueue(formatEvent({ type: "token", content: chunk })) } - // 增加每日使用计数 - incrementDailyUsage(ctx.userId) + // 仅在成功完成时保留配额计数 + // 过滤或失败时回退配额(不惩罚用户) + if (!success || wasFiltered) { + refundDailyQuota(ctx.userId) + } - controller.enqueue(encoder.encode(FORMAT_DONE)) + // 学生侧:苏格拉底式辅导输出校验 + // 流式完成后检查完整回复是否符合引导式教学原则 + if (success && !wasFiltered && isStudent && fullContent) { + const socraticResult = validateSocraticOutput(fullContent) + if (!socraticResult.valid) { + safeEnqueue(formatEvent({ + type: "socratic_warning", + message: "AI response did not fully follow Socratic tutoring principles.", + })) + } + } + + safeEnqueue(FORMAT_DONE) } catch (error) { success = false errorMessage = error instanceof Error ? error.message : String(error) - controller.enqueue(encoder.encode(formatError(getAiErrorMessage(error)))) + // AI 调用失败,回退配额 + refundDailyQuota(ctx.userId) + safeEnqueue(formatError(getAiErrorMessage(error))) } finally { - controller.close() + try { + controller.close() + } catch { + // 已关闭,忽略 + } // 埋点 void trackEvent({ @@ -152,6 +206,7 @@ export async function POST(request: NextRequest): Promise { tokenCount: fullContent.length / 4, errorMessage, isStudent, + filtered: wasFiltered, }, }).catch(() => { // 静默失败 diff --git a/src/modules/ai/actions.ts b/src/modules/ai/actions.ts index d4448dc..9dae37f 100644 --- a/src/modules/ai/actions.ts +++ b/src/modules/ai/actions.ts @@ -4,9 +4,11 @@ import { getTranslations } from "next-intl/server" import type { ActionState } from "@/shared/types/action-state" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" -import { Permissions } from "@/shared/types/permissions" +import { Permissions, type Permission } from "@/shared/types/permissions" import { createAiService, safeAiCall } from "./services/ai-service" +import { getAiUsageStats } from "./data-access" +import { getKnowledgePointsWithRelations, getStudentKpMastery } from "@/modules/textbooks/data-access-graph" import { AiChatInputSchema, GradingInputSchema, @@ -42,10 +44,10 @@ import type { // --------------------------------------------------------------------------- const requireAiPermission = async ( - ...permissions: readonly string[] + ...permissions: readonly Permission[] ): Promise<{ userId: string }> => { const results = await Promise.all( - permissions.map((p) => requirePermission(p as never)) + permissions.map((p) => requirePermission(p)) ) return { userId: results[0].userId } } @@ -294,8 +296,56 @@ export async function recommendStudyPathAction( return { success: false, message: t("error.invalidInput") } } + const serviceInput: StudyPathInput = { ...parsed.data } + + // V3:知识图谱集成(对标 Squirrel AI 纳米级知识图谱) + // 若传入 textbookId 但未传入 knowledgeGraph,自动从 textbooks 模块获取 + if (parsed.data.textbookId && !parsed.data.knowledgeGraph) { + try { + const [kps, masteryMap] = await Promise.all([ + getKnowledgePointsWithRelations(parsed.data.textbookId), + getStudentKpMastery(parsed.data.studentId, parsed.data.textbookId), + ]) + + if (kps.length > 0) { + serviceInput.knowledgeGraph = { + nodes: kps.map((kp) => ({ + id: kp.id, + name: kp.name, + level: kp.level, + masteryLevel: masteryMap.get(kp.id)?.masteryLevel, + })), + edges: kps + .flatMap((kp) => + (kp.prerequisiteIds ?? []).map((prereqId) => ({ + from: prereqId, + to: kp.id, + type: "prerequisite" as const, + })) + ), + } + + // 同步填充 currentMastery(若未传入) + if (!serviceInput.currentMastery || serviceInput.currentMastery.length === 0) { + serviceInput.currentMastery = kps + .filter((kp) => masteryMap.has(kp.id)) + .map((kp) => { + const m = masteryMap.get(kp.id)! + return { + knowledgePoint: kp.name, + masteryLevel: Math.round((m.masteryLevel / 100) * 5), + errorCount: m.totalQuestions - m.correctQuestions, + } + }) + } + } + } catch { + // 知识图谱获取失败不阻断主流程,降级为无图谱模式 + } + } + const service = createAiService(ctx.userId) - const result = await safeAiCall(() => service.recommendStudyPath(parsed.data)) + const result = await safeAiCall(() => service.recommendStudyPath(serviceInput)) if (!result.ok) { return { success: false, message: result.message } } @@ -317,20 +367,9 @@ export async function getAiUsageStatsAction(): Promise try { await requirePermission(Permissions.AI_CONFIGURE) - // 当前从 trackEvent 的内存数据返回统计 - // 生产环境应查询数据库或 Redis 聚合 - const stats: AiUsageStats = { - totalCalls: 0, - callsToday: 0, - callsThisWeek: 0, - activeUsers: 0, - errorRate: 0, - avgDurationMs: 0, - byCapability: [], - byRole: [], - topUsers: [], - recentActivity: [], - } + // 从 data-access 层获取真实聚合统计 + // 当前为内存事件存储(单实例),生产环境应查询 DB 或 Redis + const stats = await getAiUsageStats() return { success: true, data: stats } } catch (error) { diff --git a/src/modules/ai/components/ai-assistant-widget.tsx b/src/modules/ai/components/ai-assistant-widget.tsx index 7787453..07015b1 100644 --- a/src/modules/ai/components/ai-assistant-widget.tsx +++ b/src/modules/ai/components/ai-assistant-widget.tsx @@ -72,7 +72,7 @@ export function AiAssistantWidget(): React.ReactNode { - +
diff --git a/src/modules/ai/components/ai-chat-panel.tsx b/src/modules/ai/components/ai-chat-panel.tsx index c849e82..faba287 100644 --- a/src/modules/ai/components/ai-chat-panel.tsx +++ b/src/modules/ai/components/ai-chat-panel.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl" import { Send, Bot, User, Square, Trash2, Sparkles } from "lucide-react" import { toast } from "sonner" +import { cn } from "@/shared/lib/utils" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Textarea } from "@/shared/components/ui/textarea" @@ -31,8 +32,6 @@ type AiChatPanelProps = { title?: string /** 最大消息数 */ maxMessages?: number - /** 是否启用流式响应(默认 true) */ - streaming?: boolean /** 建议提示词列表(空状态展示) */ suggestedPrompts?: string[] } @@ -49,7 +48,7 @@ type AiChatPanelProps = { * - 清除对话按钮 * - 建议提示词 * - aria-live 无障碍 - * - 对话历史持久化(localStorage) + * - 对话历史持久化(localStorage,防抖写入) */ export function AiChatPanel({ systemPrompt, @@ -57,7 +56,6 @@ export function AiChatPanel({ placeholder, title, maxMessages = 50, - streaming: _streamingEnabled = true, suggestedPrompts, }: AiChatPanelProps): React.ReactNode { const t = useTranslations("ai") @@ -65,33 +63,31 @@ export function AiChatPanel({ const [input, setInput] = useState("") const scrollRef = useRef(null) const storageKey = "ai-chat-history" + const persistTimerRef = useRef | null>(null) - // 从 localStorage 恢复对话历史 + // 持久化对话历史(防抖 500ms,避免流式过程中频繁写入) useEffect(() => { - try { - const stored = localStorage.getItem(storageKey) - if (stored) { - const parsed = JSON.parse(stored) as AiChatMessage[] - if (Array.isArray(parsed) && parsed.length > 0) { - // 通过 send 不合适,直接设置 messages 不支持 - // 这里只是恢复显示,不重新发送 - } - } - } catch { - // 忽略解析错误 + if (messages.length === 0) return + // 流式过程中不写入,流结束后再写 + if (streaming) return + + if (persistTimerRef.current) { + clearTimeout(persistTimerRef.current) } - }, []) - - // 持久化对话历史 - useEffect(() => { - try { - if (messages.length > 0) { + persistTimerRef.current = setTimeout(() => { + try { localStorage.setItem(storageKey, JSON.stringify(messages.slice(-20))) + } catch { + // 忽略写入错误 + } + }, 500) + + return () => { + if (persistTimerRef.current) { + clearTimeout(persistTimerRef.current) } - } catch { - // 忽略写入错误 } - }, [messages]) + }, [messages, streaming]) // 自动滚动到底部 useEffect(() => { @@ -194,7 +190,7 @@ export function AiChatPanel({ {messages.length > 0 ? ( @@ -202,7 +198,10 @@ export function AiChatPanel({ {messages.map((message, index) => (
{message.role === "assistant" ? ( @@ -210,11 +209,13 @@ export function AiChatPanel({ )}
{message.role === "assistant" ? ( @@ -266,7 +267,7 @@ export function AiChatPanel({ onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder={placeholder ?? t("chat.placeholder")} - className="min-h-[60px] resize-none" + className="min-h-16 resize-none" disabled={streaming || messages.length >= maxMessages} aria-label={t("chat.inputLabel")} /> diff --git a/src/modules/ai/components/ai-lesson-content-generator.tsx b/src/modules/ai/components/ai-lesson-content-generator.tsx index 4b8434e..66c110a 100644 --- a/src/modules/ai/components/ai-lesson-content-generator.tsx +++ b/src/modules/ai/components/ai-lesson-content-generator.tsx @@ -128,7 +128,7 @@ export function AiLessonContentGenerator({ value={additionalContext} onChange={(e) => setAdditionalContext(e.target.value)} placeholder={t("lessonPrep.additionalContextPlaceholder")} - className="min-h-[60px] text-sm" + className="min-h-16 text-sm" maxLength={500} />
diff --git a/src/modules/ai/components/ai-question-variant-generator.tsx b/src/modules/ai/components/ai-question-variant-generator.tsx index e48cfc5..67a38e7 100644 --- a/src/modules/ai/components/ai-question-variant-generator.tsx +++ b/src/modules/ai/components/ai-question-variant-generator.tsx @@ -22,6 +22,12 @@ import type { QuestionVariantResult } from "@/modules/ai/types" type VariantType = "same_knowledge_point" | "different_difficulty" | "different_format" +const VARIANT_TYPES: readonly VariantType[] = ["same_knowledge_point", "different_difficulty", "different_format"] + +const isVariantType = (value: string): value is VariantType => { + return VARIANT_TYPES.includes(value as VariantType) +} + type AiQuestionVariantGeneratorProps = { /** 原始题目 */ originalQuestion: { @@ -107,7 +113,11 @@ export function AiQuestionVariantGenerator({