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