Skip to content

Comments

动态欢迎语 & 推荐性能优化#255

Merged
longsizhuo merged 10 commits intomainfrom
feat/ai-assistant-more
Feb 20, 2026
Merged

动态欢迎语 & 推荐性能优化#255
longsizhuo merged 10 commits intomainfrom
feat/ai-assistant-more

Conversation

@longsizhuo
Copy link
Member

动态的首屏欢迎语:打开 AI 助手时,不再使用固定的兜底问题。如果当前没有任何聊天记录,会根据当前所在的文档/页面内容,动态生成 4 个相关的初始提问建议。
追问建议并行加载:优化了生成后续推荐问题的性能。现在发送问题时,主回复流与相关推荐会同时触发加载。当 AI 回复结束时,追问建议基本上能做到“秒出”,不再需要干等 loading。

@Crokily 如何测试:

测试首屏动态推荐:进入一个具体的文章页面(不要在首页打招呼),打开 AI 助手悬浮窗,看看初始弹出的 4 个建议是否和文章内容相关(期间会展示骨架屏动画)。
测试追问并行加载:随便发一条新消息并打开控制台 Network,观察到生成建议 (/api/suggestions) 的请求是与大模型 chat 接口差不多同时发出的,大大减少了漫长等待感。

#131

@vercel
Copy link

vercel bot commented Feb 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
involutionhell-github-io Ready Ready Preview, Comment Feb 20, 2026 4:02pm
website-preview Ready Ready Preview, Comment Feb 20, 2026 4:02pm

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

该 PR 为 AI 助手引入“首屏动态欢迎语”和“追问建议并行加载”,以提升首次打开的相关性与发送消息后的体感速度;同时补齐了建议生成 API、埋点、以及聊天记录落库等配套基础设施(Prisma + Postgres)。

Changes:

  • 首屏无聊天记录时,根据当前文档上下文动态生成 4 条欢迎建议,并增加骨架屏展示。
  • 发送消息时并行触发追问建议生成,尽量在主回复结束时同步展示建议。
  • 引入 Prisma/PG 适配与新数据模型(Chat/Message/AnalyticsEvent),新增 suggestions / analytics API,并在 chat 完成时保存对话。

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
prisma/schema.prisma 调整 Prisma 生成器/数据源配置并新增 Chat/Message/AnalyticsEvent 模型
prisma.config.ts 新增 Prisma config(schema 路径与 datasource url)
package.json 依赖切换到 Prisma + pg,并引入 adapter/类型包
pnpm-lock.yaml 锁文件随依赖切换更新
lib/db.ts 新增 PrismaClient + pg Pool + PrismaPg adapter 初始化
lib/ai/providers/glm.ts 新增 GLM-4-Flash provider(用于建议生成的模型候选)
auth.ts NextAuth adapter 从 Neon 切换到 PrismaAdapter
app/components/assistant-ui/thread.tsx UI:欢迎建议骨架屏/动态建议、追问建议区、assistant thinking 状态
app/components/assistant-ui/assistant-modal.tsx Thread 透传欢迎/追问建议及 loading 状态
app/components/DocsAssistant.tsx 并行加载追问建议、首屏欢迎建议获取、埋点上报、chatId 传递
app/api/suggestions/route.ts 新增建议生成 API(欢迎/追问两类)
app/api/chat/route.ts chat 结束时将会话与消息写入数据库
app/api/analytics/route.ts 新增埋点写入 API
.husky/pre-commit pre-commit 流程调整(移除自动 git add -A)
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 5 to 15
const connectionString = `${process.env.DATABASE_URL}`;

const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};

const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool as any);
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
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.

connectionString is built with a template literal, so when DATABASE_URL is undefined it becomes the literal string "undefined". This file also always constructs Pool/PrismaClient, which can break local dev (and any environment without DB) even though other code tries to gracefully run without a DB. Consider only creating the pool/adapter when process.env.DATABASE_URL is present (or exporting a lazy getter), and avoid as any by using the adapter’s expected pool type.

Suggested change
const connectionString = `${process.env.DATABASE_URL}`;
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool as any);
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
let prisma = globalForPrisma.prisma;
export function getPrismaClient():
| PrismaClient
| undefined {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
return undefined;
}
if (!prisma) {
const pool = new Pool({ connectionString: databaseUrl });
const adapter = new PrismaPg(pool);
prisma = new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
}
return prisma;
}

Copilot uses AI. Check for mistakes.

if (!databaseUrl) {
if (!process.env.DATABASE_URL) {
console.warn("[auth] DATABASE_URL missing – running without Neon adapter");
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.

The warning message still says “running without Neon adapter” even though the code now uses PrismaAdapter. Updating this log will avoid confusion during debugging.

Suggested change
console.warn("[auth] DATABASE_URL missing – running without Neon adapter");
console.warn("[auth] DATABASE_URL missing – running without Prisma adapter (using JWT sessions)");

Copilot uses AI. Check for mistakes.
Comment on lines +75 to +123
// 确保有 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);
}
},
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.
Comment on lines +114 to +119
// 把报错原因和原始文本暴露出来方便我调试
return Response.json({
questions: [],
debugError: String(e),
debugText: text,
});
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.
Comment on lines +5 to +20
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,
},
});
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.
Comment on lines 506 to 518
const message = useMessage() as any;

const isRunning =
message.status === "in_progress" ||
(typeof message.status === "object" && message.status?.type === "running");

const hasContent =
message.content &&
(typeof message.content === "string"
? message.content.length > 0
: Array.isArray(message.content)
? message.content.length > 0
: !!message.content);
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.

useMessage() is cast to any, and then the code introspects message.status/message.content. This removes type safety and makes it easy to break if @assistant-ui/react changes shape. If possible, use the library’s exported message type (or a narrow local type guard) so the running/empty-content checks are type-safe.

Suggested change
const message = useMessage() as any;
const isRunning =
message.status === "in_progress" ||
(typeof message.status === "object" && message.status?.type === "running");
const hasContent =
message.content &&
(typeof message.content === "string"
? message.content.length > 0
: Array.isArray(message.content)
? message.content.length > 0
: !!message.content);
const message = useMessage();
const isRunnableMessage = (
msg: unknown,
): msg is { status?: string | { type?: string }; content?: unknown } => {
if (msg === null || typeof msg !== "object") {
return false;
}
return "status" in msg || "content" in msg;
};
const runnableMessage = isRunnableMessage(message) ? message : undefined;
const isRunning =
!!runnableMessage &&
(runnableMessage.status === "in_progress" ||
(typeof runnableMessage.status === "object" &&
runnableMessage.status?.type === "running"));
const content = runnableMessage?.content;
const hasContent =
!!content &&
(typeof content === "string"
? content.length > 0
: Array.isArray(content)
? content.length > 0
: !!content);

Copilot uses AI. Check for mistakes.
Comment on lines 49 to 116
@@ -48,36 +59,226 @@ function DocsAssistantInner({ pageContext }: DocsAssistantProps) {
pageContext,
provider,
apiKey,
chatId,
},
}),
[pageContext, provider, apiKey],
[pageContext, provider, apiKey, chatId],
);

const [suggestions, setSuggestions] = useState<string[]>([]);
// 仅标志后台是否正在获取建议(用于逻辑判断)
const [isFetchingSuggestions, setIsFetchingSuggestions] = useState(false);
// 控制 UI 上是否显示“正在思考...”加载状态(只有主回答结束后,由于建议还在获取,才显示骨架屏)
const [showSuggestionsLoader, setShowSuggestionsLoader] = useState(false);
// 缓存获取好的建议,等待主回答结束后才推给 Thread 渲染
const [pendingSuggestions, setPendingSuggestions] = useState<string[]>([]);

// 欢迎页建议相关的 state
const [welcomeSuggestions, setWelcomeSuggestions] = useState<
WelcomeSuggestion[]
>([]);
const [isLoadingWelcome, setIsLoadingWelcome] = useState(false);
const fetchedWelcomeRef = useRef(false);

// 埋点上报函数
const logAnalyticsEvent = useCallback(
async (eventType: string, eventData?: any) => {
try {
await fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
eventType,
eventData: {
...eventData,
chatId,
url: window.location.href,
provider,
},
}),
});
} catch (e) {
console.error("Failed to log analytics event:", e);
}
},
[chatId, provider],
);

// 组件挂载时上报打开事件
useEffect(() => {
logAnalyticsEvent("assistant_opened");
}, [logAnalyticsEvent]);

const chat = useChat({
id: `assistant-${provider}-${apiKey}`, // Force chat reset when provider OR key changes
// 当 Provider 或 Key 更改时强制重置聊天
id: chatId,
// 当 Provider 或 Key 更改时强制重置聊天 (但保持 chatId 不变会不会有问题?可能需要重新生成)
// 这里我们暂时保留 chatId 不变,视为同一会话切换了模型
transport,
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.

chatId is generated once per mount and is no longer tied to provider/apiKey. This changes the previous behavior where switching provider/key forced a chat reset; now the runtime will keep the same chat session across provider changes, which may be surprising and can mix histories between models/keys. If the old reset behavior is still desired, regenerate chatId when provider/apiKey changes (or incorporate them into the useChat id).

Copilot uses AI. Check for mistakes.
import { Thread } from "@/app/components/assistant-ui/thread";
import { TooltipIconButton } from "@/app/components/assistant-ui/tooltip-icon-button";

import { WelcomeSuggestion } from "@/app/components/DocsAssistant";
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.

WelcomeSuggestion is only used as a type here; using a type-only import (import type { WelcomeSuggestion } ...) avoids creating an unnecessary runtime dependency on DocsAssistant (and helps prevent accidental circular imports/bundle bloat).

Suggested change
import { WelcomeSuggestion } from "@/app/components/DocsAssistant";
import type { WelcomeSuggestion } from "@/app/components/DocsAssistant";

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +126
userId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]

user users? @relation(fields: [userId], references: [id])

@@index([userId])
}

model Message {
id String @id @default(cuid())
chatId String
role String
content String
createdAt DateTime @default(now())

chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)

@@index([chatId])
}

model AnalyticsEvent {
id String @id @default(cuid())
userId Int?
eventType String
eventData Json?
createdAt DateTime @default(now())

user users? @relation(fields: [userId], references: [id])

@@index([eventType])
@@index([userId])
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.

Schema model naming is inconsistent: existing models are lower_snake_case / plural (e.g. users, sessions, doc_paths), while new tables are introduced as PascalCase (Chat, Message, AnalyticsEvent) with camelCase fields. This will create differently-cased table/column names in Postgres (often requiring quoted identifiers) and makes the schema harder to reason about alongside the existing tables. Consider aligning the new models with the established naming (or add @@map / @map to keep DB names consistent).

Suggested change
userId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages Message[]
user users? @relation(fields: [userId], references: [id])
@@index([userId])
}
model Message {
id String @id @default(cuid())
chatId String
role String
content String
createdAt DateTime @default(now())
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
@@index([chatId])
}
model AnalyticsEvent {
id String @id @default(cuid())
userId Int?
eventType String
eventData Json?
createdAt DateTime @default(now())
user users? @relation(fields: [userId], references: [id])
@@index([eventType])
@@index([userId])
userId Int? @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
messages Message[]
user users? @relation(fields: [userId], references: [id])
@@index([userId])
@@map("chats")
}
model Message {
id String @id @default(cuid())
chatId String @map("chat_id")
role String
content String
createdAt DateTime @default(now()) @map("created_at")
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
@@index([chatId])
@@map("messages")
}
model AnalyticsEvent {
id String @id @default(cuid())
userId Int? @map("user_id")
eventType String @map("event_type")
eventData Json? @map("event_data")
createdAt DateTime @default(now()) @map("created_at")
user users? @relation(fields: [userId], references: [id])
@@index([eventType])
@@index([userId])
@@map("analytics_events")

Copilot uses AI. Check for mistakes.
}

datasource db {
provider = "postgresql"
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.

datasource db is missing the required url (or directUrl) field. Prisma schema validation will fail and client generation/migrations won’t work unless a connection URL is defined (typically url = env("DATABASE_URL")).

Suggested change
provider = "postgresql"
provider = "postgresql"
url = env("DATABASE_URL")

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant