前言

React 的基础学习已经进行了一段时间,是时候做一个完整的前端项目来进行应用了,但只是一个单纯的前端项目似乎并没有什么应用上的价值,所以我思考应该做一个Langchain结合RAG的项目来看看。项目就叫mechat吧,顺带放在博客上(目前还没有做到)。

目前项目已经开源上传于Github,这里给出的是前端项目的从头一步步构建的记录,方便我将来能够复现,同时也对相关的技术应用、问题和解决方案做一个总结,如果能帮助到你那就更好了。

废话不多说,先看看这个项目用到的技术栈:

  • React 和 Next.js App Router 负责页面与服务端接口;
  • TypeScript 作为主要开发语言;
  • Ant Design 提供基础组件;
  • CSS Modules 管理页面样式;
  • PostgreSQL 保存会话与消息;
  • Chroma 保存知识片段、向量和 Metadata;
  • LangChain 负责提示词、历史消息和模型调用的组合;
  • Ollama 提供本地的 qwen3:8b 与 qwen3-embedding:0.6b。

请求发送过程为:

1
2
3
4
5
6
7
8
9
10
11
React 页面
↓ fetch
Next.js Route Handler
├── PostgreSQL:读取会话和历史消息
├── Ollama Embedding:把问题转换成向量
├── Chroma:检索相关知识 Chunk
└── LangChain + Ollama Chat:组织提示词并生成回答
↓
PostgreSQL:保存本轮用户消息、AI 回答和引用
↓
React 页面更新消息与历史会话

这里需要注意的是本文的请求都是基于await的异步请求。

小提一嘴,多个await的调用包括串行,并行,以及.then各自调用。影响执行顺序。

一、初始化前端项目

1. 创建 Next.js 项目

使用 Create Next App 初始化项目,输入名字后选择推荐设置即可:

1
npx create-next-app@latest

cd进入你的项目后,安装 Ant Design、图标及 Next.js 样式注册器,这里主要是方便简化开发流程:

1
npm install antd @ant-design/nextjs-registry @ant-design/icons

后续服务端功能还需要安装langchain核心包,本地模型ollama,向量数据库chroma,postgreSQL连接工具等等,这里不再赘述ollama模型的安装过程:

1
2
npm install @langchain/core @langchain/ollama chromadb pg jose zod
npm install -D @types/pg

完成这些,初始化就算完成了。

2. 应用Antd主题设计

初始化后我们得到app下的 layout.tsx 与page.tsx。其中*layout 包裹 page,当访问 / 时,Next.js 把 page.tsx 的渲染结果作为 children 传给 RootLayout;

此时我们修改一下网站的基础信息,并调整主题应用,通过 AntdRegistry 包裹子页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import type { Metadata } from "next";

import { Geist, Geist_Mono } from "next/font/google";
// 新增引入
import { AntdRegistry } from "@ant-design/nextjs-registry";
import "./globals.css";

// 修改网站信息
export const metadata: Metadata = {
title: "Mechat",
description: "支持知识库检索的 AI 对话平台",
};

//原有的字体导入,可以按需保留
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});

const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});

export default function RootLayout({ children }: LayoutProps<"/">) {
return (
// 切换中文
<html lang="zh-CN">
<body>
{/* 应用主题 */}
<AntdRegistry>{children}</AntdRegistry>
</body>
</html>
);
}

AntdRegistry 的作用是配合 Next.js 服务端渲染正确收集 Ant Design 样式,避免首次渲染时样式闪烁或服务端与客户端样式顺序不一致。

二、页面布局设计与样式管理方式

1. 页面布局设计

首先需要进行页面布局设计,这里我们的项目参考常见 AI 对话页面,整体分为左侧导航和右侧聊天区域:

1
2
3
4
5
6
7
8
9
┌──────────┬───────────────────────────────────┐
│ │ Header │
│ ├───────────────────────────────────┤
│ │ │
│ Sider │ Content │
│ │ │
│ ├───────────────────────────────────┤
│ │ Footer │
└──────────┴───────────────────────────────────┘

左侧 Sider 包含:

  • Mechat 标题;
  • 搜索会话按钮;
  • 展开或收缩按钮;
  • 新建对话与历史会话;
  • 当前用户头像和用户名。

右侧包含:

  • Header:当前会话标题;
  • Content:欢迎页、错误提示、加载状态和消息列表;
  • Footer:消息输入框和发送按钮。

所以页面最初只有四个占位区域:

1
2
3
4
5
6
7
8
<Layout className={styles.pageLayout}>
<Sider className={styles.sidebar}>Sider</Sider>
<Layout>
<Header className={styles.header}>Header</Header>
<Content className={styles.content}>Content</Content>
<Footer className={styles.footer}>Footer</Footer>
</Layout>
</Layout>

将上面的内容丢到page页面的return后面去,记得引入layout并解构对象。

2. CSS Modules 与 Ant Design 样式

项目使用 CSS Modules 样式管理:

1
2
3
4
#以page页面为例,为每个页面创建独立的.module.css文件
import styles from "./page.module.css";

<Layout className={styles.pageLayout}>...</Layout>
1
2
3
4
5
.pageLayout:global(.ant-layout) {
height: 100dvh;
min-height: 100dvh;
overflow: hidden;
}

这里需要区分三个概念:

  1. className={styles.pageLayout} 会把 CSS Module 中的局部类名绑定到组件根 DOM;
  2. Ant Design 组件内部仍然存在 .ant-layout、.ant-btn 等全局类;
  3. 当需要限定“同时具有本地类与 Ant 类”的元素时,可以使用 .pageLayout:global(.ant-layout)。

这不是说 CSS Modules 天生优先级低,而是最终样式仍遵循 CSS 的层叠规则:来源顺序、选择器优先级和 !important 都可能影响结果。项目中采用的原则是:

  • 页面固定布局写进 CSS Modules;
  • Ant Design 已有明确属性时优先使用属性;
  • 通用颜色以后可以进入主题配置;
  • 只在需要触及 Ant Design 内部类时使用 :global();

这样的好处在于:减少样式冲突,易于阅读,后期维护方便。

三、组件拆分与设计

前面我已经已经定义了布局,现在需要按照布局和功能设计来进行组件拆分和功能细化,通过明确不同模块的功能职责,数据流转方式,来简化每个页面的内容,使得项目易读的同时有利于维护。

当前我思考设计的组件结构为:

1
2
3
4
5
6
7
app/page.tsx
├── LoginModal 登录弹窗
├── TalkList 新建对话与历史会话列表
└── ChatPanel 右侧聊天页面
├── Header 当前会话标题
├── Content 消息、空状态、加载和错误
└── Footer 输入框与发送按钮

所以按照这个方向,我们开始着手每个页面的设计。

1. page.tsx 的职责

page.tsx 是其他所有组件的父组件,所以状态数据和接口请求都放在这里,并使用 Props 向子组件传递。但这里先不请求接口,而是使用本地假数据完成页面布局和功能设计,因为后端尚未完成。根据用途,我们先预设这些数据在page页面,记得给page页面加上"use client";。

  • 当前用户(静态阶段为假用户名);
  • 会话列表和当前会话 ID;
  • 当前消息列表;
  • 当前输入框内容;
  • 页面级加载和错误状态(静态阶段先用固定布尔值观察样式)。

一般的前后端分离开发会事先约定“未来接口返回的数据长什么样”,所以前端的静态阶段由假数据满足这些类型,页面可以独立开发和观察;等后端接入后,再把假数据替换为 fetch 返回的数据。这样可以避免在写布局时同时排查数据库、Cookie 和模型服务。

2. TalkList 页面配置

首先在 app 路径下创建 components 文件夹,用于存放页面组件。文件夹内先创建 talkList.tsx,同时创建 talkList.module.css 管理它自己的样式。

前面的布局和功能设计我们可以得知,侧边菜单组件talkList.tsx应该有一个Header,Content,以及Footer。按照常见的AI对话平台布局设计,Header中加入一个标题,标题靠左显示,右侧是搜索按钮,搜索按钮按下后弹出窗口输入对话名字;以及收缩按钮,按下后侧栏收缩,专注的对话视窗。Content中我们放入一个antd样式的menu,menu第一个按钮是新开启对话,然后以一个分割线后显示最近对话,并将最近对话以子菜单选项形式加载。Footer中我们显示登录用户的状态信息。

到这里我们就已经能够明白,这个组件的功能包括:

  • 搜索按钮:静态阶段先验证弹窗和输入框聚焦,真实搜索留到接口阶段。
  • 收缩按钮:按下后整体侧边栏收缩,使用 useState 保存收缩状态。
  • 新建对话:静态阶段清空当前会话 ID,让右侧显示空状态。
  • 历史对话:使用useEffect在页面渲染完成时获取数据,当然目前的静态阶段使用假数据渲染。

于是我们可以开始操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// talkList.tsx页面
import styles from "./talkList.module.css";

export default function TalkList() {
return (
<>
<div className={styles.talkListHome}>
<div className={styles.talkListHeader}>Talk List</div>
<div className={styles.talkListContent}>Content</div>
<div className={styles.talkListFooter}>Footer</div>
</div>
</>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// talkList.tsx页面
import styles from "./talkList.module.css";
// 引入hook函数
import { useState, useRef } from "react";
// 引入需要用到的组件
import { Modal, Tooltip, Button, Input } from "antd";
// 引入按钮图标
import { SearchOutlined } from "@ant-design/icons";
// 引入InputRef属性
import type { InputRef } from "antd";

//创建接口检查
interface TalkListProps {
collapsed: boolean;
}

// 对前端传来的数据装入TalkListProps进行核验
export default function TalkList({ collapsed }: TalkListProps) {
// 设置弹窗状态
const [isModalOpen, setIsModalOpen] = useState(false);
// 绑定属性,属性初始为null,后面用它做聚焦
const searchInputRef = useRef<InputRef>(null);
return (
<>
<div className={styles.talkListHome}>
<div className={styles.talkListHeader}>
<div className={styles.sidebarActions}>
<Tooltip
title="搜索会话"
placement={collapsed ? "right" : "bottom"}
align={{ offset: collapsed ? [25, 0] : [0, 10] }}
mouseEnterDelay={0.25}
>
<Button
aria-label="搜索会话"
className={styles.iconButton}
icon={<SearchOutlined />}
onClick={() => setIsModalOpen(true)}
/>
</Tooltip>
</div>
</div>
<div className={styles.talkListContent}>Content</div>
<div className={styles.talkListFooter}>Footer</div>
</div>
<Modal
title="会话查询"
closable={{ "aria-label": "Custom Close Button" }}
open={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
afterOpenChange={(open) => {
if (open) {
window.setTimeout(() => searchInputRef.current?.focus(), 0);
}
}}
>
<Input ref={searchInputRef} placeholder="输入会话名称或关键词" />
</Modal>
</>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// talkList.tsx 页面
import styles from "./talkList.module.css";
import { useState, useRef } from "react";
import { Modal, Tooltip, Button, Input } from "antd";
import {
SearchOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
} from "@ant-design/icons";
import type { InputRef } from "antd";

//新增函数引入,对收缩的方法传入
interface TalkListProps {
collapsed: boolean;
onToggleCollapsed: () => void;
}

// 对前端传来的数据装入TalkListProps进行核验
export default function TalkList({
collapsed,
onToggleCollapsed,
}: TalkListProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const searchInputRef = useRef<InputRef>(null);
return (
<>
<div className={styles.talkListHome}>
<div className={styles.talkListHeader}>
{/* 新增标题 */}
<div className={styles.brand}>{collapsed ? "M" : "Mechat"}</div>
<div className={styles.sidebarActions}>
<Tooltip
title="搜索会话"
placement={collapsed ? "right" : "bottom"}
align={{ offset: collapsed ? [25, 0] : [0, 10] }}
mouseEnterDelay={0.25}
>
<Button
aria-label="搜索会话"
className={styles.iconButton}
icon={<SearchOutlined />}
onClick={() => setIsModalOpen(true)}
/>
</Tooltip>
{/* 新增收缩按钮 */}
<Tooltip
title={collapsed ? "展开侧栏" : "收起侧栏"}
placement={collapsed ? "right" : "bottom"}
align={{ offset: collapsed ? [25, 0] : [0, 10] }}
mouseEnterDelay={0.25}
>
<Button
aria-label={collapsed ? "展开侧栏" : "收起侧栏"}
className={styles.iconButton}
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={onToggleCollapsed}
/>
</Tooltip>
</div>
</div>
<div className={styles.talkListContent}>Content</div>
<div className={styles.talkListFooter}>Footer</div>
</div>
<Modal
title="会话查询"
closable={{ "aria-label": "Custom Close Button" }}
open={isModalOpen}
onOk={() => setIsModalOpen(false)}
onCancel={() => setIsModalOpen(false)}
// afterOpenChange 是 Modal 打开/关闭动画结束后触发的回调
afterOpenChange={(open) => {
if (open) {
window.setTimeout(() => searchInputRef.current?.focus(), 0);
}
}}
>
<Input ref={searchInputRef} placeholder="输入会话名称或关键词" />
</Modal>
</>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
+ // 引入MenuProps属性
+ import type { InputRef, MenuProps } from "antd";

+ //新增函数引入,对收缩的方法传入
interface TalkListProps {
+ conversations: ConversationSummary[];
+ activeConversationId: string | null;
+ loading: boolean;
+ onNewConversation: () => void;
+ onSelectConversation: (conversationId: string) => void;
}

+ //预设聊天的数据类型,后续续会加入一个type引用
+ type ConversationSummary = {
+ id: string;
+ title: string;
+ };

+ //拿到菜单组件的需求,主要是做标识检测报错
+ type MenuItem = Required<MenuProps>["items"][number];

+ // 对前端传来的数据装入TalkListProps进行核验
export default function TalkList({
+ conversation,
+ activeConversationId,
+ loading,
+ onNewConversation,
+ onSelectConversation,
}: TalkListProps) {
+ const items: MenuItem[] = [
+ {
+ key: "new",
+ label: "新建对话",
+ icon: <MailOutlined />,
+ },
+ { type: "divider" },
+ {
+ key: "history",
+ label: "最近",
+ type: "group",
+ children: conversations.length
+ ? conversations.map((conversation) => ({
+ key: conversation.id,
+ label: conversation.title,
+ icon: <HistoryOutlined />,
+ }))
+ : [
+ {
+ key: "empty",
+ label: loading ? "正在加载…" : "暂无历史会话",
+ disabled: true,
+ },
+ ],
+ },
+ ];
+ const historyContent = conversations.length ? (
+ <div className={styles.historyList}>
+ {conversations.map((conversation) => (
+ <Button
+ block
+ className={styles.historyItem}
+ data-active={conversation.id === activeConversationId}
+ key={conversation.id}
+ type="text"
+ onClick={() => onSelectConversation(conversation.id)}
+ >
+ {conversation.title}
+ </Button>
+ ))}
+ </div>
+ ) : (
+ <Empty
+ className={styles.emptyHistory}
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
+ description={"暂无历史会话"}
+ />
+ );
+ return (
+ <>
+ <div className={styles.talkListHome}>
+ <div className={styles.talkListContent}>
+ {collapsed ? (
+ <div className={styles.collapsedList}>
+ <Tooltip
+ title="新建对话"
+ placement="right"
+ align={{ offset: [25, 0] }}
+ mouseEnterDelay={0.25}
+ >
+ <Button
+ aria-label="新建对话"
+ className={styles.collapsedActionButton}
+ icon={<MailOutlined />}
+ shape="circle"
+ onClick={onNewConversation}
+ />
+ </Tooltip>
+ <Popover
+ title="历史对话"
+ content={historyContent}
+ placement="rightTop"
+ trigger="click"
+ >
+ <Tooltip
+ title="历史对话"
+ placement="right"
+ align={{ offset: [25, 0] }}
+ mouseEnterDelay={0.25}
+ >
+ <Button
+ aria-label="历史对话"
+ className={styles.collapsedActionButton}
+ icon={<HistoryOutlined />}
+ shape="circle"
+ />
+ </Tooltip>
+ </Popover>
+ </div>
+ ) : (
+ <Menu
+ className={styles.menu}
+ mode="inline"
+ items={items}
+ selectedKeys={activeConversationId ? [activeConversationId] : []}
+ onClick={({ key }) => {
+ if (key === "new") {
+ onNewConversation();
+ } else if (key !== "empty") {
+ onSelectConversation(key);
+ }
+ }}
+ />
+ )}
+ </div>
+ </div>
+ </>
+ );
+ }

这一步只完成 TalkList 的 Footer,不接登录接口。用户名先通过 Props 传入,将来只需要更换 username 的来源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+ import { Avatar } from "antd";

interface TalkListProps {
+ username?: string;
}

+ // 本步骤新增:Footer 只负责显示用户,不负责查询用户。
- <div className={styles.talkListFooter}>Footer</div>
+ {username ? (
+ <div className={styles.userPanel} title={username}>
+ <Avatar className={styles.userAvatar}>
+ {username.charAt(0).toUpperCase()}
+ </Avatar>
+ <span className={styles.userName}>{username}</span>
+ </div>
+ ) : null}

头像内容使用 username.charAt(0).toUpperCase(),因此用户名 mepha 会显示为 M。Footer 不需要知道用户来自假数据、Cookie 还是数据库,它只接收字符串。

TalkList 完成后再整理数据边界

前面的 Content 步骤把假数据暂时写进了组件,是为了先观察 Menu。现在 Header、Content 和 Footer 都已完成,应把假数据提升到 page.tsx,使 TalkList 最终只负责显示。

先创建 lib/types.ts,保留以后接口仍然会使用的数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 静态开发阶段先约定数据形状;接入接口后继续复用这些类型。
export type ConversationSummary = {
id: string;
title: string;
};

export type RagSource = {
chunkId: string;
section: string;
};

export type ChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
sources: RagSource[];
createdAt: string;
};

export type AuthUser = {
id: string;
username: string;
};

这里定义类型并不代表接口已经实现。它只是规定:会话摘要至少需要 id 和 title;消息需要角色、正文、引用来源和创建时间。假数据与以后接口返回值都应遵守同一形状。
完成后在TalkList页面引入该type文件后删除自定义的ConversationSummary属性。

完成后的 TalkListProps 为:

1
2
3
4
5
6
7
8
9
10
interface TalkListProps {
collapsed: boolean;
conversations: ConversationSummary[];
activeConversationId: string | null;
loading: boolean;
username?: string;
onToggleCollapsed: () => void;
onNewConversation: () => void;
onSelectConversation: (conversationId: string) => void;
}

这些 Props 可以分成三类:

  • 显示数据:conversations、activeConversationId、username;
  • 显示状态:collapsed、loading;
  • 操作入口:onToggleCollapsed、onNewConversation、onSelectConversation。

在 page.tsx 中先准备本地数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+ import type {ConversationSummary,} from "./lib/types";

+ // 静态阶段使用假数据代替 GET /api/conversations 的返回值。
+ const mockConversations: ConversationSummary[] = [
+ { id: "conversation-1", title: "第一个对话" },
+ { id: "conversation-2", title: "关于 React 的讨论" },
+ { id: "conversation-3", title: "RAG 检索测试" },
+ ];

+ const mockUsername = "mepha";

+ //同时记得
export default function Home() {
+ const [collapsed, setCollapsed] = useState(false);
+ // 设定选中对话的状态,设定为null或者字符串。
+ const [activeConversationId, setActiveConversationId] = useState<
+ string | null
+ >(null);
return ()

引入子组件import TalkList from "./components/talkList";,然后传给 TalkList:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<TalkList
collapsed={collapsed}
conversations={mockConversations}
activeConversationId={activeConversationId}
loading={false}
username={mockUsername}
onToggleCollapsed={() => {
setCollapsed((current) => !current);
}}
onNewConversation={() => {
setActiveConversationId(null);
}}
onSelectConversation={setActiveConversationId}
/>

这里的点击行为只是修改本地状态,没有任何网络请求。它让我们能够验证:选中不同历史会话时 Menu 的选中项是否变化,点击“新建对话”时选中状态是否清空。

最后进行样式美化,先是在对于我们的TalkList页面,我们需要添加一些样式属性,这里主要是应对侧边菜单搜索后的渲染。

1
2
// 这里新加了data-collapsed={collapsed}>以便收缩时可以进行样式绑定。
<div className={styles.talkListHome} data-collapsed={collapsed}>

TalkList 的 CSS 按职责分组,而不是按照标签名称随意排列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
/* TalkList 根容器:在 Sider 内纵向组织顶部、会话列表和底部用户区 */
.talkListHome {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}

/* 侧栏顶部区域:排列 Mechat 品牌名、搜索按钮和收缩按钮 */
.talkListHeader {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 42px;
gap: 10px;
margin-bottom: 20px;
}

/* Mechat 品牌名称:控制字号、字重和收缩动画 */
.brand {
min-width: 0;
overflow: hidden;
color: #f8fafc;
font-size: 24px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.5px;
white-space: nowrap;
transition: opacity 0.2s ease, width 0.2s ease;
}

/* 侧栏顶部操作按钮容器:排列搜索按钮和收缩按钮 */
.sidebarActions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}

/* 搜索按钮和收缩按钮的默认外观 */
.iconButton:global(.ant-btn) {
width: 42px;
height: 42px;
min-width: 42px;
flex-shrink: 0;
padding: 0;
color: #cbd5e1;
background: rgb(255 255 255 / 8%);
border-color: rgb(255 255 255 / 12%);
border-radius: 50%;
box-shadow: none;
}

/* 搜索按钮和收缩按钮的鼠标悬停效果 */
.iconButton:global(.ant-btn):hover {
color: #ffffff;
background: rgb(255 255 255 / 16%);
border-color: rgb(255 255 255 / 22%);
}

/* 收缩状态的顶部区域:将品牌名和两个操作按钮改为纵向排列 */
.talkListHome[data-collapsed="true"] .talkListHeader {
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}

/* 收缩状态的品牌名:保持 M 与圆形按钮使用相同的占位尺寸 */
.talkListHome[data-collapsed="true"] .brand {
display: flex;
flex: 0 0 42px;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
opacity: 1;
}

/* 收缩状态的顶部操作区:让搜索和展开按钮纵向居中排列 */
.talkListHome[data-collapsed="true"] .sidebarActions {
flex-direction: column;
align-items: center;
gap: 10px;
}

/* 会话列表区域:占据侧栏剩余空间,内容过多时独立滚动 */
.talkListContent {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #526174 transparent;
}

/* 展开状态的对话菜单:通过 Ant Design 菜单变量统一文字、背景和选中颜色 */
.menu:global(.ant-menu) {
/* Ant Design Menu 组件的主题变量:分别控制默认、悬停、选中和分组标题状态 */
--ant-menu-item-color: #cbd5e1 !important;
--ant-menu-item-hover-color: #ffffff !important;
--ant-menu-item-selected-color: #ffffff !important;
--ant-menu-item-bg: transparent !important;
--ant-menu-item-hover-bg: rgb(255 255 255 / 9%) !important;
--ant-menu-item-selected-bg: #3b82f6 !important;
--ant-menu-group-title-color: #8291a5 !important;

width: 100%;
color: #d9e2ec;
background: transparent;
border-inline-end: 0;
}

/* 展开状态的菜单项:统一普通菜单项与子菜单标题的高度、间距和圆角 */
.menu :global(.ant-menu-item),
.menu :global(.ant-menu-submenu-title) {
height: 42px;
margin: 4px 0;
color: #cbd5e1;
line-height: 42px;
border-radius: 10px;
}

/* 展开状态的菜单项悬停效果:提高文字亮度并显示半透明背景 */
.menu :global(.ant-menu-item:hover),
.menu :global(.ant-menu-submenu-title:hover) {
color: #ffffff;
background: rgb(255 255 255 / 9%);
}

/* 当前会话对应的选中菜单项:使用蓝色背景突出正在查看的会话 */
.menu :global(.ant-menu-item-selected) {
color: #ffffff;
background: #3b82f6;
}

/* 历史会话分组标题:控制“最近”等分组名称的间距和弱化文字样式 */
.menu :global(.ant-menu-item-group-title) {
padding: 18px 12px 6px;
color: #8291a5;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}

/* 菜单项图标:继承当前菜单项颜色,使默认、悬停和选中状态保持同步 */
.menu :global(.ant-menu-item .anticon),
.menu :global(.ant-menu-submenu-title .anticon) {
color: currentColor;
font-size: 16px;
}

/* 侧栏收缩后的操作区:将“新建对话”和“历史对话”按钮纵向居中排列 */
.collapsedList {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 100%;
}

/* 侧栏收缩后的圆形操作按钮:统一尺寸、颜色、边框和背景 */
.collapsedActionButton:global(.ant-btn.ant-btn-circle) {
width: 42px;
min-width: 42px;
height: 42px;
padding: 0;

color: #cbd5e1;
background: rgb(255 255 255 / 8%);
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 50%;
box-shadow: none;
}

/* 收缩状态圆形按钮的悬停效果:与侧栏顶部图标按钮保持一致 */
.collapsedActionButton:global(.ant-btn.ant-btn-circle):hover {
color: #ffffff;
background: rgb(255 255 255 / 16%);
border-color: rgb(255 255 255 / 22%);
}

/* 历史对话 Popover 内容区:限制宽度和高度,记录过多时允许独立滚动 */
.historyList {
display: flex;
flex-direction: column;
gap: 4px;
width: 220px;
max-height: 320px;
overflow-y: auto;
}

/* Popover 中的单条历史会话按钮:单行显示,过长标题使用省略号 */
.historyItem:global(.ant-btn) {
height: 38px;
padding-inline: 12px;
overflow: hidden;
color: #334155;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 8px;
}

/* 历史会话按钮的悬停效果:用浅蓝背景提示当前指向的记录 */
.historyItem:global(.ant-btn):hover {
color: #1d4ed8;
background: #eff6ff;
}

/* 当前会话对应的历史按钮:根据 data-active 属性显示持续选中状态 */
.historyItem:global(.ant-btn)[data-active="true"] {
color: #1d4ed8;
background: #dbeafe;
}

/* 历史记录为空或正在加载时的 Empty 区域:与历史列表保持相同宽度 */
.emptyHistory {
width: 220px;
margin-block: 12px;
}

/* 左下角用户身份区域:固定显示头像首字符和用户名 */
.userPanel {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 10px;
min-height: 58px;
margin-top: 12px;
padding: 12px 6px 0;
color: #e2e8f0;
border-top: 1px solid rgb(255 255 255 / 10%);
}

/* 用户头像:使用统一的品牌蓝色突出当前登录身份 */
.userAvatar:global(.ant-avatar) {
flex: 0 0 auto;
color: #ffffff;
font-weight: 700;
background: #3b82f6;
}

/* 用户名:超出侧栏宽度时省略显示 */
.userName {
min-width: 0;
overflow: hidden;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}

/* 收缩状态的用户信息:仅保留头像并水平居中 */
.talkListHome[data-collapsed="true"] .userPanel {
justify-content: center;
padding-inline: 0;
}

/* 收缩状态的用户名:空间不足时隐藏文字 */
.talkListHome[data-collapsed="true"] .userName {
display: none;
}

同时在 page.module.css 中补全 Sider 自身的背景和内部高度:

1
2
3
4
5
6
7
8
9
10
11
12
.sidebar:global(.ant-layout-sider) {
padding: 18px 14px;
overflow: hidden;
background: #182230;
}

.sidebar :global(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}

然后设定page页面的父组件收缩前后的状态。

1
2
3
4
5
6
7
8
<Sider
trigger={null}
collapsible
collapsed={collapsed}
width={272}
collapsedWidth={76}
className={styles.sidebar}
>

到这里 TalkList 已经完成。它能够展示假会话、切换选中状态、收缩、弹出历史记录和显示用户。

3. ChatPanel 的拆分

右侧区域继续按照 Header、Content、Footer 的顺序实现。我们一步步实现。

先创建 app/components/chatPanel.tsx以及chatPanel.module.css,然后初始化它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Layout } from "antd";
import styles from "./chatPanel.module.css";

const { Header, Content, Footer } = Layout;

interface ChatPanelProps {
}

export default function ChatPanel() {
return (
<Layout className={styles.chatLayout}>
<Header className={styles.header}><div className="text-white">Hello</div></Header>
<Content>Content</Content>
<Footer>Footer</Footer>
</Layout>
);
}

然后我们在page页面import并使用它。

1
2
3
4
5
6
7
8
+ import ChatPanel from "./components/chatPanel";

- <Layout>
- <Header className={styles.header}>Header</Header>
- <Content className={styles.content}>Content</Content>
- <Footer className={styles.footer}>Footer</Footer>
- </Layout>
+ <ChatPanel />

此时只检查右侧是否占满页面。

Content 需要展示四种互斥状态:错误、加载、空会话和消息列表。先增加 Props:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//引入所需要的组件
import { Layout, Alert, Spin, Typography, Button, Space, Input } from "antd";
//引入已定义的类型
import type { ChatMessage } from "@/app/lib/types";

// 本步骤新增:Content 所需的数据全部由父组件传入。
interface ChatPanelProps {
title: string;
username?: string;
messages: ChatMessage[];
error: string;
messagesLoading: boolean;
sending: boolean;
onDismissError: () => void;
}

再替换 Content 占位内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<Content className={styles.content}>
<div className={styles.messageColumn}>
{/* 判断是否加载错误 */}
{error ? (
<Alert type="error" message={error} closable onClose={onDismissError} />
) : null}
{/* 是否在加载数据,历史为空则显示新建对话信息 */}
{messagesLoading ? (
<Spin description="正在加载历史消息" />
) : messages.length === 0 ? (
<div className={styles.emptyState}>
<Typography.Title level={2}>你好,我是 Mechat</Typography.Title>
<Typography.Paragraph>
新建一个对话,或者从左侧选择历史会话。
</Typography.Paragraph>
</div>
) : (
<div className={styles.messageList}>
{messages.map((message) => (
<article
className={styles.messageRow}
data-role={message.role}
key={message.id}
>
<div className={styles.messageBubble}>
<div className={styles.messageRole}>
{message.role === "user" ? username : "Mechat"}
</div>
<div className={styles.messageContent}>{message.content}</div>
</div>
</article>
))}
</div>
)}
</div>
</Content>

最后增加受控输入框需要的 Props:

1
2
3
4
5
6
7
// 本步骤新增:Footer 只发出输入变化和发送事件,不负责 fetch。
interface ChatPanelProps {
// 前面的 Props 省略
messageInput: string;
onMessageInputChange: (value: string) => void;
onSendMessage: () => void;
}

将 Footer 占位内容替换为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<Footer className={styles.footer}>
<Space.Compact className={styles.composer}>
<Input
className={styles.messageInput}
placeholder="需要做点什么?"
value={messageInput}
disabled={sending}
onChange={(event) => onMessageInputChange(event.target.value)}
onPressEnter={onSendMessage}
/>
<Button
type="primary"
className={styles.sendButton}
loading={sending}
disabled={!messageInput.trim()}
onClick={onSendMessage}
>
发送
</Button>
</Space.Compact>
</Footer>

到这里页面编写完成,但最后需要去page父组件加入参数传入才能看到效果。

完成后的 ChatPanelProps 为:

1
2
3
4
5
6
7
8
9
10
11
12
interface ChatPanelProps {
title: string;
username?: string;
messages: ChatMessage[];
messageInput: string;
error: string;
messagesLoading: boolean;
sending: boolean;
onDismissError: () => void;
onMessageInputChange: (value: string) => void;
onSendMessage: () => void;
}

在 page.tsx 中准备按会话 ID 分类的假消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 静态阶段模拟 GET /api/conversations/{id}/messages 的结果。
const mockMessages: Record<string, ChatMessage[]> = {
"conversation-1": [
{
id: "message-1",
role: "user",
content: "你好,请介绍一下这个项目。",
sources: [],
createdAt: "2026-09-24T09:00:00.000Z",
},
{
id: "message-2",
role: "assistant",
content: "这是一个知识库问答项目。当前页面仍使用本地假数据。",
sources: [{ chunkId: "mock-chunk-1", section: "项目介绍 > 技术栈" }],
createdAt: "2026-09-24T09:00:01.000Z",
},
],
};

然后在 page.tsx 中新增需要用到的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const [collapsed, setCollapsed] = useState(false);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [activeConversationId, setActiveConversationId] = useState<
string | null
>(null);
const [sending, setSending] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
const [pageError, setPageError] = useState("");
const [messageInput, setMessageInput] = useState("");
const activeTitle = activeConversationId
? (conversations.find((item) => item.id === activeConversationId)?.title ??
"历史对话")
: "新对话";
const currentMessages = activeConversationId
? mockMessages[activeConversationId]
: [];

父组件中的传入的参数入下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<ChatPanel
title={activeTitle}
username={user?.username}
messages={messages}
messageInput={messageInput}
error={pageError}
messagesLoading={messagesLoading}
sending={sending}
onDismissError={() => undefined}
onMessageInputChange={setMessageInput}
onSendMessage={() => {
// 静态阶段只验证事件,第四部分再替换成真实请求。
console.log("准备发送的本地输入:", messageInput);
}}
/>

ChatPanel 只决定“怎样显示”,page.tsx 决定“当前给它什么数据”。这种组件也常被称为展示组件。

ChatPanel 的 CSS 重点是三层高度关系:整个 ChatPanel 固定为视口高度,Header 和 Footer 使用自身高度,中间 Content 获得剩余空间并独立滚动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.chatLayout:global(.ant-layout) {
min-width: 0;
min-height: 0;
height: 100dvh;
overflow: hidden;
}

.content:global(.ant-layout-content) {
min-width: 0;
min-height: 0;
padding: 28px;
overflow-y: auto;
background: #f6f8fb;
}

.messageRow {
display: flex;
justify-content: flex-start;
}

.messageRow[data-role="user"] {
justify-content: flex-end;
}

.footer:global(.ant-layout-footer) {
display: flex;
justify-content: center;
padding: 16px 24px 20px;
background: #f6f8fb;
}

到这里,静态页面阶段完成。我们能够观察以下效果:

  • Sider 展开和收缩;
  • Menu 与 Popover 中的假历史会话;
  • 左下角用户信息;
  • 切换假会话后更新右侧标题和消息;
  • 空会话、普通消息、RAG 引用位置;
  • 输入框、发送按钮及其禁用状态。

四、页面功能与请求接口

静态阶段已经把数据类型、组件 Props 和页面状态准备好了,但使用了本地数据来源,这里我们将本地数据来源替换为请求获取数据即可,我们的全部请求如下:

1
2
3
4
5
6
7
8
将本地用户信息修改为向后端获取用户凭证
mockUsername → GET /api/auth/session
获得用户信息后向后端获取用户对话标题加载
mockConversations → GET /api/conversations
选中历史对话后向后端获取该对话的详细内容
mockMessages → GET /api/conversations/{conversationId}/messages
发送信息给后端,由后端传向AI
console.log → POST /api/chat

1. 登录状态恢复

首先删掉本地的假数据

1
const mockUsername = "mepha";

设定上整体的登录流程逻辑是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
浏览器携带本地保存的 Session Cookie
↓
请求 /api/auth/session
↓
服务器验证 Cookie 中的会话令牌
┌──────┴──────┐
↓ ↓
验证通过 验证失败/不存在
↓ ↓
返回用户信息 返回 user: null
↓ ↓
更新 user 状态 显示登录弹窗
↓
加载该用户的历史会话

所以在页面第一次加载时请求:

1
GET /api/auth/session

如果 Cookie 中存在有效会话,则恢复用户并继续加载历史会话;否则显示强制登录弹窗,具体方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 页面加载时检查并恢复登录状态
useEffect(() => {
// 标记当前 Effect 是否已经失效
let cancelled = false;

const restoreSession = async () => {
try {
// 请求服务器验证浏览器中保存的 Session
const response = await fetch("/api/auth/session", {
cache: "no-store",
});

// 解析服务器返回的用户信息
const data = await responseJson<{
user?: AuthUser | null;
}>(response);

// Effect 有效且用户已登录时,恢复用户并加载历史会话
if (!cancelled && data.user) {
setUser(data.user);
await loadConversations();
}
} catch {
// Effect 仍然有效时,显示检查失败信息
if (!cancelled) {
setPageError("登录状态检查失败,请刷新页面重试。");
}
} finally {
// 标记登录状态检查已经结束
if (!cancelled) {
setAuthReady(true);
}
}
};

// 不使用其返回的 Promise
void restoreSession();

// 组件卸载或 Effect 重跑时,使旧请求结果失效
return () => {
cancelled = true;
};
}, [loadConversations]);
// loadConversations 引用变化时重新检查

然后我们设置一个通用的 JSON 响应解析函数,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 这是我们设定的后端返回错误回馈
type ApiError = {
error?: string;
};

const [authReady, setAuthReady] = useState(false);
const [user, setUser] = useState<AuthUser | null>(null);


// 这其中的<T>泛型参数,可以在调用时定义。
// response: Response表示参数必须是 fetch() 返回的 Response 对象
async function responseJson<T>(response: Response) {
return (await response.json()) as T & ApiError;
}
//T & ApiError是在说返回结果除了任意泛型参数T,也可能包含错误反馈。

登录 Modal 使用以下设置禁止绕过:

1
2
3
4
5
6
7
<Modal
open={open}
closable={false}
keyboard={false}
mask={{ closable: false }}
footer={null}
/>

弹窗打开后使用 Ref 聚焦用户名:

1
2
3
4
5
6
7
const usernameRef = useRef<InputRef>(null);

afterOpenChange={(isOpen) => {
if (isOpen) {
window.setTimeout(() => usernameRef.current?.focus(), 0);
}
}}

2. 加载历史会话

登录成功后请求历史对话,流程为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
调用 loadConversations()
↓
开启历史记录加载状态
↓
请求 /api/conversations
↓
解析服务器返回的数据
↓
检查 HTTP 状态码
┌────┴────┐
↓ ↓
成功 失败
↓ ↓
保存会话 抛出错误
↓ ↓
└────┬────┘
↓
关闭加载状态

请求方法为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//使用异步函数传入conversationId:
// useCallback 用来定义并保存一个函数,但不会自动调用
const loadConversations = useCallback(async () => {
// 开启历史会话加载状态,一种渲染状态传递
setHistoryLoading(true);

try {
// 请求历史会话接口,不使用缓存数据
const response = await fetch("/api/conversations", {
cache: "no-store",
});

// 解析接口返回的会话数据
const data = await responseJson<{
conversations?: ConversationSummary[];
}>(response);

// 请求失败时,抛出后端返回的错误信息
if (!response.ok) {
throw new Error(data.error ?? "历史会话加载失败。");
}

// 保存历史会话;没有数据时使用空数组
setConversations(data.conversations ?? []);
} catch (reason) {
// 捕获请求、解析或接口状态错误
setPageError(
reason instanceof Error ? reason.message : "历史会话加载失败。",
);
} finally {
// 无论成功或失败,都结束加载状态
setHistoryLoading(false);
}
}, []); // 保持函数引用稳定

后端返回当前用户的会话摘要,前端保存到 conversations,TalkList 再把它渲染成菜单。

题外话,对于这个方法来说,它在登陆后检查一次组件卸载情况和登录用户信息后触发,但并不能应对组件卸载后异步请求返回的情况,考虑到本项目难以出现频繁的组件卸载且请求返回速度较快不做额外考量,但建议以后处理得更加严谨。

选择历史会话时请求:

1
GET /api/conversations/{conversationId}/messages

方法为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 选择一个历史会话,并加载该会话的消息
const selectConversation = async (conversationId: string) => {
// 开启消息加载状态,并清除之前的错误提示
setMessagesLoading(true);
setPageError("");

try {
// 根据会话 ID 请求对应的历史消息
const response = await fetch(
`/api/conversations/${conversationId}/messages`,
{ cache: "no-store" },
);

// 解析接口返回的消息数组
const data = await responseJson<{
messages?: ChatMessage[];
}>(response);

// 请求失败时抛出错误
if (!response.ok) {
throw new Error(data.error ?? "历史消息加载失败。");
}

// 保存当前会话 ID 和对应的历史消息
setActiveConversationId(conversationId);
setMessages(data.messages ?? []);
} catch (reason) {
// 保存请求或解析过程中产生的错误
setPageError(
reason instanceof Error ? reason.message : "历史消息加载失败。",
);
} finally {
// 无论成功或失败,都结束加载状态
setMessagesLoading(false);
}
};

请求成功后同时更新:

1
2
setActiveConversationId(conversationId);
setMessages(data.messages ?? []);

3. 发送消息

发送前先创建一条临时用户消息,使用户点击发送后马上看到自己的内容:

1
2
3
4
5
6
7
8
9
const optimisticMessage: ChatMessage = {
id: `local-${Date.now()}`,
role: "user",
content,
sources: [],
createdAt: new Date().toISOString(),
};

setMessages((current) => [...current, optimisticMessage]);

随后调用:

1
POST /api/chat

方法主体为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// 发送用户消息并获取 AI 回答
const sendMessage = async () => {
// 去除输入内容首尾的空格
const content = messageInput.trim();

// 内容为空、正在发送或用户未登录时,停止执行
if (!content || sending || !user) {
return;
}

// 创建一条本地用户消息,让页面立即显示发送内容
const optimisticMessage: ChatMessage = {
id: `local-${Date.now()}`,
role: "user",
content,
sources: [],
createdAt: new Date().toISOString(),
};

// 显示用户消息、清空输入框并开启发送状态
setMessages((current) => [...current, optimisticMessage]);
setMessageInput("");
setSending(true);
setPageError("");

try {
// 向聊天接口提交消息和当前会话 ID
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: content,
// 新会话没有 ID,因此不传;旧会话则继续使用原 ID
conversationId: activeConversationId ?? undefined,
}),
});

// 解析接口返回的会话 ID、AI 回答和引用来源
const data = await responseJson<{
conversationId?: string;
answer?: string;
sources?: RagSource[];
}>(response);

// 登录状态失效时,清除用户状态并要求重新登录
if (response.status === 401) {
setUser(null);
throw new Error("登录状态已失效,请重新登录。");
}

// 接口失败或缺少必要数据时抛出错误
if (!response.ok || !data.answer || !data.conversationId) {
throw new Error(data.error ?? "回答生成失败。");
}

// 保存本次聊天对应的会话 ID
setActiveConversationId(data.conversationId);

// 将 AI 回答添加到当前消息列表
setMessages((current) => [
...current,
{
id: `assistant-${Date.now()}`,
role: "assistant",
content: data.answer,
sources: data.sources ?? [],
createdAt: new Date().toISOString(),
},
]);

// 重新加载左侧历史会话列表
await loadConversations();
} catch (reason) {
// 保存发送或回答生成过程中的错误
setPageError(
reason instanceof Error ? reason.message : "回答生成失败。",
);
} finally {
// 无论成功或失败,都结束发送状态
setSending(false);
}
};

逻辑设计上,如果当前没有 conversationId,后端会创建新会话;如果已经打开历史会话,则继续向该会话添加消息。

1
2
3
4
body: JSON.stringify({
message: content,
conversationId: activeConversationId ?? undefined,
});

这些就是本项目涉及的全部接口,后续可能会更新?

五、Next.js 后端的基本结构

虽然这是一个 React 项目,但 Next.js 不只负责页面,它可以同时负责前后端。而对于后端来说,我们首先需要定义后端接口,官方称之为Route Handler。
我们将接口放在app/api 下,这里它类似于传统后端的Controller角色,其中的route.ts 会运行在服务端,可以访问 Cookie、环境变量、PostgreSQL、Chroma 和 Ollama。

当前接口为:

1
2
3
4
5
6
7
8
app/api/
├── auth/
│ ├── login/route.ts POST 登录
│ └── session/route.ts GET 当前登录状态
├── conversations/
│ ├── route.ts GET 会话列表
│ └── [conversationId]/messages/route.ts GET 会话消息
└── chat/route.ts POST 发送问题

此外,我们还需要设置服务器函数,比如数据检验,登录鉴权,连接数据库的设置以及Langchain的提示词模板相关配置,这类似于传统后端的Service角色。

1
2
3
4
5
lib/server/
├── auth.ts Cookie 与登录校验
├── db.ts PostgreSQL 连接池和事务
├── conversations.ts 会话、消息查询与保存
└── rag.ts Chroma 检索、提示词和 Ollama

对于这些拓展性的后端文件,我们需要引入服务器包进行服务端边界保护,保证它们只能被服务端代码导入,不能被客户端组件导入:

1
import "server-only";

此时如果误把它们导入客户端组件,Next.js 会在构建阶段报错。

1.登录接口

从前端来看,登录的流程在于,加载完页面用当前已有的Cookie查询Session是否对能对应已有用户,否则让用户输入用户名密码登录。这里的登录功能由login/route.ts完成。由session/route.ts完成已有Session的核验。

在此时开发环境,我们将用户名和密码存入本地环境,配置在 .env.local:

1
2
3
4
5
DEMO_USER_ID=demo-mepha
DEMO_USERNAME=your-name
DEMO_PASSWORD=your-local-password
SESSION_SECRET=至少32位的随机字符串
SESSION_COOKIE_SECURE=false

注意不要把真实密码提交到 Git。项目通过 .gitignore 忽略 .env.local,只提交不含真实信息的 .env.example。

首先是login\route.ts,负责登录账号查验,签发Session,使用Post方法,内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// Zod 是运行时数据校验库
import { z } from "zod";

// 这里从 auth.ts 导入三个服务端函数
// 分别用于检查用户名和密码是否正确,获取演示用户的信息,创建登录凭证并写入 Session Cookie
import {
createSession,
getDemoUser,
verifyDemoCredentials,
} from "@/lib/server/auth";

// 验证前端请求体必须满足的格式。
const loginSchema = z.object({
username: z.string().trim().min(1).max(80),
password: z.string().min(1).max(200),
});

// POST决定它接收的 HTTP 请求方法
export async function POST(request: Request) {
try {
// 先把请求体从json转为JS对象,safeParse()是Zod下的设置,它可以避免参数校验失败而直接抛出异常,而是返回一个结果对象。
const parsed = loginSchema.safeParse(await request.json());

// 对不同失败的处理
// 参数不合法时
if (!parsed.success) {
return Response.json(
{ error: "请输入用户名和密码。" },
{ status: 400 },
);
}

// 检查是否于服务端存在记录一致
if (!verifyDemoCredentials(parsed.data.username, parsed.data.password)) {
return Response.json(
{ error: "用户名或密码错误。" },
{ status: 401 },
);
}

// 成功后回去对象并 创建登录 Session
const user = getDemoUser();
await createSession(user);

return Response.json({ user });
} catch (error) {
console.error("登录失败", error);
return Response.json(
{ error: "登录服务配置不完整。" },
{ status: 500 },
);
}
}

session/route.ts负责从已有Session中返回用户信息到前端,使用Get方法:

1
2
3
4
5
6
import { getSessionUser } from "@/lib/server/auth";

// 因为此方法返回JS对象格式内容,传向前端时转换为json格式
export async function GET() {
return Response.json({ user: await getSessionUser() });
}

对于登录功能的逻辑处理函数,lib\server\auth.ts设计如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
// 这个模块只能被服务端代码导入
import "server-only";


// Node.js 的加密工具
import { timingSafeEqual } from "node:crypto";
// 这是 Next.js 服务端 Cookie API,用来读取请求携带的 Cookie以及向响应写入 Cookie
import { cookies } from "next/headers";
// jose 用于生成和验证 JWT。
import { jwtVerify, SignJWT } from "jose";
// 引入所需的类型
import type { AuthUser } from "@/lib/types";

const SESSION_COOKIE = "mechat_session";
// 定义 Cookie 的有效期
const SESSION_MAX_AGE = 60 * 60 * 24 * 7;

// 从服务器端本地获取 JWT 签名密钥
function getSessionSecret() {
const value = process.env.SESSION_SECRET;

if (!value || value.length < 32) {
throw new Error("SESSION_SECRET 必须至少包含 32 个字符。");
}
// 环境变量读取出来是字符串,但 jose 签名需要字节数据,因此用 TextEncoder 转换
return new TextEncoder().encode(value);
}

// 安全比较用户输入和服务器记录,使用稳定的比较时间,减少攻击者通过响应时间推测内容的可能性
function safeEqual(actual: string, expected: string) {
// 先转成 Node.js 的二进制 Buffer
const actualBuffer = Buffer.from(actual);
const expectedBuffer = Buffer.from(expected);

return (
// 先检查长度再检查内容
actualBuffer.length === expectedBuffer.length &&
timingSafeEqual(actualBuffer, expectedBuffer)
);
}

// 检验后登录
export function verifyDemoCredentials(username: string, password: string) {
// 获取本地环境变量的用户名和密码
const expectedUsername = process.env.DEMO_USERNAME;
const expectedPassword = process.env.DEMO_PASSWORD;

// 没有则报错
if (!expectedPassword) {
throw new Error("缺少 DEMO_PASSWORD 环境变量。");
}

// 安全比较后返回结果
return (
safeEqual(username, expectedUsername) &&
safeEqual(password, expectedPassword)
);
}

// 获得用户信息
export function getDemoUser(): AuthUser {
return {
id: process.env.DEMO_USER_ID,
username: process.env.DEMO_USERNAME,
};
}

// 创建已登录用户Session
export async function createSession(user: AuthUser) {
// 创建 JWT,并把用户名放入载荷
const token = await new SignJWT({ username: user.username })
// 签名算法+写入内容+签发时间+有效期+使用密钥
.setProtectedHeader({ alg: "HS256" })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime("7d")
.sign(getSessionSecret());

const cookieStore = await cookies();
// 设置是否允许通过 HTTP 发送Cookie,由Secure 属性决定
const secureCookie =
process.env.SESSION_COOKIE_SECURE === "true" ||
(process.env.NODE_ENV === "production" &&
process.env.SESSION_COOKIE_SECURE !== "false");

// 写入Cookie。
cookieStore.set(SESSION_COOKIE, token, {
// httpOnly: true:前端 JavaScript 不能读取
// secure: true:只通过 HTTPS 发送
// sameSite: "lax":限制跨站请求携带 Cookie
// maxAge:保存7天
// path: "/":整个网站接口都可以收到
httpOnly: true,
secure: secureCookie,
sameSite: "lax",
maxAge: SESSION_MAX_AGE,
path: "/",
});
}

// 从 Session 中恢复用户,
export async function getSessionUser(): Promise<AuthUser | null> {
// 于Cookie中找到session的value
const token = (await cookies()).get(SESSION_COOKIE)?.value;
// 不存在返回null
if (!token) {
return null;
}

try {
// 存在则验证jwt并用算法解密,获得payload对象
const { payload } = await jwtVerify(token, getSessionSecret(), {
algorithms: ["HS256"],
});

// 检查类型是否正确
if (!payload.sub || typeof payload.username !== "string") {
return null;
}
// 若正确则直接使用该用户信息查询
return {
id: payload.sub,
username: payload.username,
};
} catch {
return null;
}
}

// 获取当前登录用户信息,否则报错
export async function requireSessionUser() {
const user = await getSessionUser();

if (!user) {
throw new UnauthorizedError();
}

return user;
}

// 未授权报错
export class UnauthorizedError extends Error {
constructor() {
super("请先登录。");
this.name = "UnauthorizedError";
}
}

注意,这里的JWT解密从Session获取用户信息是一种无状态Session,也就是解密后直接使用其中存入的用户信息查询,在开发环境中可以简化流程设计,但在生产环境中建议使用有状态Session来保证安全查询。

2.会话加载接口

登录完成后,按照我们前端的流程,就应该由已登录用户信息查询该用户的对话情况了。在左侧菜单,我们会先加载该用户下的对话信息,但只查询对话title和对话id。查询后将数据加载到左侧子菜单,用户点击后由对话id查询对话的具体内容。

这里的子菜单查询创建功能由conversations\route.ts完成,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import {
requireSessionUser,
UnauthorizedError,
} from "@/lib/server/auth";

import { listConversations } from "@/lib/server/conversations";

export async function GET() {
try {
// 先验证已登录用户Session
// 否则throw错误直接跳到Catch
const user = await requireSessionUser();
// 然后请求对话列表
const conversations = await listConversations(user.id);
return Response.json({ conversations });
} catch (error) {
// 如果是登录信息错误则返回...
if (error instanceof UnauthorizedError) {
return Response.json({ error: error.message }, { status: 401 });
}
// 否则返回...
console.error("读取会话列表失败", error);
return Response.json(
{ error: "无法连接 PostgreSQL,请检查 DATABASE_URL 和数据库服务。" },
{ status: 503 },
);
}
}

而对于对话详细信息加载的conversations\[conversationId]\route.js的设计如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import {
requireSessionUser,
UnauthorizedError,
} from "@/lib/server/auth";

import { getConversationMessages } from "@/lib/server/conversations";


export async function GET(
// 参数解析conversationId,params 是 Promise 使用并通过 await 读取
{ params }: { params: Promise<{ conversationId: string }> },
) {
try {
// 获取已登录用户信息
const user = await requireSessionUser();
// 解析当前请求的聊天Id
const { conversationId } = await params;
// 查询该Id下聊天具体内容
const messages = await getConversationMessages(user.id, conversationId);
// 若查询结果为null 或 undefined
if (!messages) {
return Response.json({ error: "没有找到该会话。" }, { status: 404 });
}
// 否则返回信息
return Response.json({ messages });
} catch (error) {
// 错误处理
if (error instanceof UnauthorizedError) {
return Response.json({ error: error.message }, { status: 401 });
}

console.error("读取会话消息失败", error);
return Response.json(
{ error: "读取历史消息失败,请检查 PostgreSQL 服务。" },
{ status: 503 },
);
}
}

对应的服务器函数lib\server\conversations.ts为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
import "server-only";
// 随机ID,用于生成会话ID
import { randomUUID } from "node:crypto";
// PoolClient 用于 PostgreSQL 连接池中数据库连接
import type { PoolClient } from "pg";

import type {
ChatMessage,
ConversationSummary,
RagSource,
} from "@/lib/types";
// 导入数据库工具
import { query, withTransaction } from "@/lib/server/db";
// 定义数据库行类型
type ConversationRow = {
id: string;
title: string;
created_at: Date;
updated_at: Date;
};
// 消息数据库行
type MessageRow = {
id: string;
role: "user" | "assistant";
content: string;
sources: RagSource[] | null;
created_at: Date;
};
// 定义发送给模型所需的信息类型
export type StoredHistoryMessage = {
role: "user" | "assistant";
content: string;
};

// 转换会话数据
function toConversation(row: ConversationRow): ConversationSummary {
// 将数据库数据类型转成前端需要的类型
return {
id: row.id,
title: row.title,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}
// 转换消息数据
function toMessage(row: MessageRow): ChatMessage {
return {
id: String(row.id),
role: row.role,
content: row.content,
sources: Array.isArray(row.sources) ? row.sources : [],
createdAt: row.created_at.toISOString(),
};
}
// 查询会话列表,传入用户id
export async function listConversations(userId: string) {
// $1用于替换userId,这是参数化查询,可以防止SQL注入
// 最多返回100个会话,避免一次性加载无限数据
const result = await query<ConversationRow>(
`SELECT id, title, created_at, updated_at
FROM conversations
WHERE user_id = $1
ORDER BY updated_at DESC
LIMIT 100`,
[userId],
);
// 对每一条数据库记录执行 toConversation()转换
return result.rows.map(toConversation);
}

// 查询某个会话的消息,传入用户和会话Id
export async function getConversationMessages(
userId: string,
conversationId: string,
) {
// 首先检查该会话归属
const owner = await query<{ id: string }>(
`SELECT id
FROM conversations
WHERE id = $1 AND user_id = $2`,
[conversationId, userId],
);
// 不属于该用户返回null
if (owner.rowCount === 0) {
return null;
}
// 否则查询内容,按照消息写入顺序排序
const result = await query<MessageRow>(
`SELECT id, role, content, sources, created_at
FROM messages
WHERE conversation_id = $1 AND user_id = $2
ORDER BY id ASC`,
[conversationId, userId],
);
// 最后做格式转换返回
return result.rows.map(toMessage);
}
// 最近历史消息,这是给AI提供上下文使用,上下文长度可以自行调节。
export async function getRecentHistory(
userId: string,
conversationId: string,
limit = 16,
): Promise<StoredHistoryMessage[] | null> {
// 查询该会话归属
const owner = await query<{ id: string }>(
`SELECT id
FROM conversations
WHERE id = $1 AND user_id = $2`,
[conversationId, userId],
);
// 不属于该用户返回null
if (owner.rowCount === 0) {
return null;
}
// 否则查询最近消息,先取最新16条消息,再按照时间顺序重排
const result = await query<StoredHistoryMessage>(
`SELECT role, content
FROM (
SELECT id, role, content
FROM messages
WHERE conversation_id = $1 AND user_id = $2
ORDER BY id DESC
LIMIT $3
) recent_messages
ORDER BY id ASC`,
[conversationId, userId, limit],
);
// 返回数据
return result.rows;
}
// 生成会话标题,新会话以用户首消息生成标题
// 主要包括删除回车空格限制字数
function createTitle(message: string) {
const normalized = message.replace(/\s+/g, " ").trim();
return normalized.length > 30
? `${normalized.slice(0, 30)}…`
: normalized || "新对话";
}
// 保存信息用于确定会话所有权并锁定
// PoolClient用于确保在一个连接中完成这些事务
async function assertConversationOwner(
client: PoolClient,
conversationId: string,
userId: string,
) {
// 确定会话所有权
const result = await client.query(
`SELECT id
FROM conversations
WHERE id = $1 AND user_id = $2
FOR UPDATE`,
[conversationId, userId],
);
// 否则报错
if (result.rowCount === 0) {
throw new Error("找不到该会话,或当前用户没有访问权限。");
}
}

// 保存一轮会话
export async function saveExchange({
userId,
conversationId,
userMessage,
assistantMessage,
sources,
}: {
userId: string;
conversationId?: string;
userMessage: string;
assistantMessage: string;
sources: RagSource[];
}) {
// 确定会话 ID,没有就随机生成
const resolvedConversationId = conversationId ?? randomUUID();
// 开启数据库事务
// 这里的client 是 withTransaction() 从连接池借出的同一条连接,事务中的所有操作必须全部成功,否则回滚
await withTransaction(async (client) => {
if (conversationId) {
// 确定会话拥有着
await assertConversationOwner(client, conversationId, userId);
} else {
// 如果没有传入会话Id则新建会话信息
await client.query(
`INSERT INTO conversations (id, user_id, title)
VALUES ($1, $2, $3)`,
[resolvedConversationId, userId, createTitle(userMessage)],
);
}
// 写入用户会话信息
await client.query(
`INSERT INTO messages (conversation_id, user_id, role, content)
VALUES ($1, $2, 'user', $3)`,
[resolvedConversationId, userId, userMessage],
);
// 写入AI会话信息,并保存来源,sources是JS数组,需要转成Json
await client.query(
`INSERT INTO messages (
conversation_id,
user_id,
role,
content,
sources
)
VALUES ($1, $2, 'assistant', $3, $4::jsonb)`,
[
resolvedConversationId,
userId,
assistantMessage,
JSON.stringify(sources),
],
);
// 最后更新会话时间
await client.query(
`UPDATE conversations
SET updated_at = NOW()
WHERE id = $1`,
[resolvedConversationId],
);
});
// 事务结束返回会话Id
return resolvedConversationId;
}

其完整流程如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
      新会话
没有 conversationId
↓
生成 UUID
↓
开启事务
↓
创建 conversations 记录
↓
保存用户消息
↓
保存 AI 消息和 RAG 来源
↓
更新会话时间
↓
提交事务
↓
返回新 conversationId


旧会话
已有 conversationId
↓
开启事务
↓
检查会话是否属于当前用户
↓
锁定会话记录
↓
保存用户消息
↓
保存 AI 消息和 RAG 来源
↓
更新会话时间
↓
提交事务
↓
返回原 conversationId

聊天发送接口

最后是聊天发送功能的后端接口,主要处理从前端发回的POST请求,将用户的信息进行处理返回AI回复。

这段 chat/route.ts 是整个聊天流程的接口入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import { z } from "zod";

import {
requireSessionUser,
UnauthorizedError,
} from "@/lib/server/auth";
import {
getRecentHistory,
saveExchange,
} from "@/lib/server/conversations";
// 引入RAG功能
import { answerWithRag } from "@/lib/server/rag";
// 指定 Node.js 运行时
export const runtime = "nodejs";

// 定义请求内容格式
const chatSchema = z.object({
message: z.string().trim().min(1).max(4_000),
conversationId: z.uuid().optional(),
});

// POST接口
export async function POST(request: Request) {
try {
// 获取当前登录用户信息
const user = await requireSessionUser();
// 校验内容数据格式
const parsed = chatSchema.safeParse(await request.json());
// 失败显示
if (!parsed.success) {
return Response.json(
{ error: "消息为空、过长或会话 ID 格式不正确。" },
{ status: 400 },
);
}
// 成功返回内容
const { message, conversationId } = parsed.data;
// 判断是新的聊天还是旧的聊天,并获取最近内容
const history = conversationId
? await getRecentHistory(user.id, conversationId)
: [];
// 无法找到最近聊天返回404
if (history === null) {
return Response.json({ error: "没有找到该会话。" }, { status: 404 });
}
// 否则调用RAG做出回答
const result = await answerWithRag(message, history);
// 回答后保存本轮对话
const resolvedConversationId = await saveExchange({
userId: user.id,
conversationId,
userMessage: message,
assistantMessage: result.answer,
sources: result.sources,
});
// 保存后向前端返回回答内容和来源
return Response.json({
conversationId: resolvedConversationId,
answer: result.answer,
sources: result.sources,
});
} catch (error) {
// 未验证用户错误
if (error instanceof UnauthorizedError) {
return Response.json({ error: error.message }, { status: 401 });
}
// 否则提示聊天失败打印错误原因。
console.error("聊天请求失败", error);
const isDatabaseError =
error instanceof Error &&
/DATABASE_URL|ECONNREFUSED|PostgreSQL|database/i.test(error.message);
return Response.json(
{
error: isDatabaseError
? "无法连接 PostgreSQL,请先启动数据库并执行 npm run db:migrate。"
: "生成回答失败,请检查阿里云百炼配置、Chroma 和服务器日志。",
},
{ status: 503 },
);
}
}

其他

最后是对应的RAG模板,按照Langchain的chat模板进行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
import "server-only";
// Chroma-向量数据库
import { ChromaClient, type Metadata } from "chromadb";
// LangChain 消息类型
import { AIMessage, HumanMessage } from "@langchain/core/messages";
// LangChain 提示词模板工具
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
// 大模型嵌入接口
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import type { RagSource } from "@/lib/types";
import type { StoredHistoryMessage } from "@/lib/server/conversations";
// 定义chunk检索结果类型
type RetrievedChunk = {
chunkId: string;
section: string;
source: string;
content: string;
distance: number | null;
rerankScore: number;
};
// 阿里云API接口入口
const dashScopeBaseUrl =
process.env.DASHSCOPE_BASE_URL ??
"https://dashscope.aliyuncs.com/compatible-mode/v1";
// 从本地环境中读取api_key
function getDashScopeApiKey() {
const apiKey = process.env.DASHSCOPE_API_KEY;
// 没有则报错
if (!apiKey) {
throw new Error(
"缺少 DASHSCOPE_API_KEY,请在服务器环境变量中配置阿里云百炼 API Key。",
);
}

return apiKey;
}
// 创建文本嵌入向量模型,可以自定义模型型号于环境变量
function createEmbeddings() {
return new OpenAIEmbeddings({
model:
process.env.DASHSCOPE_EMBEDDING_MODEL ??
"qwen3.7-text-embedding-flash",
apiKey: getDashScopeApiKey(),
encodingFormat: "float",
// 失败时重试次数
maxRetries: 1,
configuration: {
baseURL: dashScopeBaseUrl,
},
});
}
// 创建聊天模型,可以自定义模型型号于环境变量
function createChatModel() {
return new ChatOpenAI({
model: process.env.DASHSCOPE_CHAT_MODEL ?? "qwen-plus",
apiKey: getDashScopeApiKey(),
// 温度用于定义回答稳定性,但不是所有模型都有
// 低t更倾向选择高概率词,输出相对稳定
// 高t更多低概率词被选中,输出更有变化
temperature: 0,
maxRetries: 1,
useResponsesApi: false,
configuration: {
baseURL: dashScopeBaseUrl,
},
});
}
// 创建 Chroma 客户端,连接 Chroma 服务
const chromaClient = new ChromaClient({
host: process.env.CHROMA_HOST ?? "127.0.0.1",
port: Number(process.env.CHROMA_PORT ?? 8000),
ssl: process.env.CHROMA_SSL === "true",
});
// 系统提示词
const SYSTEM_PROMPT = `你是 Mechat,一个知识库问答助手。

回答规则:
1. 优先根据“RAG 参考资料”回答,并准确理解资料的章节和内容。
2. 不要编造参考资料中不存在的出处。
3. 如果参考资料不足以判断或回答,允许使用模型自身已有知识,但回答必须先明确写出:
“无法根据知识库内容回答,请提示管理员更新知识库。以下是模型自有数据回答内容:”
4. 如果模型自身也无法确认,应直接说明无法确认,不要猜测。
5. 回答保持清晰、简洁,并结合已有对话历史理解追问。`;
// 创建提示词模板
const prompt = ChatPromptTemplate.fromMessages([
["system", SYSTEM_PROMPT],
new MessagesPlaceholder("history"),
[
"human",
`RAG 参考资料:
{context}

当前问题:
{query}`,
],
]);
// 安全读取 Metadata 字段,因为它不一定存在,也不一定是字符串,只有字符串才返回,否则返回空字符串。
function metadataText(metadata: Metadata | null, key: string) {
const value = metadata?.[key];
return typeof value === "string" ? value : "";
}
// 对检索结果二次排序,依据 Chroma 结果和可能的问题,这依据RAG知识库内容进行针对优化设计,这里是一个简单的示例
function rerankChunk(
query: string,
content: string,
section: string,
distance: number | null,
) {
let score = distance ?? 10;
const asksForLocation = /(哪里|哪儿|位置|位于|什么地方)/.test(query);

if (asksForLocation && content.includes("位于")) {
score -= 0.22;
}
if (asksForLocation && section.includes("世界观")) {
score -= 0.12;
}
if (asksForLocation && section.includes("登场角色")) {
score += 0.08;
}

return score;
}
// 查询 Chroma,根据用户问题,默认最终返回4个 Chunk。
async function retrieveChunks(query: string, limit = 4) {
// 连接 Chroma 中指定的 Collection
const collection = await chromaClient.getCollection({
name: process.env.CHROMA_COLLECTION ?? "wiki_knowledge_v1",
});
// 将问题转换成向量
const queryEmbedding = await createEmbeddings().embedQuery(query);
// 查询 Chroma
const result = await collection.query({
queryEmbeddings: [queryEmbedding],
nResults: 20,
});
// 取出 Chroma 返回结果
const ids = result.ids[0] ?? [];
const documents = result.documents?.[0] ?? [];
const metadatas = result.metadatas?.[0] ?? [];
const distances = result.distances?.[0] ?? [];
// 转换为统一的 Chunk 类型,根据每个 ID 及其索引读取对应数据
const chunks: RetrievedChunk[] = ids.map((id, index) => {
const metadata = metadatas[index] ?? null;
const content = documents[index] ?? "";
const section = metadataText(metadata, "section");
const distance = distances[index] ?? null;
// 最后返回
return {
chunkId: metadataText(metadata, "chunk_id") || id,
section,
source: metadataText(metadata, "source"),
content,
distance,
rerankScore: rerankChunk(query, content, section, distance),
};
});
// 使用 Map,以 chunkId 为键去重。
const uniqueChunks = new Map<string, RetrievedChunk>();

// 对去重,按照 rerankScore 从小到大排列
for (const chunk of chunks.sort((a, b) => a.rerankScore - b.rerankScore)) {
if (chunk.content && !uniqueChunks.has(chunk.chunkId)) {
uniqueChunks.set(chunk.chunkId, chunk);
}
}
// 最后将 Map 转回数组,并保留前4条。
return [...uniqueChunks.values()].slice(0, limit);
}
// 格式化 RAG 上下文消息
function formatContext(chunks: RetrievedChunk[]) {
if (chunks.length === 0) {
return "当前检索没有返回可用的知识库资料。";
}

return chunks
.map(
(chunk, index) => `[资料 ${index + 1}]
章节:${chunk.section || "未知"}
来源:${chunk.source || "未知"}
内容:
${chunk.content}`,
)
.join("\n\n");
}
// 格式化历史消息,转成ai模板格式
function formatHistory(history: StoredHistoryMessage[]) {
return history.map((message) =>
message.role === "user"
? new HumanMessage(message.content)
: new AIMessage(message.content),
);
}
// 模型返回内容处理,如果是字符串直接返回,内容块数组则逐项处理
function responseText(content: unknown) {
if (typeof content === "string") {
return content;
}

if (Array.isArray(content)) {
return content
.map((item) => {
if (typeof item === "string") {
return item;
}
if (
item &&
typeof item === "object" &&
"text" in item &&
typeof item.text === "string"
) {
return item.text;
}
return "";
})
.join("");
}

return String(content ?? "");
}
// RAG 主函数,整个流程串联
export async function answerWithRag(
query: string,
history: StoredHistoryMessage[],
) {
// 检索结果初始化
let chunks: RetrievedChunk[] = [];

try {
// 尝试检索
chunks = await retrieveChunks(query);
} catch (error) {
// 检索不成功时
console.error("Chroma 检索失败,将使用模型自身知识回答。", error);
}
// 检索后组合提示词并调用模型
// pipe()会创建一个新的 Runnable 顺序,前一步输出会作为后一步输入
const response = await prompt.pipe(createChatModel()).invoke({
// 格式化后的 RAG 资料
context: formatContext(chunks),
// 格式化后的历史对话。
history: formatHistory(history),
// 问题
query,
});
// 整理引用来源
const sources: RagSource[] = chunks.map((chunk) => ({
// 其中返回前端的来源不包含Chunk正文,只返回必要信息
chunkId: chunk.chunkId,
section: chunk.section,
source: chunk.source,
distance: chunk.distance,
}));
// 返回答案和来源给前端
return {
answer: responseText(response.content),
sources,
};
}

数据库连接函数server\db.ts如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import "server-only";
// pg 是 Node.js 的 PostgreSQL 驱动
import { Pool, type PoolClient, type QueryResultRow } from "pg";
// 给 globalThis(全局对象)增加连接池字段,主要是保存并复用原来的连接池
const globalForPostgres = globalThis as typeof globalThis & {
jobTalkerPool?: Pool;
};
// 创建数据库连接池
function createPool() {
// 使用本地环境变量连接
const connectionString = process.env.DATABASE_URL;

if (!connectionString) {
throw new Error("缺少 DATABASE_URL 环境变量。");
}
// 创建并返回连接池信息
return new Pool({
connectionString,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
}
// 获取连接池
export function getPool() {
// 第一次创建时jobTalkerPool 不存在,此时创建并保存到 globalThis
if (!globalForPostgres.jobTalkerPool) {
globalForPostgres.jobTalkerPool = createPool();
}
// 已存在直接调用
return globalForPostgres.jobTalkerPool;
}
// 普通查询函数
export async function query<T extends QueryResultRow>(
text: string,
values: unknown[] = [],
) {
return getPool().query<T>(text, values);
}
// 事务函数
export async function withTransaction<T>(
// 接收一个异步回调函数,client 是从连接池中取出的一条固定连接
callback: (client: PoolClient) => Promise<T>,
) {
// 从当前连接池获取连接
const client = await getPool().connect();
// "BEGIN"开启事务,告知数据库从现在开始的 SQL 属于同一个事务
try {
await client.query("BEGIN");
// 把这条事务连接交给业务代码
const result = await callback(client);
// 完成后提交事务
await client.query("COMMIT");
// 回调的返回值返回给调用者
return result;
} catch (error) {
// 错误时回滚并报错
await client.query("ROLLBACK");
throw error;
} finally {
// 事务是否完成都释放该连接
client.release();
}
}

六、PostgreSQL 保存会话

1. 数据职责

PostgreSQL 不负责向量检索,当前只保存业务关系:

1
2
3
用户 user_id
└── 会话 conversation_id
└── 消息 message_id

当前没有 users 表,因为账号仍来自环境变量。但 conversations 和 messages 都保留 user_id,以后接入正式用户表时可以继续扩展。

2. 表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CREATE TABLE IF NOT EXISTS conversations (
id UUID PRIMARY KEY,
user_id TEXT NOT NULL,
title VARCHAR(120) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS messages (
id BIGSERIAL PRIMARY KEY,
conversation_id UUID NOT NULL
REFERENCES conversations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
role VARCHAR(16) NOT NULL
CHECK (role IN ('user', 'assistant')),
content TEXT NOT NULL,
sources JSONB NOT NULL DEFAULT '[]'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

sources 使用 JSONB 保存本轮回答引用的 Chunk 信息。删除会话时,ON DELETE CASCADE 会同时删除所属消息。

3. 配置和迁移

1
DATABASE_URL=postgresql://postgres:数据库密码@127.0.0.1:5432/mechat

如果密码中含有 @,连接字符串中需要写成 %40。

创建数据库后运行:

1
npm run db:migrate

迁移脚本曾出现 Node.js 24 无法从 CommonJS 包具名导入 @next/env 的问题。错误写法:

1
import { loadEnvConfig } from "@next/env";

修正为:

1
2
3
import nextEnv from "@next/env";

const { loadEnvConfig } = nextEnv;

4. 历史消息与事务

历史对话只取最近 16 条消息,先按 ID 倒序限制数量,再恢复为正序:

1
2
3
4
5
6
7
8
9
SELECT role, content
FROM (
SELECT id, role, content
FROM messages
WHERE conversation_id = $1 AND user_id = $2
ORDER BY id DESC
LIMIT 16
) recent_messages
ORDER BY id ASC;

保存一轮对话时使用事务:

1
2
3
4
5
6
BEGIN
创建或锁定会话
写入用户消息
写入助手消息和引用
更新会话时间
COMMIT

任何一步失败就 ROLLBACK,避免只保存用户问题却没有 AI 回答。

七、LangChain 基础学习记录

这一部分保留从单独脚本验证模型的学习过程。需要注意:Python 练习用于理解 LangChain 和离线知识库构建,网页运行时使用的是 Next.js 中的 LangChain.js;两者不会同时抢占页面逻辑。

1. 安装环境

建议使用 Conda 隔离 Python 包:

1
2
conda create -n langchain python=3.12
conda activate langchain

安装练习和 RAG 处理需要的包:

1
2
3
pip install -U langchain langchain-openai langchain-ollama
pip install -U langchain-chroma langchain-text-splitters chromadb
pip install -U beautifulsoup4 lxml

-U 是 --upgrade,表示安装或升级到符合依赖条件的较新版本。不要在系统 Python 和 Conda 环境中混装同一套依赖;可以用下面的命令确认当前解释器:

1
2
where.exe python
python -m pip --version

2. 调用第三方聊天模型

阿里云提供 OpenAI 兼容接口,因此可以使用 ChatOpenAI。qwen-max 虽然属于大语言模型,但兼容接口暴露的是 Chat Completions 协议,因此应使用聊天模型客户端,而不是由类名推断模型能力。

1
2
3
4
5
6
7
8
9
10
11
import os
from langchain_openai import ChatOpenAI

model = ChatOpenAI(
model="qwen-max",
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

response = model.invoke("你好")
print(response.content)

3. 调用本地 Ollama

1
2
3
4
5
6
7
8
9
from langchain_ollama import ChatOllama

model = ChatOllama(
model="qwen3:8b",
base_url="http://127.0.0.1:11434",
)

response = model.invoke("你好")
print(response.content)

模型名必须与 ollama list 完全一致,qwen3:8b 不能写成 qwen:8b 或 qwen3-8b。

4. invoke 与 stream

invoke() 等待一轮调用完成后返回结果;stream() 迭代接收模型输出片段。流式输出不会让模型本身生成得更快,但用户可以更早看到内容。

1
2
3
4
response = model.stream("你好")

for chunk in response:
print(chunk.content, end="", flush=True)

需要注意:Python 中能够 stream() 不等于网页已经实现 SSE。当前网页 /api/chat 仍然在回答完成后一次性返回 JSON,真正的浏览器流式响应是后续功能。

5. LLM、Chat 与 Embedding

模型和接口可以这样理解:

  • LLM/Completion 接口通常接收一个完整字符串并返回续写文本;
  • Chat 接口用 system、human、ai 等角色组织消息,更适合聊天和多轮上下文;
  • Embedding 模型不生成自然语言回答,只把文本转换成向量。

Completion 与 Chat 在“能做什么”上有重合,很多任务都可以互相模拟;主要区别是服务商开放的 API 协议以及组织输入的便利程度,而不是 Chat 模型就不属于大语言模型。

1
2
3
4
5
6
7
8
9
10
11
12
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI

messages = [
SystemMessage(content="你是一个有帮助的助手。"),
HumanMessage(content="你好"),
AIMessage(content="有什么能帮你的?"),
HumanMessage(content="请总结我们的对话。"),
]

for chunk in model.stream(messages):
print(chunk.content, end="", flush=True)

在 ChatPromptTemplate.from_messages() 中还可以使用元组简写:

1
2
3
4
5
messages = [
("system", "你是一个有帮助的助手。"),
("human", "你好"),
("ai", "有什么能帮你的?"),
]

元组只是消息的简便表示方法,不代表模型自动获得永久记忆。每次调用时仍然需要把所需历史消息传入。

6. Embedding 与余弦相似度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import math
import os
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(
model="text-embedding-v4",
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
dimensions=1024,
check_embedding_ctx_length=False,
)

query = "我在睡觉"
documents = ["我在休息", "旺仔水饺", "徐州地方"]

query_vector = embeddings.embed_query(query)
document_vectors = embeddings.embed_documents(documents)

def cosine_similarity(vector_a, vector_b):
dot_product = sum(a * b for a, b in zip(vector_a, vector_b))
norm_a = math.sqrt(sum(a * a for a in vector_a))
norm_b = math.sqrt(sum(b * b for b in vector_b))

if norm_a == 0 or norm_b == 0:
return 0.0

return dot_product / (norm_a * norm_b)

for document, vector in zip(documents, document_vectors):
print(document, cosine_similarity(query_vector, vector))

这里的相似度只表示向量空间中的接近程度,不等同于事实正确率。向量数据库可能使用余弦距离、L2 距离等不同度量,因此 Chroma 返回的 distance 也不能不看配置就直接解释成余弦相似度。

7. PromptTemplate

LangChain 模板的主要作用是标准化变量注入和消息结构。使用哪一种模板不会直接改变模型参数,也不会让模型真实变成 zero-shot 或 few-shot;是否包含示例、上下文和角色规则取决于最终拼出的提示词。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from langchain_core.prompts import PromptTemplate
from langchain_ollama import OllamaLLM

prompt = PromptTemplate(
input_variables=["query", "context"],
template=(
"根据以下内容回答问题。"
"如果无法从内容中得到答案,请回答:"
"根据提供的内容无法回答该问题。\n\n"
"内容:\n{context}\n\n"
"问题:\n{query}\n\n"
"回答:"
),
)

model = OllamaLLM(
model="qwen3:8b",
base_url="http://127.0.0.1:11434",
)

chain = prompt | model

response = chain.invoke({
"query": "四川的特色食物有哪些?",
"context": "四川的特色食物包括火锅、川菜和串串香。",
})

print(response)

提示词只能约束模型行为,不能从技术上保证模型百分之百不使用预训练知识。对高要求场景还需要引用检查、输出校验或拒答判断。

8. FewShotPromptTemplate

Few-shot 是在提示词中加入少量输入输出示例,让模型模仿格式或规律:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate
from langchain_ollama import OllamaLLM

examples = [
{"question": "苹果是什么?", "answer": "苹果是一种常见的水果。"},
{"question": "白菜是什么?", "answer": "白菜是一种常见的蔬菜。"},
]

example_prompt = PromptTemplate.from_template(
"问题:{question}\n回答:{answer}"
)

prompt = FewShotPromptTemplate(
examples=examples,
example_prompt=example_prompt,
prefix="请参考下面的示例,用相同方式回答。",
suffix="问题:{query}\n回答:",
input_variables=["query"],
example_separator="\n\n",
)

model = OllamaLLM(model="qwen3:8b")
chain = prompt | model
print(chain.invoke({"query": "西瓜是什么?"}))

FewShotPromptTemplate 只是方便组织示例;直接用普通字符串拼入相同示例,模型看到的内容也可以等价。

9. ChatPromptTemplate 与历史消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_ollama import ChatOllama

prompt = ChatPromptTemplate.from_messages([
("system", "你是一个简洁、友好的 AI 助手。"),
MessagesPlaceholder(variable_name="history"),
("human", "{query}"),
])

model = ChatOllama(model="qwen3:8b")
chain = prompt | model

history = [
HumanMessage(content="我叫小明,正在学习 LangChain。"),
AIMessage(content="好的,我记住了。"),
]

response = chain.invoke({
"history": history,
"query": "我叫什么,正在学习什么?",
})

print(response.content)

模型并没有自己保存“小明”这条信息,是程序在本次调用中重新传入了 history。网页项目也是从 PostgreSQL 读取最近消息后放入 MessagesPlaceholder。

10. Chain 的含义

1
chain = prompt | model

这里的管道操作符把上一个组件的输出作为下一个组件的输入:先用变量生成 Prompt,再把 Prompt 交给模型。Chain 不是一个额外运行的服务器,而是组织可运行组件的数据流。

七、知识库数据清洗

这一部分主要包含爬虫数据获取和数据清洗与分块.

八、完整启动顺序

1. PostgreSQL

确认 PostgreSQL 的 5432 端口已经启动,并完成:

1
npm run db:migrate

2. Ollama

1
E:\Code\Ollama\ollama.exe serve

确认模型:

1
ollama list

需要:

1
2
qwen3:8b
qwen3-embedding:0.6b

3. Chroma

1
2
3
4
& "D:\anaconda3\envs\langchain\Scripts\chroma.exe" run `
--path "E:\Work\react\RAG\Base\chroma_db" `
--host 127.0.0.1 `
--port 8000

4. Next.js

1
2
cd E:\Work\react\job-talker
npm run dev

访问:

1
http://localhost:3000

5. 验证

先询问知识库中明确存在的问题,回答完成后应检查:

  • 页面是否出现回答;
  • 是否能展开参考资料;
  • 左侧是否出现新会话;
  • 刷新后历史会话是否仍然存在;
  • 继续追问时是否能够结合历史消息;
  • PostgreSQL 中是否同时存在 user 和 assistant 消息。

最后运行工程检查:

1
2
npm run lint
npm run build