Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
# 1) 将 /images/* 文章图片就近复制并更新引用
pnpm migrate:images || exit 1

# 将迁移后的变更加入暂存,确保本次提交包含更新
git add -A

# 2) 校验图片路径与命名(不合规则阻止提交)
pnpm lint:images || exit 1

Expand Down
27 changes: 27 additions & 0 deletions app/api/analytics/route.ts
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,
},
});
Comment on lines +5 to +20
Copy link

Copilot AI Feb 20, 2026

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 userId and writes it to the database. That allows spoofing/poisoning analytics data (and potentially associating events to other users). Prefer deriving userId from the authenticated session on the server, or omit it entirely and log only server-validated identifiers.

Copilot uses AI. Check for mistakes.

return Response.json({ success: true });
} catch (error) {
console.error("Analytics API error:", error);
return Response.json({ error: "Failed to log event" }, { status: 500 });
}
}
47 changes: 47 additions & 0 deletions app/api/chat/route.ts
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";
Expand All @@ -19,6 +20,7 @@ interface ChatRequest {
};
provider?: AIProvider;
apiKey?: string;
chatId?: string;
}

export async function POST(req: Request) {
Expand All @@ -29,6 +31,7 @@ export async function POST(req: Request) {
pageContext,
provider = "intern", // 默认使用书生模型
apiKey,
chatId,
}: ChatRequest = await req.json();

// 对指定Provider验证key是否存在
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route always attempts to persist chat history via Prisma in onFinish. In environments where DATABASE_URL isn’t configured (the app previously supported running without DB), this will reliably throw and spam logs (and could add latency at the end of each response). Consider guarding the persistence block behind a DB-enabled flag (e.g. if (process.env.DATABASE_URL)) or making the prisma client optional so the feature can be disabled cleanly.

Copilot uses AI. Check for mistakes.
});

return result.toUIMessageStreamResponse();
Expand Down
165 changes: 165 additions & 0 deletions app/api/suggestions/route.ts
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
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On welcome requests, JSON parse failures return debugError and debugText to the client. That leaks internal errors/model output to end users and can expose sensitive prompt/context. Prefer logging server-side and returning a generic error (or gate debug fields behind a development-only flag).

Suggested change
// 把报错原因和原始文本暴露出来方便我调试
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);

Copilot uses AI. Check for mistakes.
} else {
// 如果解析失败,尝试通过正则提取引号中的内容(兼容中英文引号)
const fallbackMatches = text.match(/(?:["“])([^"”]+)(?:["”])/g);
if (fallbackMatches && fallbackMatches.length > 0) {
questions = fallbackMatches
.map((m) => m.replace(/["“”]/g, "").trim())
.filter((line) => line.length > 0);
} else {
// 如果连引号都没有,尝试按行分割兜底
questions = text
.split("\n")
.map((line) =>
line
.replace(/^\d+\.\s*/, "")
.replace(/[`"“”]/g, "")
.trim(),
)
.filter(
(line) =>
line.length > 0 &&
!line.startsWith("json") &&
!line.startsWith("[") &&
!line.startsWith("]"),
);
}
}
}

// 确保返回的是数组
if (!Array.isArray(questions)) {
questions = [];
}

// 对于跟进建议最多返回 3 个,对于欢迎建议最多返回 4 个
const maxCount = isWelcomeRequest ? 4 : 3;
return Response.json({ questions: questions.slice(0, maxCount) });
} catch (error) {
console.error("建议 API 错误 (Suggestions API error):", error);
return Response.json(
{ error: "无法生成建议 (Failed to generate suggestions)" },
{ status: 500 },
);
}
}
Loading
Loading