feat(ai): V2 深度增强 — SSE 流式/全局助手/内容安全/多角色覆盖

对标 Khanmigo/Duolingo Max/Squirrel AI/Century Tech 实现:

- SSE 流式响应:createAiChatCompletionStream AsyncGenerator + /api/ai/chat/stream SSE 端点 + useAiChatStream hook(AbortController 停止生成 + localStorage 持久化)

- Markdown 渲染:AiMarkdownRenderer(react-markdown + remark-gfm + 代码块/表格/列表 + hover 复制按钮)

- 全局 AI 助手:AiAssistantWidget 浮动按钮 + Sheet 侧抽屉 + usePathname 路由推断上下文(7 类场景系统提示)+ dashboard layout 全局注入 AiClientProvider

- 内容安全:content-safety.ts 多层过滤(输入/输出安全过滤 + 每日限制 student 50/teacher 200/parent 30/admin 500 + 学生苏格拉底模式),COPPA/FERPA K12 合规

- 多角色 AI 覆盖:家长端 AiChildSummary(学情摘要)+ 管理员端 AiUsageDashboard(使用监控)+ 学生端 AiStudyPath(个性化学习路径)

- i18n 修复:8 处错误键引用 + zh-CN/en ai.json 全面扩展

- 架构文档 004/005 同步更新
This commit is contained in:
SpecialX
2026-06-23 01:34:37 +08:00
parent a60105455e
commit 4da9194a5e
27 changed files with 3522 additions and 172 deletions

View File

@@ -1,6 +1,34 @@
import { AppSidebar } from "@/modules/layout/components/app-sidebar"
import { SidebarProvider } from "@/modules/layout/components/sidebar-provider"
import { SiteHeader } from "@/modules/layout/components/site-header"
import {
AiClientProvider,
} from "@/modules/ai/context/ai-client-provider"
import { AiAssistantWidget } from "@/modules/ai/components/ai-assistant-widget"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
generateChildSummaryAction,
recommendStudyPathAction,
getAiUsageStatsAction,
} from "@/modules/ai/actions"
import type { AiClientService } from "@/modules/ai/types"
const aiClientService: AiClientService = {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
generateChildSummary: generateChildSummaryAction,
recommendStudyPath: recommendStudyPathAction,
getAiUsageStats: getAiUsageStatsAction,
}
export default function DashboardLayout({
children,
@@ -8,14 +36,17 @@ export default function DashboardLayout({
children: React.ReactNode
}) {
return (
<SidebarProvider sidebar={<AppSidebar />}>
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-background focus:text-foreground focus:border focus:border-border focus:rounded-md focus:m-2">
Skip to main content
</a>
<SiteHeader />
<main id="main-content" className="flex-1 overflow-auto p-6">
{children}
</main>
</SidebarProvider>
<AiClientProvider service={aiClientService}>
<SidebarProvider sidebar={<AppSidebar />}>
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-background focus:text-foreground focus:border focus:border-border focus:rounded-md focus:m-2">
Skip to main content
</a>
<SiteHeader />
<main id="main-content" className="flex-1 overflow-auto p-6">
{children}
</main>
<AiAssistantWidget />
</SidebarProvider>
</AiClientProvider>
)
}

View File

@@ -0,0 +1,182 @@
import { NextRequest } from "next/server"
import { auth } from "@/auth"
import { Permissions } from "@/shared/types/permissions"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { createAiChatCompletionStream } from "@/shared/lib/ai/client"
import { getAiErrorMessage } from "@/shared/lib/ai"
import { trackEvent } from "@/shared/lib/track-event"
import { env } from "@/env.mjs"
import { CHAT_SYSTEM_PROMPT } from "@/modules/ai/services/prompt-templates"
import {
filterUserInput,
filterAiOutput,
checkDailyLimit,
incrementDailyUsage,
} from "@/modules/ai/services/content-safety"
import type { AiChatMessage } from "@/modules/ai/types"
/**
* AI 聊天流式端点SSE
*
* 使用 Server-Sent Events 逐 token 推送 AI 回复,
* 降低用户感知延迟。
*
* 安全:
* - requirePermission(AI_CHAT) 权限校验
* - 输入/输出内容安全过滤
* - 每日交互限制
* - 学生侧 Socratic 模式
*/
const formatEvent = (data: unknown): string => {
return `data: ${JSON.stringify(data)}\n\n`
}
const formatError = (message: string): string => {
return formatEvent({ type: "error", message })
}
const FORMAT_DONE = "data: [DONE]\n\n"
export async function POST(request: NextRequest): Promise<Response> {
const encoder = new TextEncoder()
try {
// 1. 权限校验
const ctx = await requirePermission(Permissions.AI_CHAT)
const session = await auth()
const userRole = session?.user?.role ?? "student"
const isStudent = userRole === "student"
// 2. 每日限制
const limitCheck = checkDailyLimit(ctx.userId, userRole)
if (limitCheck.blocked) {
return new Response(formatError("Daily limit reached"), {
status: 429,
headers: { "Content-Type": "text/event-stream" },
})
}
// 3. 解析请求
const body = (await request.json()) as {
messages?: AiChatMessage[]
providerId?: string
systemPrompt?: string
}
if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
return new Response(formatError("Messages are required"), {
status: 400,
headers: { "Content-Type": "text/event-stream" },
})
}
// 4. 输入安全过滤
for (const msg of body.messages) {
if (msg.role === "user") {
const filterResult = filterUserInput(msg.content, { isStudent })
if (filterResult.blocked) {
return new Response(formatError("Input blocked by safety filter"), {
status: 400,
headers: { "Content-Type": "text/event-stream" },
})
}
}
}
// 5. 构建 system prompt学生侧 Socratic 模式)
const baseSystemPrompt = body.systemPrompt ?? CHAT_SYSTEM_PROMPT
const studentSystemPrompt = isStudent
? `${baseSystemPrompt}\n\nIMPORTANT: You are in student mode. Use the Socratic method. Do NOT give direct answers. Guide the student to find the answer themselves through questions and hints.`
: baseSystemPrompt
const messages: AiChatMessage[] = [
{ role: "system", content: studentSystemPrompt },
...body.messages,
]
// 6. 流式调用 AI
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const startTime = Date.now()
let fullContent = ""
let success = true
let errorMessage: string | undefined
try {
const aiStream = createAiChatCompletionStream({
messages: messages.map((m) => ({ role: m.role, content: m.content })),
model: String(env.AI_MODEL ?? "gpt-4o-mini"),
temperature: 0.7,
...(body.providerId ? { providerId: body.providerId } : {}),
})
for await (const chunk of aiStream) {
fullContent += chunk
// 输出安全过滤(逐 chunk 检查关键词)
const outputFilter = filterAiOutput(chunk, { isStudent })
if (outputFilter.blocked) {
controller.enqueue(encoder.encode(formatEvent({
type: "filtered",
message: "Content filtered for safety",
})))
success = false
break
}
controller.enqueue(encoder.encode(formatEvent({ type: "token", content: chunk })))
}
// 增加每日使用计数
incrementDailyUsage(ctx.userId)
controller.enqueue(encoder.encode(FORMAT_DONE))
} catch (error) {
success = false
errorMessage = error instanceof Error ? error.message : String(error)
controller.enqueue(encoder.encode(formatError(getAiErrorMessage(error))))
} finally {
controller.close()
// 埋点
void trackEvent({
event: "ai.chat_stream",
userId: ctx.userId,
targetType: "chat",
properties: {
success,
durationMs: Date.now() - startTime,
tokenCount: fullContent.length / 4,
errorMessage,
isStudent,
},
}).catch(() => {
// 静默失败
})
}
},
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
})
} catch (error) {
if (error instanceof PermissionDeniedError) {
return new Response(formatError("Permission denied"), {
status: 403,
headers: { "Content-Type": "text/event-stream" },
})
}
return new Response(formatError(getAiErrorMessage(error)), {
status: 500,
headers: { "Content-Type": "text/event-stream" },
})
}
}