前言 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 : 100 dvh; min-height : 100 dvh; overflow : hidden; }
这里需要区分三个概念:
className={styles.pageLayout} 会把 CSS Module 中的局部类名绑定到组件根 DOM;
Ant Design 组件内部仍然存在 .ant-layout、.ant-btn 等全局类;
当需要限定“同时具有本地类与 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在页面渲染完成时获取数据,当然目前的静态阶段使用假数据渲染。
于是我们可以开始操作
第一步:初始化talkList页面 第二步:添加搜索按钮与弹窗 第三步:添加header其他组件 第四步:添加content组件 第五步:添加 Footer 用户信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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 import styles from "./talkList.module.css" ;import { useState, useRef } from "react" ;import { Modal , Tooltip , Button , Input } from "antd" ;import { SearchOutlined } from "@ant-design/icons" ;import type { InputRef } from "antd" ;interface TalkListProps { collapsed : boolean ; } export default function TalkList ({ collapsed }: TalkListProps ) { const [isModalOpen, setIsModalOpen] = useState (false ); 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 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 ; } 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 <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 .talkListHome { display : flex; flex-direction : column; height : 100% ; min-height : 0 ; } .talkListHeader { display : flex; align-items : center; justify-content : space-between; min-height : 42px ; gap : 10px ; margin-bottom : 20px ; } .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 ; } .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; } .menu :global (.ant-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% ); } .historyList { display : flex; flex-direction : column; gap : 4px ; width : 220px ; max-height : 320px ; overflow-y : auto; } .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 ; } .historyItem :global (.ant-btn)[data-active="true" ] { color : #1d4ed8 ; background : #dbeafe ; } .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 的顺序实现。我们一步步实现。
第一步:完成 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" ;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 interface ChatPanelProps { 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 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 : 100 dvh; 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 状态 显示登录弹窗 ↓ 加载该用户的历史会话
所以在页面第一次加载时请求:
如果 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 (() => { let cancelled = false ; const restoreSession = async ( ) => { try { const response = await fetch ("/api/auth/session" , { cache : "no-store" , }); const data = await responseJson<{ user ?: AuthUser | null ; }>(response); if (!cancelled && data.user ) { setUser (data.user ); await loadConversations (); } } catch { if (!cancelled) { setPageError ("登录状态检查失败,请刷新页面重试。" ); } } finally { if (!cancelled) { setAuthReady (true ); } } }; void restoreSession (); return () => { cancelled = true ; }; }, [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 );async function responseJson<T>(response : Response ) { return (await response.json ()) as T & ApiError ; }
登录 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 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 { 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 ?? "历史消息加载失败。" ); } 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 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 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 { const response = await fetch ("/api/chat" , { method : "POST" , headers : { "Content-Type" : "application/json" }, body : JSON .stringify ({ message : content, conversationId : activeConversationId ?? undefined , }), }); 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 ?? "回答生成失败。" ); } setActiveConversationId (data.conversationId ); 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
对于这些拓展性的后端文件,我们需要引入服务器包进行服务端边界保护,保证它们只能被服务端代码导入,不能被客户端组件导入:
此时如果误把它们导入客户端组件,Next.js 会在构建阶段报错。
1.登录接口 从前端来看,登录的流程在于,加载完页面用当前已有的Cookie查询Session是否对能对应已有用户,否则让用户输入用户名密码登录。这里的登录功能由login/route.ts完成。由session/route.ts完成已有Session的核验。
在此时开发环境,我们将用户名和密码存入本地环境,配置在 .env.local:
1 2 3 4 5 DEMO_USER_ID =demo-mephaDEMO_USERNAME =your-nameDEMO_PASSWORD =your-local-passwordSESSION_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 import { z } from "zod" ;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 ), }); export async function POST (request : Request ) { try { 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 }, ); } 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" ;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" ;import { timingSafeEqual } from "node:crypto" ;import { cookies } from "next/headers" ;import { jwtVerify, SignJWT } from "jose" ;import type { AuthUser } from "@/lib/types" ;const SESSION_COOKIE = "mechat_session" ;const SESSION_MAX_AGE = 60 * 60 * 24 * 7 ;function getSessionSecret ( ) { const value = process.env .SESSION_SECRET ; if (!value || value.length < 32 ) { throw new Error ("SESSION_SECRET 必须至少包含 32 个字符。" ); } return new TextEncoder ().encode (value); } function safeEqual (actual : string , expected : string ) { 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 , }; } export async function createSession (user : AuthUser ) { const token = await new SignJWT ({ username : user.username }) .setProtectedHeader ({ alg : "HS256" }) .setSubject (user.id ) .setIssuedAt () .setExpirationTime ("7d" ) .sign (getSessionSecret ()); const cookieStore = await cookies (); const secureCookie = process.env .SESSION_COOKIE_SECURE === "true" || (process.env .NODE_ENV === "production" && process.env .SESSION_COOKIE_SECURE !== "false" ); cookieStore.set (SESSION_COOKIE , token, { httpOnly : true , secure : secureCookie, sameSite : "lax" , maxAge : SESSION_MAX_AGE , path : "/" , }); } export async function getSessionUser ( ): Promise <AuthUser | null > { const token = (await cookies ()).get (SESSION_COOKIE )?.value ; if (!token) { return null ; } try { 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 { 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 ( { params }: { params: Promise <{ conversationId: string }> }, ) { try { const user = await requireSessionUser (); const { conversationId } = await params; const messages = await getConversationMessages (user.id , conversationId); 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" ;import { randomUUID } from "node:crypto" ;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 (), }; } export async function listConversations (userId : string ) { 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], ); return result.rows .map (toConversation); } 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], ); 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); } 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], ); if (owner.rowCount === 0 ) { return null ; } 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 || "新对话" ; } 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[]; } ) { const resolvedConversationId = conversationId ?? randomUUID (); await withTransaction (async (client) => { if (conversationId) { await assertConversationOwner (client, conversationId, userId); } else { 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], ); 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], ); }); 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" ; import { answerWithRag } from "@/lib/server/rag" ;export const runtime = "nodejs" ;const chatSchema = z.object ({ message : z.string ().trim ().min (1 ).max (4_000 ), conversationId : z.uuid ().optional (), }); 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) : []; if (history === null ) { return Response .json ({ error : "没有找到该会话。" }, { status : 404 }); } 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" ;import { ChromaClient , type Metadata } from "chromadb" ;import { AIMessage , HumanMessage } from "@langchain/core/messages" ;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" ;type RetrievedChunk = { chunkId : string ; section : string ; source : string ; content : string ; distance : number | null ; rerankScore : number ; }; const dashScopeBaseUrl = process.env .DASHSCOPE_BASE_URL ?? "https://dashscope.aliyuncs.com/compatible-mode/v1" ; 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 (), temperature : 0 , maxRetries : 1 , useResponsesApi : false , configuration : { baseURL : dashScopeBaseUrl, }, }); } 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}` , ], ]); function metadataText (metadata : Metadata | null , key : string ) { const value = metadata?.[key]; return typeof value === "string" ? value : "" ; } 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; } async function retrieveChunks (query : string , limit = 4 ) { const collection = await chromaClient.getCollection ({ name : process.env .CHROMA_COLLECTION ?? "wiki_knowledge_v1" , }); const queryEmbedding = await createEmbeddings ().embedQuery (query); const result = await collection.query ({ queryEmbeddings : [queryEmbedding], nResults : 20 , }); const ids = result.ids [0 ] ?? []; const documents = result.documents ?.[0 ] ?? []; const metadatas = result.metadatas ?.[0 ] ?? []; const distances = result.distances ?.[0 ] ?? []; 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), }; }); const uniqueChunks = new Map <string , RetrievedChunk >(); for (const chunk of chunks.sort ((a, b ) => a.rerankScore - b.rerankScore )) { if (chunk.content && !uniqueChunks.has (chunk.chunkId )) { uniqueChunks.set (chunk.chunkId , chunk); } } return [...uniqueChunks.values ()].slice (0 , limit); } 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" ); } 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 ?? "" ); } export async function answerWithRag ( query : string , history : StoredHistoryMessage [], ) { let chunks : RetrievedChunk [] = []; try { chunks = await retrieveChunks (query); } catch (error) { console .error ("Chroma 检索失败,将使用模型自身知识回答。" , error); } const response = await prompt.pipe (createChatModel ()).invoke ({ context : formatContext (chunks), history : formatHistory (history), query, }); const sources : RagSource [] = chunks.map ((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" ;import { Pool , type PoolClient , type QueryResultRow } from "pg" ;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 ( ) { 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>( callback : (client : PoolClient ) => Promise <T>, ) { const client = await getPool ().connect (); 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。
创建数据库后运行:
迁移脚本曾出现 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, contentFROM ( 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 osfrom langchain_openai import ChatOpenAImodel = 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 ChatOllamamodel = 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, SystemMessagefrom langchain_openai import ChatOpenAImessages = [ 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 mathimport osfrom langchain_openai import OpenAIEmbeddingsembeddings = 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 PromptTemplatefrom langchain_ollama import OllamaLLMprompt = 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, PromptTemplatefrom langchain_ollama import OllamaLLMexamples = [ {"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, HumanMessagefrom langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholderfrom langchain_ollama import ChatOllamaprompt = 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 的含义
这里的管道操作符把上一个组件的输出作为下一个组件的输入:先用变量生成 Prompt,再把 Prompt 交给模型。Chain 不是一个额外运行的服务器,而是组织可运行组件的数据流。
七、知识库数据清洗 这一部分主要包含爬虫数据获取和数据清洗与分块.
八、完整启动顺序 1. PostgreSQL 确认 PostgreSQL 的 5432 端口已经启动,并完成:
2. Ollama 1 E:\Code\Ollama\ollama.exe serve
确认模型:
需要:
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-talkernpm run dev
访问:
5. 验证 先询问知识库中明确存在的问题,回答完成后应检查:
页面是否出现回答;
是否能展开参考资料;
左侧是否出现新会话;
刷新后历史会话是否仍然存在;
继续追问时是否能够结合历史消息;
PostgreSQL 中是否同时存在 user 和 assistant 消息。
最后运行工程检查:
1 2 npm run lint npm run build