相关项目代码已上传github,gitee,csdn等等,

关键词:FastAPI、SSE、前端轮询、异步任务、原生 JavaScript、知识库导入、流式对话、RAG 前端交互、任务状态管理

博客适配:零基础可读懂、代码逐行注释、架构设计解析、生产可用方案、项目落地避坑

前言

在当前 AI RAG 项目开发的主流趋势中,绝大多数开发者的技术重心和研究精力,都高度集中在后端核心链路的优化迭代上。其中就包含向量数据库的参数调优、LangGraph 智能流水线的流程编排、大模型 Prompt 工程优化、检索策略权重配比等核心技术点,这也是大多数 RAG 教程和实战项目重点讲解的内容。但在完整的项目落地、产品上线的真实场景中,后端算法能力再优秀,也需要前端交互承接用户操作、反馈运行状态、展示最终结果,前端交互体验就是 AI 项目落地的“最后一公里”,也是最容易被忽略、最容易出现短板的关键环节。

很多技术demo之所以只能停留在本地测试、无法上线商用,核心问题并非检索精度低、大模型效果差,而是前端交互体验极差。比如大文件上传全程无进度反馈、后台任务运行时页面空白卡死、AI 问答长时间黑屏等待、任务失败无报错提示、多服务部署端口冲突、用户无法取消无效任务等。这些看似细小的交互问题,会直接打断用户操作流程、降低产品可信度,最终彻底毁掉一套高性能的后端 RAG 架构,让精心优化的算法效果无法落地呈现。

本文作为本系列教程的第一篇前端实战篇,摒弃碎片化知识点讲解,采用全局架构梳理 + 逐行代码解析 + 设计思想深挖 + 落地避坑总结的完整教学模式,手把手带你从零搭建一套轻量化、高性能、生产级可用的 RAG 系统前端交互架构。整套方案基于 FastAPI 后端服务 + 原生 HTML/CSS/JavaScript 实现,无需任何前端框架依赖,彻底解决旅游知识库 RAG 项目落地过程中的三大核心交互痛点,完美适配个人项目、毕业设计、小型商用系统的开发场景。

本项目针对性解决的三大核心交互难题如下:

  1. 大文件导入无阻塞、全流程可视化:完美支持 50MB 超大体积 PDF、Markdown 知识库文件上传,后端完整执行文件解析、图片 OCR 识别、文档语义切片、内容向量化、向量库入库等耗时操作,全程不阻塞前端页面,用户可实时查看每一步处理节点的运行状态,彻底告别盲等待。

  2. AI 流式对话极致体验:摒弃传统接口同步阻塞返回模式,基于 SSE 服务端单向推送技术,实现媲美 ChatGPT 的打字机流式输出效果,大模型生成内容实时逐字展示,有效消除用户等待焦虑,大幅提升智能问答交互体验。

  3. 双服务架构无缝适配:针对本项目知识库导入服务(8000端口)、智能查询服务(8001端口)物理分离的部署架构,解决多端口跨域、接口适配、状态同步、页面联动等前后端联调难题,实现双服务无感切换、统一交互。

本文所有代码均经过项目实测验证,可直接复制部署运行,每一段核心代码都配备超细中文注释,每一个架构设计都附带原理讲解与优劣分析,无敷衍代码、无模糊概念、无遗漏知识点,非常适合新手入门学习、开发者项目复盘、技术博客原创发布。

一、项目整体架构概

本次实战搭建的旅游知识库 RAG 系统,采用前后端物理分离、业务服务解耦、双服务独立部署的轻量化架构设计,彻底打破传统单服务架构的耦合弊端。将知识库数据导入、智能问答查询两大核心业务完全拆分,独立部署、独立运行、互不干扰,既降低了单服务的运行压力,又方便后续针对不同业务模块单独迭代、优化、扩容,是中小型 AI 知识库项目最优的落地架构。

整套系统拆分的两大核心服务职责清晰、边界明确:

  • 知识库导入服务(8000 端口):作为整个 RAG 知识库的数据源头,专门负责静态知识库的构建工作。核心能力包含用户文件上传接收、文件格式校验与解析、PDF 转 Markdown、图片内容 OCR 识别与摘要生成、长文档智能切片、文本内容向量化、向量数据批量入库 Milvus 数据库,所有数据处理任务异步执行,为后续智能问答提供完整的知识库数据源。

  • 智能问答查询服务(8001 端口):作为系统核心交互入口,专门面向用户提供智能问答服务。核心能力包含用户问题接收、问题意图识别、旅游实体确认、多维度向量检索、网络实时搜索、检索结果重排序融合、大模型答案生成、图文内容整合输出,承接所有前端问答交互请求,实现智能问答核心能力。

在前端技术选型上,本项目摒弃 Vue、React、Next 等主流前端框架,全程采用原生 HTML + CSS + JavaScript 零框架开发方案。相比于框架开发,原生开发无需安装依赖、无需打包编译、无需适配框架语法,部署极简、资源占用极低、兼容性极强,不仅大幅降低项目部署和维护成本,更适合新手读懂底层交互原理,避免“只会框架、不懂原生”的技术短板。

为支撑整套 RAG 系统的实时交互、状态同步、持久化能力,前端搭建了一套完整的技术能力体系,核心支撑能力包含:后端内存任务状态全局管理、FastAPI 后台异步任务调度、前端定时轮询状态同步、SSE 服务端流式推送、浏览器本地会话持久化存储,全方位保障系统稳定、流畅、高效运行。

二、前端项目骨架与核心依赖设计

2.1 完整目录结构(核心文件详解)

为了保证项目代码结构清晰、业务职责单一、后续迭代便捷、新手易读易维护,本项目严格遵循分层模块化、职责单一化的开发规范,将工具能力、业务服务、数据模型、路由接口、前端页面完全拆分,每个目录、每个文件都有明确的定位和作用,杜绝代码杂乱堆砌、业务逻辑耦合的问题。完整核心目录结构及详细解析如下:

front/
├── utils/                  # 工具核心层(全局通用能力,全项目复用)
│   ├── paths.py            # 全局路径统一管理,统一静态资源、文件存储路径,供后端挂载静态资源使用
│   ├── deps.py             # 依赖注入层,基于lru_cache实现全局服务单例,解决并发状态错乱问题
│   └── task_util.py        # 全局任务内存状态管理器(项目核心核心,支撑所有进度展示)
├── service/                # 业务服务层(封装所有核心业务逻辑,解耦路由与业务)
│   ├── task_service.py     # 任务状态增删改查核心服务,统一任务数据操作入口
│   ├── import_file_service.py # 文件导入全流程业务服务,封装所有文件处理逻辑
│   └── query_service.py    # 问答查询全流程业务服务,封装所有智能问答逻辑
├── schema/                 # 数据模型层(统一前后端请求、响应数据格式)
│   ├── task_schema.py      # 任务状态数据模型,规范任务接口返回字段与格式
│   └── upload_schema.py    # 文件上传响应模型,统一上传接口返回数据结构
├── import_router.py        # 知识库导入接口路由(绑定8000端口,所有导入相关接口)
├── query_router.py         # 智能问答接口路由(绑定8001端口,所有问答相关接口)
├── import.html             # 文件导入前端交互页面,提供上传、进度查看、任务管理能力
└── chat.html               # 流式对话前端交互页面,提供智能问答、历史会话、图文展示能力

2.2 核心基石:task_util.py 全局任务状态管理器

在传统的文件处理、异步任务项目中,绝大多数开发方案都存在一个致命短板:后端任务执行全程黑盒,无法向前端同步实时运行状态,用户只能被动等待页面响应,完全不知道系统正在执行什么、执行进度如何、是否出现异常。这种“盲等”交互体验极差,也是很多 AI 项目无法商用的核心原因之一。

为彻底解决这个问题,本项目自研实现了基于内存字典的全局任务状态管理器,在服务运行期间永久缓存所有异步任务的执行状态,只要服务不重启,任务状态数据就不会丢失。后端通过精准记录每一个业务节点的运行、完成状态,向前端输出标准化的状态数据,让前端可以精准渲染进度条、运行日志、状态徽章,实现全流程可视化交互。

本模块最核心的设计思想为前后端业务彻底解耦:所有业务流程节点的 ID、中英文映射关系全部由后端统一定义、统一维护,前端仅负责接收数据、渲染页面,无需硬编码任何业务节点信息。后续项目迭代新增业务流程、新增处理节点时,只需在后端映射字典中新增一行配置,前端代码零修改、零适配即可完成迭代,极大提升了项目的可扩展性和维护性

# front/utils/task_util.py
from typing import Dict, List
from collections import defaultdict

# ====================== 全局内存存储变量(核心全局状态) ======================
# 作用:服务运行期间永久缓存任务状态,无需数据库存储,高效轻量
# 使用defaultdict自动初始化空列表,彻底规避key不存在导致的程序报错,代码更健壮
_tasks_running_list: Dict[str, List[str]] = defaultdict(list)  # 正在执行的任务节点集合
_tasks_done_list: Dict[str, List[str]] = defaultdict(list)     # 已完成的任务节点集合
_tasks_status: Dict[str, str] = {}                             # 任务整体状态:pending/running/completed/failed

# ====================== 核心:流程节点中英文映射字典 ======================
# 所有业务流程的后端节点ID与前端展示中文名称一一对应
# 项目迭代新增节点时,仅需在此处添加映射,前端无需任何修改
_NODE_NAME_TO_CN: Dict[str, str] = {
    # ==================== 知识库导入全流程节点 ====================
    "import_file": "文件类型检测",          # ImportFileNode
    "pdf_to_md": "PDF转Markdown",          # PdfToMdNode
    "remake_img": "Markdown图片处理",       # RemakeMdImgNode
    "split_doc": "文档切分",                # DocumentSplitNode
    "extract_name": "名称识别",             # GetNameNode

    # ==================== 智能问答查询全流程节点 ====================
    # 对应 search_main_graph.py 中的 create_search_graph()
    "query_analysis": "问题理解与重写",    # RewritingQueryNode
    "vector_search": "切片向量检索",       # VectorSearchNode
    "hyde_search": "假设性文档检索",       # HydeSearchNode
    "web_search": "联网搜索",              # WebSearchNode
    "rrf_search": "倒排融合排序",          # RRFSearchNode
    "reranker_search": "重排序",           # RerankerSearchNode
    "answer_output": "生成答案",           # AnswerOutputNode

    # ==================== 特殊/辅助节点 ====================
    "__end__": "处理完成",                 # LangGraph 结束标记
}
# ====================== 核心方法:任务状态更新 ======================
def add_running_task(task_id: str, node_name: str) -> None:
    """添加正在执行的任务节点(自动去重,避免重复记录)"""
    if node_name not in _tasks_running_list[task_id]:
        _tasks_running_list[task_id].append(node_name)

def add_done_task(task_id: str, node_name: str) -> None:
    """
    节点执行完成回调方法,更新全局任务状态
    1. 将当前节点从「运行中列表」移除,标记任务进度更新
    2. 将当前节点加入「已完成列表」,避免重复统计
    """
    if node_name in _tasks_running_list[task_id]:
        _tasks_running_list[task_id].remove(node_name)
    if node_name not in _tasks_done_list[task_id]:
        _tasks_done_list[task_id].append(node_name)

# ====================== 核心方法:对外提供中文状态数据 ======================
def get_running_task_list(task_id: str) -> List[str]:
    """根据任务ID获取当前运行中的中文节点列表,直接供前端页面渲染展示"""
    return [_NODE_NAME_TO_CN.get(n, n) for n in _tasks_running_list.get(task_id, [])]

def get_done_task_list(task_id: str) -> List[str]:
    """根据任务ID获取已完成的中文节点列表,直接供前端页面渲染展示"""
    return [_NODE_NAME_TO_CN.get(n, n) for n in _tasks_done_list.get(task_id, [])]

设计亮点总结

  • 代码健壮性极强:基于 defaultdict 实现全局存储,自动初始化空数据,彻底规避普通字典 key 不存在导致的程序报错,无需额外判空处理。

  • 前后端完全解耦:统一中英文映射规则,业务迭代、节点新增修改仅需维护后端配置,前端视图层零改动,适配长期迭代场景。

  • 高性能轻量存储:采用内存级临时存储,无需读写数据库,状态读写速度达到毫秒级,完美适配前端实时状态更新的需求,无性能损耗。

2.3 单例依赖注入:deps.py 全局服务统一管理

在 FastAPI 框架的原生运行机制中,每一次前端请求都会触发服务实例的重新创建,多请求并发场景下,会生成大量独立的服务实例。如果多个请求操作同一套任务状态数据,不同实例的数据相互独立、无法同步,就会出现任务状态错乱、进度丢失、数据不一致的严重问题,这也是绝大多数异步任务项目的隐形坑点。

为彻底解决并发数据不一致问题,本项目采用 lru_cache 缓存装饰器实现全局单例服务,保证在整个 FastAPI 应用生命周期内,所有业务服务、状态服务仅被实例化一次。无论前端发起多少并发上传、查询请求,所有请求都会复用同一个服务实例,操作同一块内存状态数据,从底层彻底杜绝并发状态错乱问题,保障系统稳定运行。

# front/utils/deps.py
from functools import lru_cache
from front.service.task_service import TaskService
from front.service.import_file_service import ImportFileService
from front.service.query_service import QueryService

# lru_cache 永久缓存装饰器:保证函数返回对象全局唯一,实现单例模式
# maxsize=None 代表无限制缓存,永久复用实例,不重复创建
@lru_cache(maxsize=None)
def get_task_service() -> TaskService:
    """全局单例:任务状态管理服务,统一所有任务数据的增删改查"""
    return TaskService()

@lru_cache(maxsize=None)
def get_import_file_service() -> ImportFileService:
    """全局单例:文件导入业务服务,依赖任务服务实现状态同步,统一文件处理业务"""
    return ImportFileService(get_task_service())

@lru_cache(maxsize=None)
def get_query_service() -> QueryService:
    """全局单例:问答查询业务服务,统一所有智能问答核心业务逻辑"""
    return QueryService()

核心原理详解lru_cache 装饰器会缓存函数的返回结果,项目启动后首次调用服务获取函数时,会实例化对应服务并缓存结果。后续所有请求再次调用该函数时,不会重新实例化,直接返回缓存的全局唯一实例,彻底解决多请求并发场景下的状态隔离、数据不一致问题,是本项目任务状态同步精准度的核心保障。

三、知识库导入模块:异步上传 + 实时进度轮询

知识库文件导入是整个 RAG 系统的数据入口,所有后续智能问答、检索匹配的数据源都来源于此,是项目中至关重要的前置环节。同时,文件导入也是整个系统中耗时最长、最容易阻塞、交互问题最多的模块。对于大体积 PDF、MD 文件,后端需要依次完成格式校验、文件解析、图片处理、文档切片、语义向量化、向量入库等十余步复杂操作,整个流程耗时可达数十秒。

如果采用传统同步接口开发,前端会一直处于等待状态,页面卡死、无法操作,用户无法判断系统是卡顿还是正在运行,极易重复上传、刷新页面,导致后台任务堆积、数据异常。为此,本项目采用 FastAPI BackgroundTasks 后台异步任务 + 前端定时轮询的组合方案,实现上传秒级响应、后台异步执行、前端实时看进度、任务可随时取消的极致交互体验,彻底解决大文件处理的阻塞痛点。

3.1 后端路由:import_router.py 接口全解析

本模块路由层严格遵循同步逻辑快速响应、耗时逻辑异步执行、职责单一、接口精简的设计原则。路由层仅负责接收前端请求、参数校验、服务调度、结果返回,不掺杂任何复杂业务逻辑,所有文件处理、状态更新逻辑全部下沉到 Service 层,保证代码分层清晰、易于维护。

# import_router.py
import os
import uvicorn
from fastapi import FastAPI, UploadFile, File, Depends, BackgroundTasks
from fastapi.responses import FileResponse
from starlette.staticfiles import StaticFiles
from front.utils.deps import get_import_file_service, get_task_service
from front.schema.upload_schema import UploadResponse
from front.schema.task_schema import TaskStatusResponse
from front.utils.paths import get_front_page_dir

def create_app() -> FastAPI:
    """创建导入服务FastAPI实例,统一配置服务信息"""
    app = FastAPI(title="知识库导入服务", description="旅游RAG知识库文件上传、解析、切片、入库专属服务", version="2.0")
    # 挂载前端静态资源目录,实现html页面直接浏览器访问
    front_page_dir = get_front_page_dir()
    if front_page_dir and os.path.exists(front_page_dir):
        app.mount("/front", StaticFiles(directory=front_page_dir), name="front-static")
    # 统一注册所有导入业务路由接口
    register_router(app)
    return app

def register_router(app: FastAPI):
    """注册导入业务所有接口,分层管理、职责清晰"""
    # 前端导入页面访问接口
    @app.get("/import", summary="跳转文件导入页面")
    async def import_page():
        """读取本地HTML文件并返回,展示前端上传页面"""
        page_path = os.path.join(get_front_page_dir(), "import.html")
        return FileResponse(path=page_path)

    # 核心文件上传异步接口
    @app.post("/upload", response_model=UploadResponse, summary="文件异步上传接口")
    async def upload_file(
        background_tasks: BackgroundTasks,  # FastAPI内置后台任务,不阻塞主响应流程
        file: UploadFile = File(..., description="上传PDF/MD格式知识库文件"),
        service: ImportFileService = Depends(get_import_file_service)
    ):
        # 1. 同步执行:保存文件、生成唯一任务ID(耗时仅几毫秒,无阻塞风险)
        task_id, file_dir, import_file_path = service.process_upload_file(file)
        
        # 2. 异步执行:耗时的全量解析入库流水线
        # 主线程无需等待任务完成,立即响应前端,避免页面阻塞
        background_tasks.add_task(
            service.run_import_pipeline, 
            task_id, file_dir, import_file_path
        )
        
        # 3. 立即返回任务ID,前端基于该ID轮询任务状态
        return UploadResponse(message="文件上传成功,后台开始处理", task_id=task_id)

    # 任务状态查询接口(前端轮询核心接口)
    @app.get("/status/{task_id}", response_model=TaskStatusResponse, summary="查询任务处理状态")
    async def get_task_status(
        task_id: str,
        task_service: TaskService = Depends(get_task_service)
    ):
        """根据唯一任务ID,查询并返回当前任务的完成节点、运行节点、整体状态"""
        task_info = task_service.get_task_info(task_id)
        return TaskStatusResponse(**task_info)

3.2 前端核心逻辑:拖拽上传 + 进度轮询 + 任务取消

前端导入页面针对用户操作习惯做了全方位优化,支持点击上传、拖拽上传两种主流操作方式,同时内置文件格式校验、文件大小校验、请求取消、进度可视化、状态日志实时渲染、异常提示等全套能力。彻底解决传统上传组件功能单一、交互简陋、无容错机制的问题,适配新手用户使用,同时适配超大文件异步处理场景。

// ========== 全局配置项(可自定义修改,适配不同部署环境) ==========
// 自动适配当前域名,兼容本地localhost调试、线上服务器部署,无需手动改域名
const API_BASE = (() => {
    const host = window.location.hostname || '127.0.0.1';
    const port = '8000'; // 知识库导入服务固定端口
    return `http://${host}:${port}`;
})();
const MAX_SIZE = 50 * 1024 * 1024; // 最大上传文件限制:50MB,适配大体积知识库文件
const ALLOW_EXT = ['.pdf', '.md']; // 仅支持PDF、Markdown两种知识库格式文件

// ========== DOM 节点全局获取 ==========
const dropZone = document.getElementById('dropZone');
const fileList = document.getElementById('fileList');

// ========== 页面拖拽上传事件监听与默认行为阻止 ==========
// 阻止浏览器默认拖拽打开文件行为,避免页面跳转
dropZone.addEventListener('dragover', (e) => e.preventDefault());
// 拖拽进入区域,高亮提示用户
dropZone.addEventListener('dragenter', () => dropZone.classList.add('active'));
// 拖拽离开区域,取消高亮
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('active'));

// 拖拽放下文件,触发文件处理与上传逻辑
dropZone.addEventListener('drop', (e) => {
    e.preventDefault();
    dropZone.classList.remove('active');
    // 接收拖拽文件列表,进入校验上传流程
    handleFiles(e.dataTransfer.files);
});

// 批量处理上传文件,完成格式、大小双重校验
function handleFiles(fileArr) {
    Array.from(fileArr).forEach(file => {
        // 统一小写后缀,避免大小写格式校验报错
        const ext = '.' + file.name.split('.').pop().toLowerCase();
        // 非法格式拦截
        if (!ALLOW_EXT.includes(ext)) {
            return showToast('文件格式错误,仅支持 PDF / MD 格式');
        }
        // 超大文件拦截
        if (file.size > MAX_SIZE) {
            return showToast('文件过大,最大支持50MB文件上传');
        }
        // 校验通过,执行单文件上传逻辑
        uploadFile(file);
    });
}

// ========== 核心上传函数:支持取消、进度渲染、状态轮询、异常捕获 ==========
async function uploadFile(file) {
    // 基于时间戳生成唯一文件DOM ID,避免多文件DOM冲突
    const fileId = 'file-' + Date.now();
    // 渲染文件上传卡片(展示文件名、大小、进度、状态、取消按钮)
    const itemHtml = `文件卡片DOM结构`;
    fileList.insertAdjacentHTML('afterbegin', itemHtml);
    const itemEl = document.getElementById(fileId);
    const statusBadge = itemEl.querySelector('.status-badge');
    const progressWrapper = itemEl.querySelector('.progress-wrapper');
    const logListEl = itemEl.querySelector('.log-list');

    // 1. 初始化请求控制器:基于AbortController实现请求手动取消,适配无效任务终止场景
    const abortCtrl = new AbortController();
    let pollTimer = null; // 状态轮询定时器
    let taskId = null;    // 后端返回唯一任务ID

    // 2. 取消按钮点击逻辑:终止请求、停止轮询、更新页面状态
    itemEl.querySelector('.cancel-btn').onclick = () => {
        abortCtrl.abort(); // 强制中断正在进行的fetch上传请求
        if (pollTimer) clearInterval(pollTimer); // 清空轮询定时器,停止状态更新
        // 更新页面状态为已取消
        statusBadge.textContent = '已取消';
        statusBadge.className = 'status-badge status-error';
        showToast('任务已手动取消');
    };

    try {
        // 3. 构建FormData表单数据,适配文件上传请求格式
        const formData = new FormData();
        formData.append('file', file);

        // 发起POST上传请求,绑定取消信号,支持随时中断
        const uploadRes = await fetch(`${API_BASE}/upload`, {
            method: 'POST',
            body: formData,
            signal: abortCtrl.signal
        });

        // 接口异常捕获
        if (!uploadRes.ok) throw new Error('服务端上传接口异常');
        const resData = await uploadRes.json();
        taskId = resData.task_id;

        // 4. 更新初始状态,开启定时轮询,实时同步后端任务进度
        statusBadge.textContent = '处理中';
        statusBadge.className = 'status-badge status-processing';

        // 每1.5秒轮询一次,平衡实时性与服务器压力,避免高频请求压垮服务
        pollTimer = setInterval(async () => {
            const statusRes = await fetch(`${API_BASE}/status/${taskId}`);
            const data = await statusRes.json();
            
            // 5. 计算业务节点进度百分比:基于完成节点数/总节点数,比文件字节进度更贴合业务
            const totalNodes = 7; // 导入流水线固定7个核心处理节点
            const doneCount = data.done_list.length;
            const percent = Math.min((doneCount / totalNodes) * 100, 99);
            document.getElementById(`progress-${fileId}`).style.width = percent + '%';
            
            // 6. 实时渲染日志列表:区分已完成、运行中节点,视觉层级清晰
            logListEl.innerHTML = '';
            data.done_list.forEach(name => {
                logListEl.innerHTML += `<li class="log-done">✅ ${name}</li>`;
            });
            data.running_list.forEach(name => {
                logListEl.innerHTML += `<li class="log-running">⏳ ${name}</li>`;
            });

            // 7. 任务结束状态判断:完成/失败 终止轮询、更新最终状态
            if (data.status === 'completed') {
                clearInterval(pollTimer);
                statusBadge.textContent = '处理完成';
                statusBadge.className = 'status-badge status-completed';
                document.getElementById(`progress-${fileId}`).style.width = '100%';
                showToast('文件入库成功!知识库更新完成');
            }
            if (data.status === 'failed') {
                clearInterval(pollTimer);
                statusBadge.textContent = '处理失败';
                statusBadge.className = 'status-badge status-error';
                showToast('文件处理失败,请检查文件格式或重试');
            }
        }, 1500);

    } catch (err) {
        // 忽略用户手动取消的异常,仅捕获真实程序报错,避免无效报错提示
        if (err.name !== 'AbortError') {
            showToast('上传异常:' + err.message);
        }
    }
}

// 全局轻量提示弹窗工具函数,统一项目提示风格
function showToast(msg) {
    // 省略弹窗DOM渲染逻辑,可自定义样式适配页面
    alert(msg);
}

核心交互设计解析

  • 业务节点式进度展示:摒弃传统文件上传仅展示字节进度的粗糙方式,基于后端业务处理节点统计进度,让用户清晰知晓系统正在执行的具体操作,进度反馈更精准、更贴合业务场景。

  • 可中断异步任务:基于浏览器原生 AbortController API 实现请求中断,用户可随时取消耗时过长、无需继续的任务,避免后台无效任务堆积,节省服务器资源。

  • 合理化轮询策略:采用1.5秒低频率轮询,既保证任务状态更新的实时性,又避免高频请求频繁访问接口、消耗服务器带宽和算力,适配多用户并发场景。

四、智能对话模块:SSE 流式问答 + 会话持久化

在传统 AI 问答项目开发中,绝大多数开发者会使用普通 HTTP 同步接口实现问答交互。但大模型生成完整答案需要 5-10 秒甚至更久的推理时间,同步接口会导致前端页面全程空白、输入框卡死、无任何反馈,用户无法判断是网络卡顿还是模型正在推理,极易重复提问、刷新页面,交互体验极差。

为彻底解决 AI 问答等待焦虑问题,本项目选用 SSE(Server-Sent Events)服务端推送技术 实现流式问答输出,而非主流的 WebSocket。核心选型原因非常明确:智能问答场景仅需要服务端向客户端单向推送增量数据,无需客户端向服务端频繁发送数据。SSE 基于原生 HTTP 协议实现,无需手动维护连接状态、自带浏览器自动断线重连、代码极简、资源占用极低,相比于需要双向通信、手动心跳保活的 WebSocket,更适配本项目的流式问答场景,轻量化优势显著。

4.1 后端路由:双模式问答接口设计

本问答接口采用流式/非流式双模式兼容设计,通过前端传参 is_stream 自由切换模式,适配不同使用场景。流式模式用于日常交互,提升用户体验;非流式模式可用于接口测试、批量问答、后台自动化调用,兼顾实用性和灵活性。

# query_router.py 核心代码(带完整注释)
from fastapi import APIRouter, Request, BackgroundTasks, Depends
from fastapi.responses import StreamingResponse
from front.utils.deps import get_query_service
from front.schema.query_schema import QueryRequest, QueryResponse, StreamSubmitResponse
from front.utils.sse_util import sse_generate

# 创建问答专属路由,统一接口前缀与标签
router = APIRouter(prefix="/api", tags=["智能问答"])

@router.post("/query", response_model=StreamSubmitResponse | QueryResponse, summary="智能问答核心接口")
async def get_query(
    request: QueryRequest,
    background_tasks: BackgroundTasks,
    service: QueryService = Depends(get_query_service)
):
    """
    双模式智能问答核心接口
    1. 流式模式 is_stream=True:后台异步执行问答流水线,立即返回task_id,前端建立SSE长连接接收增量数据
    2. 非流式模式 is_stream=False:同步阻塞执行,模型推理完成后一次性返回完整答案
    双模式设计兼顾交互体验与接口通用性
    """
    # 会话ID复用:无会话则新建,有会话则复用,实现多轮对话上下文关联
    session_id = request.session_id or service.generate_session_id()
    # 生成当前问答任务唯一ID,用于SSE连接绑定、状态追踪
    task_id = service.generate_task_id()
    # 提交任务状态,标记问答任务开始执行
    service.submit_query(task_id, request.is_stream)

    if request.is_stream:
        # 流式问答:异步执行耗时推理逻辑,不阻塞前端响应
        background_tasks.add_task(
            service.run_query_graph,
            task_id, session_id, request.query, True
        )
        # 返回任务ID与会话ID,供前端建立SSE长连接
        return StreamSubmitResponse(
            message="流式问答任务已提交",
            session_id=session_id,
            task_id=task_id
        )
    else:
        # 非流式问答:同步执行,等待推理完成后返回完整答案
        service.run_query_graph(task_id, session_id, request.query, False)
        answer = service.get_answer(task_id)
        return QueryResponse(message="问答成功", session_id=session_id, answer=answer)

@router.get("/stream/{task_id}", summary="SSE流式推送接口")
async def stream_connect(task_id: str, request: Request):
    """
    SSE长连接专属接口
    持续向前端推送三类数据:任务执行进度、答案增量文本、最终完整答案与图片资源
    全程长连接、实时推送、自动断线重连
    """
    return StreamingResponse(
        sse_generate(task_id, request),
        media_type="text/event-stream",
    )

4.2 前端 SSE 流式对话核心逻辑

前端基于浏览器原生 EventSource API 实现 SSE 长连接,无需引入任何第三方库,原生兼容性全覆盖。通过监听三类核心事件,完整实现任务进度可视化、答案逐字流式输出、任务收尾渲染、异常断线重试全套能力,完美复刻商业级 AI 对话产品的交互效果。同时搭配浏览器本地存储实现会话持久化,页面刷新、浏览器重启后依然保留历史对话。

// ========== 对话页面全局环境配置 ==========
// 动态适配端口,支持本地调试端口切换、线上部署自适应
const API_BASE = (() => {
    const host = window.location.hostname;
    // 优先读取本地缓存端口,无配置则默认8001问答服务端口
    const port = localStorage.getItem('kb_api_port') || '8001';
    return `http://${host}:${port}`;
})();
const chatEl = document.getElementById('chatContent');
const inputEl = document.getElementById('userInput');

// ========== 会话ID持久化配置(核心:刷新页面不丢失对话上下文) ==========
let sessionId = localStorage.getItem('kb_session_id');
// 首次进入页面无会话则生成唯一随机会话ID,永久缓存到本地
if (!sessionId) {
    sessionId = 'sess-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
    localStorage.setItem('kb_session_id', sessionId);
}

// 页面加载完成后自动加载历史对话记录
window.onload = loadHistory;

// ========== 发送问答核心函数:兼容流式/非流式双模式 ==========
async function onSend() {
    // 空输入拦截,避免无效请求
    const text = inputEl.value.trim();
    if (!text) return;
    // 渲染用户提问气泡,区分用户与机器人消息
    addUserMsg(text);
    // 渲染机器人加载骨架屏,提升等待体验
    const botMsgEl = addBotMsgSkeleton();
    // 清空输入框,避免重复输入
    inputEl.value = '';

    // 获取页面开关状态,判断是否开启流式输出
    const isStream = document.getElementById('streamToggle').checked;

    // 向后端发起问答请求
    const res = await fetch(`${API_BASE}/api/query`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ 
            query: text,       // 用户提问内容
            session_id: sessionId, // 会话ID,关联上下文
            is_stream: isStream    // 流式模式开关
        })
    });
    const data = await res.json();

    // 非流式模式:一次性接收完整答案,直接渲染
    if (!isStream) {
        finalizeBotAnswer(botMsgEl, data.answer);
        return;
    }

    // ========== 流式模式:开启SSE长连接,实时接收推送数据 ==========
    const eventSource = new EventSource(`${API_BASE}/stream/${data.task_id}`);
    let fullAnswer = ''; // 累计存储完整问答答案,用于最终收尾渲染

    // 1. 监听progress进度事件:实时更新RAG检索、推理各节点执行状态
    eventSource.addEventListener('progress', (e) => {
        const payload = JSON.parse(e.data);
        renderProgress(botMsgEl, payload.done_list, payload.running_list, payload.status);
    });

    // 2. 监听delta增量文本事件:逐字接收模型输出,实现打字机效果
    eventSource.addEventListener('delta', (e) => {
        const payload = JSON.parse(e.data);
        fullAnswer += payload.delta;
        // 实时渲染文字+图片,动态更新问答内容
        renderAnswerWithImages(botMsgEl.querySelector('.answer'), fullAnswer, []);
        // 自动滚动到底部,保证用户始终看到最新内容
        chatEl.scrollTop = chatEl.scrollHeight;
    });

    // 3. 监听final结束事件:任务完成,渲染完整答案与配图,关闭连接释放资源
    eventSource.addEventListener('final', (e) => {
        const payload = JSON.parse(e.data);
        finalizeBotAnswer(botMsgEl, payload.answer, null, payload.image_urls);
        eventSource.close(); // 主动关闭SSE长连接,避免资源占用
    });

    // 4. 全局异常错误处理:断线、接口异常自动兜底
    eventSource.addEventListener('error', () => {
        finalizeBotAnswer(botMsgEl, fullAnswer, '连接中断,请重试');
        eventSource.close();
    });
}

// ========== 历史对话加载函数:页面初始化自动拉取过往问答记录 ==========
async function loadHistory() {
    try {
        // 分页查询历史记录,默认最多加载50条,避免页面数据过多卡顿
        const res = await fetch(`${API_BASE}/history/${sessionId}?limit=50`);
        const data = await res.json();
        // 遍历渲染用户、机器人历史消息,保留时间戳与配图
        data.items.forEach(item => {
            if (item.role === 'user') {
                addUserMsgWithTime(item.text, item.ts);
            } else {
                addBotMsgWithTime(item.text, item.ts, item.image_urls);
            }
        });
    } catch (err) {
        // 静默捕获异常,不影响页面正常使用,控制台打印报错便于调试
        console.log('历史记录加载失败:', err);
    }
}

// ========== 清空对话历史函数:一键清空当前会话所有记录 ==========
document.getElementById('btnClear').onclick = async () => {
    // 调用后端接口删除数据库历史记录
    await fetch(`${API_BASE}/history/${sessionId}`, { method: 'DELETE' });
    // 清空页面所有消息DOM节点
    document.querySelectorAll('.msg').forEach(el => el.remove());
    showToast('对话历史已清空');
}

4.3 智能图文渲染:Markdown 图片链接自动解析

本项目是旅游专属知识库 RAG 系统,旅游问答场景不仅需要文字解答,还需要搭配景点、酒店、美食、攻略等实景图片,才能让回答更直观、更有参考价值。为此,我们自定义实现了一套智能图文解析函数,可自动识别 AI 回答文本中的自定义图片标记,精准提取图片链接、过滤非法资源、渲染图文混排卡片,无需后端单独适配字段,智能适配图文混合回答场景。

/**
 * 智能图文混排渲染函数
 * 核心能力:自动分离AI回答文字内容与图片链接,过滤非法资源,渲染图文卡片
 * @param {DOM} containerEl - 答案渲染容器DOM节点
 * @param {String} answerText - 模型返回的原始混合文本(文字+图片标记+链接)
 * @param {Array} candidateImageUrls - 后端额外返回的图片链接数组(兜底资源)
 */
function renderAnswerWithImages(containerEl, answerText, candidateImageUrls) {
    // 匹配自定义专属图片标记【图片】,精准定位图文分割位置
    const markerIndex = answerText.lastIndexOf('【图片】');
    let text = answerText;
    let rawUrls = [];

    // 拆分纯文本内容与图片链接内容,避免图片链接混入文字展示
    if (markerIndex !== -1) {
        text = answerText.substring(0, markerIndex).trim();
        const afterText = answerText.substring(markerIndex + 4).trim();
        // 正则全局匹配所有合法http/https图片链接
        rawUrls = afterText.match(/(https?:\/\/[^\s]+)/g) || [];
    }

    // 精准过滤合法图片格式,过滤视频、文档、无效链接
    const imageUrls = rawUrls.filter(url => /\.(png|jpe?g|gif|webp|bmp)$/i.test(url));
    
    // 优先渲染纯文本回答内容
    containerEl.innerHTML = `<div class="answer-text">${text}</div>`;
    
    // 存在合法图片则自动渲染图片卡片,实现图文混排
    if (imageUrls.length > 0) {
        const imgWrap = document.createElement('div');
        imgWrap.className = 'answer-images';
        imageUrls.forEach(url => {
            const img = document.createElement('img');
            img.src = url;
            img.style.maxWidth = '100%';
            img.style.borderRadius = '8px';
            img.style.marginTop = '10px';
            imgWrap.appendChild(img);
        });
        containerEl.appendChild(imgWrap);
    }
}

五、跨域与端口适配解决方案

由于本项目采用双服务独立部署架构,知识库导入服务(8000)、智能问答服务(8001)端口相互独立,前后端分离部署模式下,浏览器的同源策略会严格拦截跨域请求,导致前端无法调用后端接口、页面功能完全失效。为解决开发、生产双环境的跨域与端口适配问题,本项目配置了全套跨域解决方案和动态端口适配能力,兼顾开发调试便捷性和生产环境稳定性。

5.1 后端 CORS 跨域配置

基于 FastAPI 内置的 CORS 跨域中间件,全局开启跨域权限,允许所有域名、请求方法、请求头访问服务,完美解决本地开发跨域报错问题。生产环境可根据服务器部署域名,精准配置允许的域名,提升安全性。

# query_router.py / import_router.py 通用跨域配置
from fastapi.middleware.cors import CORSMiddleware

# 全局注册跨域中间件,适配开发环境全量访问
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],        # 允许所有域名跨域访问(开发环境通用配置)
    allow_credentials=True,     # 允许携带Cookie、会话、本地存储等凭证
    allow_methods=["*"],        # 允许GET/POST/PUT/DELETE等所有请求方法
    allow_headers=["*"],        # 允许所有自定义请求头与默认请求头
)

5.2 前端动态端口适配

为避免频繁修改代码切换服务端口、适配不同部署环境,前端基于 localStorage 实现动态端口配置,开发者可手动修改本地缓存端口,一键切换服务地址,无需改动源码,极大提升调试效率,适配多环境迭代开发。

// 前端动态端口适配核心代码,兼容多环境部署
const API_BASE = (() => {
    const host = window.location.hostname;
    // 优先读取本地自定义端口,无配置则默认8001问答服务端口
    const port = localStorage.getItem('kb_api_port') || '8001';
    return `http://${host}:${port}`;
})();

六、全文总结与技术亮点复盘

6.1 本篇核心实现能力

本文从零完整搭建了旅游知识库 RAG 系统的全套前端交互架构,打通了文件导入、任务管理、流式问答、会话持久化、图文展示、跨域适配全链路,最终实现以下核心能力:

  1. 全局可视化任务状态管理:基于内存字典+全局单例服务,实现全业务流程节点状态精准记录、实时同步,彻底解决异步任务黑盒问题。

  2. 大文件异步无阻塞导入:依托 FastAPI 后台异步任务,解耦耗时处理逻辑,搭配前端轮询实现进度可视化、任务可取消,适配超大知识库文件入库场景。

  3. 轻量高性能流式对话:基于原生 SSE 实现单向流式推送,无需框架依赖,实现媲美商业产品的打字机问答效果,消除用户等待焦虑。

  4. 永久会话持久化:通过浏览器本地存储缓存唯一会话ID,页面刷新、浏览器重启后依然保留对话上下文与历史记录,实现连续多轮对话。

  5. 智能图文混排渲染:自定义解析规则,自动识别、过滤、渲染 AI 回答中的旅游图片资源,贴合旅游知识库场景需求。

  6. 全环境兼容适配:配套完整跨域配置与动态端口适配,同时兼容本地开发、服务器部署环境,可直接上线商用。

6.2 架构设计核心优势

整套前端架构摒弃过度设计、冗余依赖、复杂语法,以实用、稳定、轻量、可扩展为核心设计理念,具备四大核心优势:

  • 低耦合易维护:前后端职责清晰、业务模块分层、数据统一规范,迭代新功能无需改动核心代码,维护成本极低。

  • 超轻量化部署:无任何前端框架、打包工具、第三方依赖,纯原生代码运行,部署简单、服务器资源占用极低。

  • 高可用强容错:支持任务取消、连接重连、异常捕获、状态兜底,避免单点异常导致整体功能失效,适配生产环境。

  • 高可扩展性:业务节点、接口端口、渲染规则均可配置,新增业务场景无需重构架构,适配长期迭代开发。

七、下篇预告

本文重点拆解了 RAG 系统的前端交互架构与落地实现,而下一篇后端核心原理篇,将带大家深入 RAG 底层核心,逐一拆解整套旅游知识库的核心算法与流水线逻辑,吃透项目核心技术壁垒:

  • MinerU 高精度 PDF 解析原理,实现无损转换 Markdown 格式的底层逻辑

  • GPT-4V 多模态大模型图片摘要生成 + MinIO 对象存储完整落地方案

  • 基于文档标题层级的智能语义切片算法,解决长文档分割碎片化问题

  • 旅游专属实体(景点/酒店/美食)LLM 智能识别、提取与清洗策略

  • 旅游文本向量化优化策略与 Milvus 向量库批量入库优化方案

  • LangGraph 全流程流水线串联、异常捕获、重试机制源码深度解析

大家在项目部署、代码运行过程中,若遇到 SSE 连接失败、跨域报错、轮询超时、文件上传失败、进度不更新 等问题,欢迎评论区留言交流,全程答疑解惑!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐