-
Notifications
You must be signed in to change notification settings - Fork 41
动态欢迎语 & 推荐性能优化 #255
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
动态欢迎语 & 推荐性能优化 #255
Changes from all commits
e5b8efc
f1576e2
1d358b4
cfbb3a0
3111e2a
76e8cef
e3eb4e0
a5d5fd3
1cc91ae
52f176c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { prisma } from "@/lib/db"; | ||
|
|
||
| export async function POST(req: Request) { | ||
| try { | ||
| const { eventType, eventData, userId } = await req.json(); | ||
|
|
||
| if (!eventType) { | ||
| return Response.json( | ||
| { error: "Event type is required" }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| await prisma.analyticsEvent.create({ | ||
| data: { | ||
| eventType, | ||
| eventData: eventData ?? {}, | ||
| userId: userId ? parseInt(String(userId)) : null, | ||
| }, | ||
| }); | ||
|
|
||
| return Response.json({ success: true }); | ||
| } catch (error) { | ||
| console.error("Analytics API error:", error); | ||
| return Response.json({ error: "Failed to log event" }, { status: 500 }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import { prisma } from "@/lib/db"; | ||
| import { streamText, UIMessage, convertToModelMessages } from "ai"; | ||
| import { getModel, requiresApiKey, type AIProvider } from "@/lib/ai/models"; | ||
| import { buildSystemMessage } from "@/lib/ai/prompt"; | ||
|
|
@@ -19,6 +20,7 @@ interface ChatRequest { | |
| }; | ||
| provider?: AIProvider; | ||
| apiKey?: string; | ||
| chatId?: string; | ||
| } | ||
|
|
||
| export async function POST(req: Request) { | ||
|
|
@@ -29,6 +31,7 @@ export async function POST(req: Request) { | |
| pageContext, | ||
| provider = "intern", // 默认使用书生模型 | ||
| apiKey, | ||
| chatId, | ||
| }: ChatRequest = await req.json(); | ||
|
|
||
| // 对指定Provider验证key是否存在 | ||
|
|
@@ -69,11 +72,55 @@ export async function POST(req: Request) { | |
| // 根据Provider获取 AI 模型实例 | ||
| const model = getModel(provider, apiKey); | ||
|
|
||
| // 确保有 chatId (如果前端没传,就生成一个临时的,虽然这会导致每次请求都是新会话) | ||
| // 理想情况是前端应该维护 chatId | ||
| const effectiveChatId = chatId || crypto.randomUUID(); | ||
|
|
||
| // 生成流式响应 | ||
| const result = streamText({ | ||
| model: model, | ||
| system: systemMessage, | ||
| messages: convertToModelMessages(messages), | ||
| onFinish: async ({ text }) => { | ||
| try { | ||
| // 1. 保存/更新会话 | ||
| await prisma.chat.upsert({ | ||
| where: { id: effectiveChatId }, | ||
| update: { updatedAt: new Date() }, | ||
| create: { id: effectiveChatId }, | ||
| }); | ||
|
|
||
| // 2. 保存用户消息 (取最后一条) | ||
| // AI SDK v5 中,UIMessage 不再有 content 字段,内容在 parts 数组中 | ||
| const lastUserMessage = messages[messages.length - 1]; | ||
| if (lastUserMessage && lastUserMessage.role === "user") { | ||
| // 从 parts 数组中提取所有文本内容并拼接 | ||
| const userContent = lastUserMessage.parts | ||
| .filter((part) => part.type === "text") | ||
| .map((part) => (part as { type: "text"; text: string }).text) | ||
| .join("\n"); | ||
|
|
||
| await prisma.message.create({ | ||
| data: { | ||
| chatId: effectiveChatId, | ||
| role: "user", | ||
| content: userContent, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| // 3. 保存 AI 回复 | ||
| await prisma.message.create({ | ||
| data: { | ||
| chatId: effectiveChatId, | ||
| role: "assistant", | ||
| content: text, | ||
| }, | ||
| }); | ||
| } catch (error) { | ||
| console.error("Failed to save chat history:", error); | ||
| } | ||
| }, | ||
|
Comment on lines
+75
to
+123
|
||
| }); | ||
|
|
||
| return result.toUIMessageStreamResponse(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,165 @@ | ||||||||||||||||||||||||||||||||||||
| import { generateText } from "ai"; | ||||||||||||||||||||||||||||||||||||
| import { getModel, requiresApiKey, type AIProvider } from "@/lib/ai/models"; | ||||||||||||||||||||||||||||||||||||
| import { createGlmFlashModel } from "@/lib/ai/providers/glm"; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // 允许流式响应最长30秒 | ||||||||||||||||||||||||||||||||||||
| export const maxDuration = 30; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import type { UIMessage, TextUIPart } from "ai"; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| interface SuggestionsRequest { | ||||||||||||||||||||||||||||||||||||
| messages: UIMessage[]; | ||||||||||||||||||||||||||||||||||||
| pageContext?: { | ||||||||||||||||||||||||||||||||||||
| title?: string; | ||||||||||||||||||||||||||||||||||||
| description?: string; | ||||||||||||||||||||||||||||||||||||
| slug?: string; | ||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||
| provider?: AIProvider; | ||||||||||||||||||||||||||||||||||||
| apiKey?: string; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| export async function POST(req: Request) { | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| const { | ||||||||||||||||||||||||||||||||||||
| messages, | ||||||||||||||||||||||||||||||||||||
| pageContext, | ||||||||||||||||||||||||||||||||||||
| provider = "intern", | ||||||||||||||||||||||||||||||||||||
| apiKey, | ||||||||||||||||||||||||||||||||||||
| }: SuggestionsRequest = await req.json(); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // 如果需要,验证 API 密钥 | ||||||||||||||||||||||||||||||||||||
| if (requiresApiKey(provider) && (!apiKey || apiKey.trim() === "")) { | ||||||||||||||||||||||||||||||||||||
| return Response.json( | ||||||||||||||||||||||||||||||||||||
| { error: "需要 API 密钥 (API key is required)" }, | ||||||||||||||||||||||||||||||||||||
| { status: 400 }, | ||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // 模型选择策略: | ||||||||||||||||||||||||||||||||||||
| // - 若用户选了自己的 Provider(openai/gemini),用用户的模型 | ||||||||||||||||||||||||||||||||||||
| // - 否则(默认 intern)优先用 GLM-4-Flash(免费且快速),若 ZHIPU_API_KEY 未配置则回退到 intern | ||||||||||||||||||||||||||||||||||||
| let model; | ||||||||||||||||||||||||||||||||||||
| if (provider !== "intern") { | ||||||||||||||||||||||||||||||||||||
| // 用户自选模型(openai / gemini) | ||||||||||||||||||||||||||||||||||||
| model = getModel(provider, apiKey); | ||||||||||||||||||||||||||||||||||||
| } else if (process.env.ZHIPU_API_KEY) { | ||||||||||||||||||||||||||||||||||||
| // 默认使用智谱 GLM-4-Flash(免费轻量) | ||||||||||||||||||||||||||||||||||||
| model = createGlmFlashModel(); | ||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||
| // 兜底:仍使用 intern | ||||||||||||||||||||||||||||||||||||
| model = getModel("intern"); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const isWelcomeRequest = messages.length === 0; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| let prompt = ""; | ||||||||||||||||||||||||||||||||||||
| if (isWelcomeRequest) { | ||||||||||||||||||||||||||||||||||||
| // 欢迎页面的初始动态建议 | ||||||||||||||||||||||||||||||||||||
| const contextInfo = [ | ||||||||||||||||||||||||||||||||||||
| pageContext?.title ? `标题: ${pageContext.title}` : "", | ||||||||||||||||||||||||||||||||||||
| pageContext?.description ? `描述: ${pageContext.description}` : "", | ||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||
| .filter(Boolean) | ||||||||||||||||||||||||||||||||||||
| .join("\n"); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| prompt = `请根据以下当前页面的上下文信息,生成4个引导新手用户提问的建议框内容。\n\n上下文:\n${contextInfo || "未知页面"}\n\n只返回纯JSON数组,包含4个对象,格式如:\n[{"title":"总结本文","label":"内容要点","action":"请帮我总结一下文章主要内容"}]\n其中title简短明确,label为右上角浅色标签,action为点击后自动发送的提问语句。`; | ||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||
| // 普通的跟进提问建议 | ||||||||||||||||||||||||||||||||||||
| // 只取最后一条用户消息,减少 token 消耗 | ||||||||||||||||||||||||||||||||||||
| const lastUserMsg = messages | ||||||||||||||||||||||||||||||||||||
| .filter((m) => m.role === "user") | ||||||||||||||||||||||||||||||||||||
| .slice(-1)[0]; | ||||||||||||||||||||||||||||||||||||
| const lastText = | ||||||||||||||||||||||||||||||||||||
| (Array.isArray(lastUserMsg?.parts) | ||||||||||||||||||||||||||||||||||||
| ? lastUserMsg.parts | ||||||||||||||||||||||||||||||||||||
| .filter((p): p is TextUIPart => p.type === "text") | ||||||||||||||||||||||||||||||||||||
| .map((p) => p.text) | ||||||||||||||||||||||||||||||||||||
| .join(" ") | ||||||||||||||||||||||||||||||||||||
| : (lastUserMsg as unknown as { content?: string })?.content) ?? ""; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // 语言检测:简单判断是否包含中文字符 | ||||||||||||||||||||||||||||||||||||
| const isChinese = /[\u4e00-\u9fa5]/.test(lastText); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| prompt = isChinese | ||||||||||||||||||||||||||||||||||||
| ? `用户问:"${lastText}"。给出3个简短中文追问(每个不超过15字),直接返回JSON数组,例如:["问题1","问题2","问题3"]` | ||||||||||||||||||||||||||||||||||||
| : `User asked: "${lastText}". Suggest 3 short follow-up questions (max 10 words each). Return a JSON array only, e.g. ["Q1","Q2","Q3"]`; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const { text } = await generateText({ | ||||||||||||||||||||||||||||||||||||
| model, | ||||||||||||||||||||||||||||||||||||
| prompt, | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| let questions: unknown[] = []; | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| // 尝试解析 JSON | ||||||||||||||||||||||||||||||||||||
| // 清理可能存在的 Markdown 代码块标记 | ||||||||||||||||||||||||||||||||||||
| let cleanedText = text | ||||||||||||||||||||||||||||||||||||
| .replace(/```json/gi, "") | ||||||||||||||||||||||||||||||||||||
| .replace(/```/g, "") | ||||||||||||||||||||||||||||||||||||
| .trim(); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // 修复大模型可能生成的中文引号 | ||||||||||||||||||||||||||||||||||||
| cleanedText = cleanedText.replace(/“/g, '"').replace(/”/g, '"'); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| // 尝试仅提取数组部分,防止 AI 返回了前缀描述文本 | ||||||||||||||||||||||||||||||||||||
| const arrayMatch = cleanedText.match(/\[[\s\S]*\]/); | ||||||||||||||||||||||||||||||||||||
| if (arrayMatch) { | ||||||||||||||||||||||||||||||||||||
| cleanedText = arrayMatch[0]; | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| questions = JSON.parse(cleanedText); | ||||||||||||||||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||||||||||||||||
| console.error("解析建议 JSON 失败:", e, "原始文本:", text); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if (isWelcomeRequest) { | ||||||||||||||||||||||||||||||||||||
| // 把报错原因和原始文本暴露出来方便我调试 | ||||||||||||||||||||||||||||||||||||
| return Response.json({ | ||||||||||||||||||||||||||||||||||||
| questions: [], | ||||||||||||||||||||||||||||||||||||
| debugError: String(e), | ||||||||||||||||||||||||||||||||||||
| debugText: text, | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+116
to
+121
|
||||||||||||||||||||||||||||||||||||
| // 把报错原因和原始文本暴露出来方便我调试 | |
| return Response.json({ | |
| questions: [], | |
| debugError: String(e), | |
| debugText: text, | |
| }); | |
| // 把报错原因和原始文本暴露出来方便我调试(仅在非生产环境) | |
| const responseBody: any = { | |
| questions: [], | |
| }; | |
| if (process.env.NODE_ENV !== "production") { | |
| responseBody.debugError = String(e); | |
| responseBody.debugText = text; | |
| } | |
| return Response.json(responseBody); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This endpoint trusts a client-supplied
userIdand writes it to the database. That allows spoofing/poisoning analytics data (and potentially associating events to other users). Prefer derivinguserIdfrom the authenticated session on the server, or omit it entirely and log only server-validated identifiers.