一、功能总览

1. 系统架构
┌────────────────────────── 前端 (my-vue-app) ──────────────────────────┐
│  Login.vue(邮箱验证码登录)                                          │
│      │ 登录成功 → 跳转                                                │
│      ▼                                                               │
│  Chat.vue(对话页面)                                                 │
│    ├── 左侧:历史记录菜单栏(queryHistoryMenu)                        │
│    ├── 右侧:SSE 流式聊天(EventSource)                              │
│    └── 底部:输入框 + 发送按钮                                         │
└──────────────────────────┬──────────────────────────────────────────┘
                           │ HTTP / SSE
                           ▼
┌────────────────────────── 后端 (stu_fastapi) ─────────────────────────┐
│  main.py ── 注册子路由 + 静态资源 + 跨域                              │
│    ├── users 模块    ── 发送邮件 / 验证码登录(Day6 升级)              │
│    ├── chat 模块     ── 对话聊天(SSE)/ 保存会话结果                   │
│    └── history 模块  ── 历史记录菜单 / 对话记录                        │
│  ai 模块(模型统一封装)                                               │
│    ├── LoadModel        ── 千问大模型 LLM                             │
│    ├── LoadEmbeddingModel ── BGE 向量化模型(含检索指令)              │
│    ├── LoadRerankerModel ── 重排序模型                                │
│    └── LoadChroma      ── Chroma 向量数据库连接                       │
│  create_data 模块                                                     │
│    └── LawDataBuild    ── 法律知识库构建脚本                           │
└──────────────────────────┬──────────────────────────────────────────┘
                           │
                           ▼
        MySQL(用户/历史记录)  Redis(验证码)  Chroma(向量库)  Ollama(意图识别小模型)
2. 对话业务流程图
用户输入问题
    │
    ▼
【ChatController.chat】 SSE 流式接口
    │
    ▼
【ChatService.chat】 核心业务
    ├── ① 判断 historyId:0 = 新对话,否则加载历史记录
    ├── ② 意图识别:Ollama 小模型判断问题是否与法律相关
    │       ├── 不相关 → 直接 LLM 回复(不检索知识库)
    │       └── 相关 → 进入 RAG 检索流程
    ├── ③ 检索流程(Day9 详讲):
    │       向量检索 top-10 → BM25 检索 top-10 → RRF 融合 → 重排序取 top-3
    ├── ④ 拼接提示词(历史记录 + 参考资料 + 问题)
    └── ⑤ LLM 流式生成 → 逐 token 返回前端
    │
    ▼
【前端 Chat.vue】 EventSource 逐字接收 → 渲染打字效果
    │
    ▼
【保存会话】 ChatService.save_conversation_result → MySQL history 表
    │
    ▼
【历史记录】 queryHistoryMenu 刷新左侧菜单栏
3. 涉及技术栈(相比 Day5-Day7 新增)
技术用途说明
Element PlusVue 3 UI 组件库卡片、表单、按钮、图标、消息提示,美化前端页面
EventSource前端 SSE 接收浏览器原生 API,监听服务器流式推送的数据
sessionStorage浏览器会话级存储登录后保存用户名,跳转页面后仍能读取
ChatOllama本地小模型调用通过 Ollama 启动的本地模型做意图识别(qwen3:1.7b)
LangChain LCEL 管道后端 RAG 流程编排RunnableParallel + RunnableLambda 串联检索
事务管理MySQL 数据一致性新增会话记录 commit/rollback + lastrowid
BGE 检索指令非对称检索优化query 端加"为这个句子生成表示以用于检索相关文章"前缀

二、小模型加载封装

1. .env 新增配置

新增 AI 相关配置:

# Ollama 本地小模型(意图识别用)
OLLAMA_MODEL_NAME="qwen3:1.7b"
OLLAMA_BASE_URL="http://localhost:11434"

三、法律知识库create_data/LawDataBuild.py

对话系统的数据基础是法律知识库create_data/LawDataBuild.py 负责把法条数据存入 Chroma 向量数据库,只需在开发阶段执行一次。

# create_data/LawDataBuild.py
import os
import sys
# 将项目根目录加入 sys.path,保证无论从哪个目录运行都能 import ai 包
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from dotenv import load_dotenv
from langchain_core.documents import Document
from langchain_chroma import Chroma

from ai import LoadEmbeddingModel

load_dotenv()

# 数据集路径:数据文件与脚本在同一目录,按脚本位置定位(不依赖运行目录)
database_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "法律数据集.txt")
# 向量数据库存储路径 / 集合名称:统一读 .env,与检索端保持一致
vector_database_path = os.getenv("CHROMA_PATH")
collection_name = os.getenv("COLLECTION_NAME")

# 按行读取:数据文件是"一行一条法条",直接每行作为一个 chunk,语义最完整
# 去掉空行,并用 dict 保序去重,避免重复法条重复入库
with open(database_path, encoding="utf-8") as f:
    lines = list(dict.fromkeys(line.strip() for line in f if line.strip()))

documents = [Document(page_content=line, metadata={"source": database_path}) for line in lines]
print(f"数据文件共 {len(documents)} 条法条")

# 关键:Chroma.from_documents 只会追加、不会覆盖。必须先删除旧 collection 再重建,
# 否则早期分割的旧长串 chunk 会一直残留,检索仍会命中旧数据。
client = Chroma(
    collection_name=collection_name,
    persist_directory=vector_database_path,
    embedding_function=LoadEmbeddingModel.load_embedding_model(),
)
client.delete_collection()
print("已删除旧 collection,开始重建...")

# 重建并入库
try:
    Chroma.from_documents(
        documents=documents,  # 数据:按行切分后的法条
        embedding=LoadEmbeddingModel.load_embedding_model(),  # 向量化模型
        persist_directory=vector_database_path,  # 存储路径
        collection_name=collection_name,  # 集合名称
        collection_metadata={"hnsw:space": "cosine"},  # 匹配规则
    )
    print("数据存入成功")
except Exception as e:
    print(f"数据存入失败:{e}")

关键点:

技巧作用
sys.path.insert把项目根目录加进模块搜索路径,保证脚本在任意目录都能 import ai
os.path.abspath(__file__)按脚本自身位置定位数据集,不依赖"在哪运行"
一行一条法条 = 一个 chunk法律文本语义完整,不需要再按长度切割,直接按行切分最合理
dict.fromkeys(...)用字典的 key 天然去重,同时保持插入顺序
client.delete_collection()⚠️ 必须先删除旧集合再重建

⚠️ 为什么必须删除旧 collection? Chroma.from_documents 只会追加、不会覆盖。如果之前用不同的分割方式(如 chunk_size=100)入库过,旧的长串 chunk 会一直残留在集合里,检索时仍会命中旧数据。所以每次重新构建知识库,务必先 delete_collection() 再重建,保证数据干净。


四、意图识别 — chat/utils/IntentionUtil.py

对话系统第一个关键决策:用户的问题到底要不要走 RAG 检索? 比如问"今天心情不好"(闲聊)就不需要检索法律知识库。这里用本地小模型(Ollama 启动的 qwen3:1.7b)做意图识别,成本低、速度快。

# chat/utils/IntentionUtil.py
import json
import re

from langchain_ollama import ChatOllama
import os
from dotenv import load_dotenv

load_dotenv()

def intention_recognition(question):
    llm = ChatOllama(
        model=os.getenv("OLLAMA_MODEL_NAME"),
        base_url=os.getenv("OLLAMA_BASE_URL"),
    )

    intention_prompt = """
        你是一个专业的法律领域意图识别专家。你的任务是判断用户的输入是否包含【法律相关】内容。

        # Definition: 什么是"法律相关"
        包括但不限于以下范畴:
        1. 法律法规咨询(刑法、民法、劳动法、婚姻法、知识产权等)
        2. 合同纠纷、违约责任、债务追讨
        3. 诉讼、仲裁、行政复议、报案流程
        4. 律师咨询、法律援助、公证遗嘱
        5. 交通事故/工伤/医疗事故的赔偿责任认定
        6. 消费者权益保护、维权投诉
        7. 公司合规、股权架构、破产清算

        # Definition: 什么是"不相关"
        1. 纯情感倾诉且未提及任何权益/纠纷/规则
        2. 纯粹的道德伦理讨论(不涉及法律评价)
        3. 日常生活闲聊、技术问题、娱乐八卦

        # Output Format
        仅输出一个JSON对象,不要包含任何其他解释文字:
        {
            "is_legal": True/False,
            "confidence": "high/medium/low",
        }

        # Examples
        User: "我和房东签了合同但他不退押金怎么办"
        Assistant: {"is_legal": true, "confidence": "high"}
        ...
    """

    rs = llm.invoke([
        {"role": "system", "content": intention_prompt},
        {"role": "user", "content": question}
    ])
    return parse_intention(rs.content)


def parse_intention(content):
    """
    把 Ollama 返回的文本稳健地解析成 {"is_legal": bool, "confidence": str}。
    Ollama 小模型偶尔会在 JSON 外包裹 ```json 代码块,或夹杂解释文字,
    直接 json.loads 会抛异常导致整个 SSE 流中断,这里做容错处理。
    """
    if not isinstance(content, str):
        content = str(content)
    # 去掉 markdown 代码块标记 ```json / ```
    content = re.sub(r"```(?:json)?", "", content, flags=re.IGNORECASE).strip()
    # 截取第一个 { 到最后一个 } 之间的内容
    start, end = content.find("{"), content.rfind("}")
    if start != -1 and end > start:
        content = content[start:end + 1]
    try:
        data = json.loads(content)
        if isinstance(data, dict) and "is_legal" in data:
            return data
    except Exception as e:
        print(f"意图识别 JSON 解析失败,原文:{content!r},错误:{e}")
    # 解析失败时保守兜底:按"法律相关"走 RAG 检索,保证问答流程不中断
    return {"is_legal": True, "confidence": "low"}

关键点:

概念说明
ChatOllamaLangChain 连接本地 Ollama 服务的加载器,base_url 指向 http://localhost:11434
意图识别 PromptFew-shot 设计:给出"法律相关/不相关"定义 + 正反例,模型按格式输出 JSON
is_legal 返回True = 法律相关走 RAG;False = 直接 LLM 回复
JSON 容错解析小模型可能输出 ```````json ````代码块或夹杂解释文字,做清洗后再 json.loads
保守兜底解析失败时返回 is_legal: True,宁可多检索也不让问答中断

为什么用本地小模型做意图识别? 意图识别只是"是/否"二分类判断,用 qwen3:1.7b 这类小模型就够用,调用千问大模型会浪费成本和时间。这也是 RAG 系统中常见的"路由"思想:先用轻量模型分流,再决定是否进入重检索流程。


五、对话聊天后端 — chat 模块【核心】

1. ChatController — SSE 流式聊天接口
# chat/controller/ChatController.py
import json
from fastapi import APIRouter
from starlette.responses import StreamingResponse
from chat.service import ChatService
from chat.entity.ConversationResultEntity import ConversationResultEntity

chat_router = APIRouter()


@chat_router.get(
    path="/chat",
    summary="聊天",
    description="""
        聊天
        访问路径:http://localhost:8001/chat/chat
        请求参数:
            question:用户问题
        返回值:
            流式输出 sse
    """
)
def chat(question: str, historyId: int):
    # 流式输出处理
    def generator():
        for chunk in ChatService.chat(question, historyId):
            yield f"data: {json.dumps({'content': chunk})}\n\n"
        yield f"data: {json.dumps({'content': '[DONE]'})}\n\n"

    return StreamingResponse(
        content=generator(),
        media_type="text/event-stream"
    )


@chat_router.post(
    path="/saveConversationResult",
    summary="保存会话结果",
    description="""
        保存会话结果
        访问路径:http://localhost:8001/chat/saveConversationResult
    """
)
def save_conversation_result(conversationResultEntity: ConversationResultEntity):
    return ChatService.save_conversation_result(conversationResultEntity)

关键点:

关键点说明
question + historyIdGET + k=v 传参。historyId=0 表示新对话,否则为已有会话 id
流式输出向前端发送chunk字段,chat聊天字段才如同流水一样打印
[DONE] 结束标记无论成功失败,最后都要发结束标记,前端才能 es.close()
保存会话用 POST涉及数据库写入,属于状态变更操作,用 POST + JSON 请求体
2. ConversationResultEntity — 保存会话请求体
# chat/entity/ConversationResultEntity.py
from pydantic import BaseModel, Field


class ConversationResultEntity(BaseModel):
    question: str = Field(..., description="问题")
    username: str = Field(..., description="用户名")
    parentId: int = Field(..., description="历史会话id")
    answer: str = Field(..., description="答案")

关键点:

  • parentId:所属会话的 id。新对话的首条记录 parentId=0,之后追问的记录 parentId=首条记录id,形成"父子"链
  • 前端保存会话时,如果当前是新对话(currentChatId=0),保存成功后会把返回的 historyId 赋值给 currentChatId,后续追问都以它为父 id
3. ChatService.chat — 对话核心业务
# chat/service/ChatService.py
from ai import LoadChroma, LoadRerankerModel, LoadModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnableParallel, RunnablePassthrough, RunnableLambda

from chat.dao import ChatDao
from chat.service import HistoryService
from chat.utils import IntentionUtil, BM25Util, RRFUtil


def chat(question, historyId):
    # 如果historyId=0,表示一个新对话
    if historyId == 0:
        history = []
    else:
        # 查询对话记录 --- 没有考虑只查询最新的几条记录
        history = HistoryService.conversation_log(historyId)['data']
    """
        使用一个小模型来做意图识别,根据结果判定是否走 RAG 检索
    """
    is_legal = IntentionUtil.intention_recognition(question)['is_legal']   # 意图识别
    llm = LoadModel.load_model()
    # 判断用户的问题是否需要走RAG检索
    if not is_legal:
        history.append({"role": "user", "content": question})
        # 直接LLM回复
        for chunk in llm.stream(history):
            if chunk.content:
                yield chunk.content
        return
    # 提示词文本内容
    template = """
        你是一个基于知识库的AI助手。请根据RAG检索内容回答用户问题。
        规则:
            - 仅基于提供的知识回答,不使用外部知识补充。
            - 检索内容不足时,说明信息不足,不要猜测。
            - 优先提炼关键答案,避免冗长解释。
            - 保持回答自然、简洁、有帮助。
            - 输出结果的时候,不允许输出根据提供的参考资料这样的内容
            - 输出结果的时候,如果没有参考的上下文信息,请给出一个友好的回复信息
        历史记录:
            {history}
        参考资料:
            {context}
        问题:
            {question}
        答案:
    """
    # 创建提示词对象
    prompt = PromptTemplate(
        template=template,
        input_variables=["history", "context", "question"],
    )

    # 创建检索器对象
    vector_db = LoadChroma.load_chrome_conn()
    vector_retriever = vector_db.as_retriever(search_kwargs={"k": 10})

    # 打印召回的结果
    def print_recall(docs):
        for doc in docs:
            print(doc.page_content)
            print("-" * 20)
        return docs

    # 重排序
    def reranker_func(data):
        print("开始进行重排序...")
        docs = data['context']
        question = data['question']
        history = data['history']
        reranker = LoadRerankerModel.load_rerank_model()
        scores = reranker.compute_score([(question, doc.page_content) for doc in docs])
        reranker_dict = []
        for index, item in enumerate(scores):
            reranker_dict.append({docs[index].id: item})
        # 排序
        reranker_dict.sort(key=lambda x: list(x.values())[0], reverse=True)
        reranker_result = [doc.page_content for index, item in enumerate(reranker_dict) for one in item.keys() for
                           doc in docs if doc.id == one][:3]
        context = "\n\n".join(
            f"[来源{index}] {item}" for index, item in enumerate(reranker_result, start=1)
        )
        print(f"重排序后的结果:\n{context}")
        return {
            "context": context,
            "history": history,
            "question": question,
        }

    # 创建langchain链
    qa_chain = (
        # 并行执行器 --- 处理问题和基于问题检索文档
        RunnableParallel(
            {
                "context": vector_retriever | RunnableLambda(print_recall), # 上下文,内容就是检索的文档
                "history": RunnableLambda(lambda _: history),  # 对话记录
                "question": RunnablePassthrough(),  # 透明传递
            }
        )
        | RunnableLambda(reranker_func)  # 重排序
        | prompt  # 提示词
        | llm  # 大模型对象
        | StrOutputParser()  # 把llm输出的结果转为字符串输出
    )
    for chunk in qa_chain.stream(question):
        yield chunk

关键点:

环节说明
historyId 判断historyId == 0 新对话,历史记录为空;否则从 conversation_log 加载历史对话
意图识别分流is_legal=False 时直接 llm.stream(history) 回复,跳过检索,省钱又省时
提示词新增历史记录模板变量从 2 个变为 3 个:{history} + {context} + {question},让模型能结合上文上下文
RunnableParallel并行取三路:检索上下文、历史记录、原问题
重排序后格式化把 top-3 文档拼成 [来源1] xxx\n\n[来源2] xxx,让 LLM 明确区分每条参考来源
流式生成qa_chain.stream(question) 逐 chunk yield,供上层 SSE 逐 token 推送

💡 本段代码中的 retriever_func(向量 + BM25 + RRF 混合检索)是 Day9 的核心内容,今天先了解整体流程,Day9 会深入拆解 BM25UtilRRFUtil 的实现原理。

4. 保存会话结果 — ChatService + ChatDao
# ChatService.py 中的保存方法
def save_conversation_result(conversationResultEntity):
    # 取出conversationResultEntity对象中的数据内容
    question = conversationResultEntity.question
    username = conversationResultEntity.username
    parent_id = conversationResultEntity.parentId
    answer = conversationResultEntity.answer
    # 存储
    history_id = ChatDao.save_conversation_result(question, username, parent_id, answer)
    if history_id != 0:
        return {
            "code": 200,
            "msg": "保存成功",
            "data": history_id,   # 返回新增记录的主键id,前端用来记录"当前会话id"
        }
    return {
        "code": 500,
        "msg": "保存失败",
        "data": None,
    }
# chat/dao/ChatDao.py
from common import MySQLUtil

"""
    查询操作不需要做事务管理
    增删改操作需要事务管理
    操作成功 --- commit 提交事务:执行当前操作
    操作失败 --- rollback 回滚事务:不执行当前操作
"""
def save_conversation_result(question, username, parent_id, answer):
    conn = MySQLUtil.get_mysql_conn()
    cur = conn.cursor()
    try:
        sql = "insert into `history` values(null, %s, %s, %s, %s, now())"
        cur.execute(sql, [question, username, parent_id, answer])
        conn.commit()  # 提交事务
        # 返回当前新增数据的主键自增ID -- history_id
        return cur.lastrowid
    except Exception as e:
        print(f"新增会话结果失败:{e}")
        conn.rollback()  # 回滚事务
        return 0
    finally:
        MySQLUtil.close_mysql_conn(cur, conn)

关键点:

概念说明
事务管理增删改操作必须显式 commit() 才真正生效;失败时 rollback() 撤销
lastrowid插入后获取自增主键 id,返回给前端作为"当前会话 id"
now()MySQL 函数,插入当前时间戳,自动记录会话创建时间
finally无论成功失败都关闭连接,防止连接泄漏
history 表结构(history_id, question, username, parent_id, answer, create_time)

MySQL 建表语句参考:

CREATE TABLE `history` (
    `history_id` int NOT NULL AUTO_INCREMENT COMMENT '历史记录id',
    `question` varchar(255) COMMENT '用户问题',
    `username` varchar(255) COMMENT '用户名',
    `parent_id` int COMMENT '所属会话id,新会话首条为0',
    `answer` text COMMENT 'AI回答',
    `create_time` datetime COMMENT '创建时间',
    PRIMARY KEY (`history_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对话历史记录表';

六、历史记录功能 — history 模块

对话页面左侧的历史记录菜单,由 history 模块提供数据。

1. HistoryController — 两个查询接口
# chat/controller/HistoryController.py
from fastapi import APIRouter

from chat.service import HistoryService

history_router = APIRouter()


@history_router.get(
    path="/queryHistoryMenu",
    summary="查询历史记录菜单栏",
)
def query_history_menu(username: str):
    return HistoryService.query_history_menu(username)


@history_router.get(
    path="/conversationLog",
    summary="查询某一条详细对话记录",
)
def conversation_log(historyId: int):
    return HistoryService.conversation_log(historyId)
2. HistoryService — 包装返回格式
# chat/service/HistoryService.py
from chat.dao import HistoryDao

# 查询历史记录菜单栏
def query_history_menu(username):
    # 获取结果
    results = HistoryDao.query_history_menu(username)
    # 包装结果
    data_list = []
    for item in results:
        data_list.append({
            "historyId": item['history_id'],
            "title": item['question'],
            "time": item['create_time'].strftime("%Y-%m-%d %H:%M:%S"),
            "active": "false"
        })
    return {
        "code": 200,
        "msg": "查询成功",
        "data": data_list
    }


# 查询某一条详细对话记录
def conversation_log(historyId):
    results = HistoryDao.conversation_log(historyId)
    data_list = []
    for item in results:
        data_list.append({
            "role": "user",
            "content": item['question'],
        })
        data_list.append({
            "role": "assistant",
            "content": item['answer'],
        })
    return {
        "code": 200,
        "msg": "查询成功",
        "data": data_list
    }

关键点:

  • Service 层负责把 Dao 返回的数据库字段名history_id)转成前端需要的字段名historyId),即"字段映射"
  • query_history_menu 只返回父会话parent_id=0)作为菜单项,每个菜单项显示"问题标题 + 时间"
  • conversation_log 把一问一答转成 {role: user/assistant, content: ...} 列表,前端直接按 role 渲染气泡
3. HistoryDao — SQL 查询
# chat/dao/HistoryDao.py
from common import MySQLUtil


# 查询历史记录菜单栏
def query_history_menu(username):
    conn = MySQLUtil.get_mysql_conn()
    cur = conn.cursor()
    sql = "SELECT history_id, question, create_time FROM history WHERE username=%s AND parent_id=0;"
    cur.execute(sql, [username])
    results = cur.fetchall()
    MySQLUtil.close_mysql_conn(cur, conn)
    return results


# 查询某一条详细对话记录
def conversation_log(historyId):
    conn = MySQLUtil.get_mysql_conn()
    cur = conn.cursor()
    sql = "SELECT question, answer FROM history WHERE history_id=%s OR parent_id=%s ORDER BY history_id ASC;"
    cur.execute(sql, [historyId, historyId])
    results = cur.fetchall()
    MySQLUtil.close_mysql_conn(cur, conn)
    return results

关键点:

SQL 语句说明
WHERE username=%s AND parent_id=0只查当前用户的父会话,作为左侧菜单项
WHERE history_id=%s OR parent_id=%s ORDER BY history_id ASC查父会话本身 OR 它的所有子会话,按时间正序拼接完整对话

父子会话数据模型:一次对话以 parent_id=0 的记录开头(history_id 例如 5),后续每一轮追问都以 parent_id=5 存储。查询时用 history_id=5 OR parent_id=5 就能把整个对话链路按顺序取出来。这个"主表 + 外键自关联"的思想是聊天历史记录的经典设计。


七、前端 Element Plus 集成

1. main.js 注册组件库
// src/main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import ElementPlus from 'element-plus'                 // 导入组件库
import 'element-plus/dist/index.css'                   // 导入组件库样式(必须!)

// 创建对象
const app = createApp(App)

// 注册路由对象
import router from './router'
app.use(router)

// 注册 Element Plus —— 全局注册所有组件,模板中可直接使用 el-xxx 标签
app.use(ElementPlus)

// axios 全局配置
import axios from 'axios'
axios.defaults.baseURL = 'http://localhost:8001/'
axios.defaults.headers.post['Content-Type'] = 'application/json'
axios.defaults.headers.put['Content-Type'] = 'application/json'
app.config.globalProperties.$axios = axios

app.mount('#app')

安装命令:

npm install element-plus @element-plus/icons-vue
依赖作用
element-plusElement Plus 组件库本体
@element-plus/icons-vue官方图标库(ChatDotRound 等)

关键点:

  • 这部分前端样式相关可以直接用AI介入即可

八、Chat.vue 对话页面【核心】

这是本次项目最核心、代码量最大的前端页面。左侧历史记录栏 + 右侧聊天区。

1. 页面布局结构
┌──────────────────────────────────────────────────────────┐
│ Chat.vue                                                  │
│ ┌───────────────────────┬──────────────────────────────┐ │
│ │  Sidebar 左侧历史栏      │  Main 主聊天区                 │ │
│ │  ├─ 头部:logo + 新对话   │  ├─ Header:标题 + 清空按钮       │ │
│ │  ├─ 搜索框               │  ├─ Message-area 消息列表      │ │
│ │  ├─ 历史记录列表(循环)     │  │   ├─ 空状态 + 快捷提示词        │ │
│ │  │  v-for item in historyList │   └─ 消息气泡(user/assistant)│ │
│ │  └─ 底部:用户信息头像      │  └─ Footer:输入框 + 发送按钮     │ │
│ └───────────────────────┴──────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
2. 核心逻辑 — SSE 流式聊天【重点】
// 定义响应式数据
let messages = ref([]);          // 聊天消息列表 [{role, content}]
let question = ref("");          // 输入框内容
let isLoading = ref(false);      // 是否正在聊天(禁用输入框)
let currentChatId = ref(0);      // 当前会话 id(0 = 新对话)

function chat() {
    isLoading.value = true;                          // 1. 标记正在聊天
    let myQuestion = question.value.trim();          // 2. 取输入并去空格
    question.value = "";                             //    清空输入框
    if (myQuestion.length === 0) { ElMessage.warning("请输入内容"); return; }

    // 3. 先本地渲染:用户消息 + AI 占位
    messages.value.push({role: 'user', content: myQuestion});
    messages.value.push({role: 'assistant', content: 'AI正在努力的生成回复ing~~~'});

    // 4. 构造 SSE 请求
    let urlSearchParams = new URLSearchParams({
        question: myQuestion,
        historyId: currentChatId.value,
    });
    let es = new EventSource("http://localhost:8001/chat/chat?" + urlSearchParams.toString());

    // 5. 拼接结果的累加器
    let s = "";

    // 6. 监听服务器推送
    es.onmessage = (e) => {
        let data = JSON.parse(e.data);
        if (data.error) {                            // 后端异常 → 友好提示
            messages.value[messages.value.length - 1].content = s || "回答生成失败,请稍后重试";
            isLoading.value = false;
            es.close();
            return;
        }
        let content = data.content;
        if (content === "[DONE]") {                  // 结束标记 → 保存会话并关闭连接
            saveConversationResult(myQuestion, s);
            isLoading.value = false;
            es.close();
            return;
        }
        s += content;                                // 累加 token
        messages.value[messages.value.length - 1].content = s;  // 覆盖占位符,实时更新
    };

    // 7. 监听错误
    es.onerror = (e) => {
        console.log("SSE发生错误:", e)
        es.close();
        isLoading.value = false;
        ElMessage.error("连接中断,回答可能不完整,请重试");
    };
}

SSE 工作原理逐步拆解:

步骤代码作用
1new EventSource(url)创建 SSE 连接,GET 请求,参数拼在 URL 上
2es.onmessage服务端每推送一条 data: 就触发一次
3JSON.parse(e.data)解析服务端发来的 {"content": "字"} JSON
4s += content把 token 累加到字符串
5覆盖最后一条消息的 contentmessages[...].content = s,触发 Vue 响应式更新
6content === "[DONE]"判断流结束,保存会话、关闭连接
7es.onerror网络中断时关闭连接,复位 isLoading

💡 EventSource vs 普通 Axios 的区别:Axios 一次请求一次性返回完整响应;EventSource持续连接,服务端边生成边推送,前端 onmessage 不断触发,实现逐字"打字机"效果。这是 SSE 与普通 HTTP 最大的区别——连接保持打开,数据分多次到达

3. 历史记录相关函数
// 查询历史记录菜单栏
function query_history_menu() {
    proxy.$axios({
        url: 'history/queryHistoryMenu',
        method: 'get',
        params: { username: username.value }
    }).then(res => {
        historyList.value = res.data.data;
    });
}

// 点击某条历史 → 加载完整对话
function conversationLog(historyId) {
    currentChatId.value = historyId;      // 记录当前会话 id(后续追问属于这个会话)
    proxy.$axios({
        url: 'history/conversationLog',
        method: 'get',
        params: { historyId: historyId }
    }).then(res => {
        messages.value = res.data.data;   // 直接覆盖消息列表,渲染历史气泡
    });
}

// 保存对话结果
function saveConversationResult(question, answer) {
    proxy.$axios({
        url: 'chat/saveConversationResult',
        method: 'post',
        data: JSON.stringify({
            question: question,
            username: username.value,
            parentId: currentChatId.value,   // 关键:新会话传0,追问传当前会话id
            answer: answer
        })
    }).then(res => {
        // 只有当currentChatId=0时,才把新增记录的historyId赋给它【表示一个新对话的开始】
        if (currentChatId.value === 0) {
            currentChatId.value = res.data.data;
        }
        query_history_menu();  // 刷新历史菜单
    });
}

// 新对话:重置会话id并清空消息
function newChat() {
    currentChatId.value = 0;
    messages.value = [];
}

currentChatId 的状态流转:

初始状态:currentChatId = 0
    │
    ▼ 用户发第一条消息并完成回答
saveConversationResult 执行成功
    │ parentId = 0 → 服务端生成新的 history_id(如 8)
    │ 前端把 currentChatId 更新为 8
    ▼
后续追问:currentChatId = 8
    │ parentId = 8 → 追加为 history_id=8 的子会话
    ▼
点击历史记录"第8条":currentChatId = 8 → 加载该会话全部记录
    │
    ▼
点击"新对话":currentChatId = 0 → 清空消息,开启新一轮
4. onMounted 初始化
onMounted(() => {
    // 用 || "" 兜底:未登录直接访问 /chat 时 sessionStorage 中没有 username,
    // getItem 会返回 null,直接赋 null 会在模板里调用 username.charAt(0) 时崩溃
    username.value = sessionStorage.getItem("username") || "";
    // 获取历史记录菜单栏
    query_history_menu();
})

关键点:

  • onMounted 是 Vue 3 的生命周期钩子,组件挂载完成后自动执行
  • sessionStorage.getItem("username") 读取登录时存下的用户名,|| "" 兜底防止 null 崩溃
5. 模板中的消息渲染

用AI描述你想要的AIChat界面即可


九、登录流程

<script setup>
import {ref, getCurrentInstance} from "vue";
import {useRouter} from "vue-router";
import {ElMessage} from "element-plus";
import {Message, Key} from "@element-plus/icons-vue";

let router = useRouter();               // 编程式路由跳转

function sendEmail() {
    proxy.$axios({
        url: 'users/sendEmail',
        method: 'get',
        params: { email: email.value },
    }).then(res => {
        if (res.data.code === 200) {
            isCode.value = !isCode.value;
            // 后端返回 data = 用户名,存入 sessionStorage,作为聊天页展示
            sessionStorage.setItem("username", res.data.data);
            ElMessage.success("成功发送验证码");
        } else {
            ElMessage.error(res.data.msg);
        }
    });
}

function checkCode() {
    proxy.$axios({
        url: 'users/checkCode',
        method: 'post',
        data: JSON.stringify({ email: email.value, code: code.value })
    }).then(res => {
        if (res.data.code === 200) {
            ElMessage.success("登录成功");
            // 延迟1秒跳转到 /chat 对话页
            setTimeout(() => {
                router.push("/chat");
            }, 1000);
        } else {
            ElMessage.error(res.data.msg);
        }
    });
}
</script>

关键点:

相关内容说明
Element Plus 组件<el-card><el-form><el-input><el-button> 替代原生标签
ElMessage 提示替代 alert(),更美观的顶部弹出提示
useRouter编程式导航,登录成功 router.push("/chat") 跳转对话页
sessionStorage存用户名,跨页面共享;Chat.vue 用 getItem 读取
图标组件<el-icon><Message/></el-icon> 前缀图标美化输入框

路由配置/chat 路由已在 router/index.js 中注册,访问 http://localhost:8080/chat 进入对话页。


十、完整数据流(登录 → 聊天 → 保存 → 回看)

【登录阶段】
用户输入邮箱 → sendEmail → 后端发验证码 + 返回 username
→ sessionStorage 存 username → 输入验证码 → checkCode → 登录成功
→ router.push("/chat")
            │
            ▼
【聊天阶段】Chat.vue onMounted
├── username = sessionStorage.get("username")
├── query_history_menu() → GET history/queryHistoryMenu → 渲染左侧菜单
└── 用户输入问题 → chat()
        │
        ▼
EventSource → GET chat/chat?question=xxx&historyId=0
        │
        ▼
【后端】ChatService.chat
├── 意图识别 → is_legal
├── RAG 检索:向量 + BM25 + RRF → 重排序 top-3(Day9 详讲)
├── 提示词拼接 → qa_chain.stream
└── 逐 token yield → SSE data: {"content":"..."} 推送
        │
        ▼
【前端】onmessage 逐字累加 → 打字机效果
        │
        ▼ 收到 [DONE]
saveConversationResult → POST chat/saveConversationResult
→ MySQL history 表新增记录 → 返回 history_id
→ 若为新会话,currentChatId = history_id
→ query_history_menu() 刷新菜单
            │
            ▼
【回看阶段】点击历史记录
conversationLog → GET history/conversationLog?historyId=x
→ 按 role 渲染完整对话气泡

十一、总体实践

  1. 实现意图识别:用 Ollama 启动本地小模型,实现 IntentionUtil,测试"法律问题 vs 闲聊"能正确分流
  2. 实现聊天接口chat/chat SSE 流式接口 + chat/saveConversationResult 保存接口
  3. 实现历史记录history/queryHistoryMenu + history/conversationLog,理解父子会话(parent_id)设计
  4. 实现 Chat.vue:左右布局 + SSE 流式接收 + 历史记录加载 + 保存会话 + 新对话
  5. 登录跳转:登录成功存入 sessionStorage,跳转 /chat
🔧 准备工作检查清单
  • Element Plus 安装:npm install element-plus @element-plus/icons-vue
  • ai 包四个工具类已封装,.env 中模型路径配置正确
  • 法律知识库已构建(Chroma law_chroma 目录有数据)
  • Ollama 已启动,qwen3:1.7b 模型已下载(常见报错:httpx.ConnectError: [WinError 10061] 由于目标计算机积极拒绝,无法连接。 )
  • MySQL history 表已创建
  • 前后端同时启动,8080(跨域写好的前端)/ 8000(自己的后端)
⚠️ 常见问题
问题可能原因解决方案
前端无样式未导入 Element Plus 的 CSS在 main.js 导入 element-plus/dist/index.css
es.onerror 频繁触发后端 SSE 生成器抛异常未捕获检查 Controller 是否 try/except 包裹
历史记录空白username 未传入(未登录)确认 sessionStorage 有 username 值
保存会话失败history 表不存在或字段名不对核对建表 SQL 与 Dao 的 SQL
检索不到数据知识库未构建或集合名不一致检查 COLLECTION_NAME / CHROMA_PATH 是否一致
BGE 检索效果差未使用 BGEQueryEmbeddings(缺检索指令)使用封装后的 LoadEmbeddingModel

核心要点

  • ai 模块统一封装:LLM / 向量化 / 重排序 / Chroma 四类模型加载集中到 ai 包,配置走 .env,一行代码调用
  • BGE 检索指令:query 端加"为这个句子生成表示以用于检索相关文章"前缀,doc 端不加,否则效果严重退化
  • 知识库重建铁律Chroma.from_documents 只追加不覆盖,重建前必须先 delete_collection()
  • 意图识别路由:本地小模型(Ollama)判断是否走 RAG,非知识库内容的聊天直接 LLM 回复,节省成本
  • SSE 异常捕获:生成器 try/except + [DONE] 结束标记,避免流中断导致浏览器报错
  • 父子会话设计parent_id=0 开新会话,追问 parent_id=history_id,查询用 history_id=xxx OR parent_id=xxx
  • 事务管理:增删改用 commit() / rollback(),插入后 lastrowid 拿自增主键
  • Element Plusapp.use(ElementPlus) 全局注册 + 导入 CSS,组件即插即用
  • SSE 前端new EventSource(url)onmessage 逐字累加 → [DONE] 后关闭连接、保存会话

最终思考:后端慢慢有了 AI 模型统一封装、知识库构建、意图识别、会话保存、历史记录;前端也搭建了 Element Plus 美化、SSE 流式对话、历史菜单交互。登录 → 聊天 → 保存 → 回看,全链路已经跑通。后续内容将进一步优化检索效果:向量检索虽然能理解语义,但对"关键词/名词"关注度不高,需要引入 BM25 关键词检索,并用 RRF 算法把两种检索结果融合,实现更精准的混合检索。

更多推荐