使用fetchEventSource构建高效AI智能助手:文件搜索场景的完整实现与深度解析

使用fetchEventSource构建高效AI智能助手:文件搜索场景的完整实现与深度解析

在当今AI技术飞速发展的浪潮中,如何将大模型的能力无缝集成到企业应用中,成为每个开发者面临的挑战。今天,我将分享一个基于fetchEventSource技术构建的AI智能文件搜索助手的完整实现,该组件已在企业级文档管理系统中成功应用,显著提升了用户检索效率。我们将深入剖析其技术实现,包括流式响应、SSE通信、Markdown渲染等核心功能。

一、技术背景:为什么选择SSE与fetchEventSource

在实现AI对话类应用时,我们通常面临两种技术路线选择:

  1. WebSocket:双向全双工通信,适合实时交互场景
  2. Server-Sent Events (SSE):单向服务器推送,轻量级实现

对于AI助手这类服务器持续生成内容,客户端只需接收的场景,SSE具有明显优势:

  • 基于HTTP协议,无需特殊协议支持
  • 自动重连机制
  • 简单的消息格式
  • 浏览器原生支持

@microsoft/fetch-event-source库则为我们提供了现代化、Promise风格的SSE API封装,相比原生EventSource具有更灵活的配置和控制能力。

二、功能需求与产品设计

我们的AI智能文件搜索助手需要实现以下核心功能:

  1. 自然语言搜索:用户通过对话式提问搜索文件
  2. 流式响应:AI生成内容时逐步展示,提升用户体验
  3. 相关文件展示:搜索结果附带相关文件推荐
  4. 历史记录管理:记录并展示用户最近的搜索记录
  5. 富文本支持:支持Markdown、数学公式等复杂内容渲染
  6. 文件预览集成:点击文件可直接查看内容

三、核心代码实现:从设计到落地

1. 组件结构与状态管理

首先,我们定义组件的核心数据结构:

typescript 复制代码
export default class AiIntelligentSearch extends Vue {
  private searchQuery = ''; // 用户输入的搜索内容
  private loading = false; // 加载状态
  private recentSearches = [] as ISearchInfo[]; // 最近搜索记录
  private searchFileList: Array<{ // 搜索结果列表
    question: string;
    content: string;
    count: number;
    fileList: IFile[] | Array<any>;
  }> = [];
  private selectedTag = -1; // 选中的历史记录标签
  private fileListStatus: { [key: number]: boolean } = {}; // 文件列表展开/收起状态
  // ...其他状态
}

2. 流式响应实现:fetchEventSource深度应用

核心在于aiStreamSearchFileList方法,它使用fetchEventSource实现服务器推送:

typescript 复制代码
async aiStreamSearchFileList() {
  const ctrl = new AbortController(); // 用于中断请求
  
  try {
    await fetchEventSource(process.env.VUE_APP_BASE_URL + '/api/aiStreamSearchFileList', {
      headers: {
        'Content-Type': 'application/json',
        Accept: 'text/event-stream',
      },
      body: JSON.stringify({
        limit: 10,
        offset: 1,
        st: this.searchQuery.trim() + '',
        sort: 0,
        sortType: 1,
        fileAppCode: 'file_management',
      }),
      method: 'POST',
      signal: ctrl.signal, // 传递信号以支持中断请求
      openWhenHidden: true, // 页面退至后台时保持连接
      
      // 连接建立回调
      onopen: async (response) => {
        if (!response.ok) {
          console.error('连接失败:', response.statusText);
          ctrl.abort();
        }
      },
      
      // 收到消息回调
      onmessage: (event) => {
        try {
          const data = JSON.parse(event.data);
          if (data) {
            if (data.status !== 'completed') {
              // 处理流式数据
              if (data.choices && data.choices[0].delta) {
                const content = data.choices[0].delta.content;
                if (content) {
                  // 将内容追加到最新的搜索结果
                  if (this.searchFileList.length > 0) {
                    this.searchFileList[this.searchFileList.length - 1].content += content;
                  }
                }
              }
              // 强制更新视图,显示最新内容
              this.$forceUpdate();
            } else {
              // 处理完成状态
              const lastItem = this.searchFileList[this.searchFileList.length - 1];
              if (!lastItem.content) {
                lastItem.content = '未找到符合相似度要求的文档。';
              }
              ctrl.abort(); // 中断请求
              this.loading = false;
              this.$forceUpdate();
            }
          }
        } catch (parseError) {
          console.error('解析消息时出错:', parseError);
          ctrl.abort();
          this.loading = false;
        }
      },
      
      // 错误处理
      onerror: (error) => {
        console.error('发生错误:', error);
        ctrl.abort();
        this.loading = false;
        this.$message.error('服务器连接中断,请稍后重试');
      },
      
      // 连接关闭
      onclose: () => {
        ctrl.abort();
        this.loading = false;
        console.log('连接已关闭');
      },
    });
  } catch (error) {
    console.error('启动流式响应时出错:', error);
    ctrl.abort();
    this.loading = false;
    this.$message.error('无法启动流式响应,请检查网络或服务器状态');
  }
}

关键点解析

  • 使用AbortController实现请求可取消
  • onmessage中处理流式数据,追加到最新搜索结果
  • this.$forceUpdate()确保Vue及时渲染新增内容
  • 错误处理完善,保证用户体验

3. 搜索执行流程整合

将普通搜索API与流式响应API结合使用:

typescript 复制代码
async searchAIFileList() {
  this.loading = true;
  const param = {
    limit: 10,
    offset: 1,
    st: this.searchQuery.trim(),
    sort: 0,
    sortType: 1,
  };

  // 1. 先调用普通搜索API获取文件列表
  const [err, res] = await to(EMSearchFileList(param));
  
  if (err) {
    this.$message.error('搜索失败,请稍后重试');
    this.loading = false;
    return;
  }

  // 2. 构建初始搜索结果
  const fileList = Array.isArray(res) ? res : [];
  const searchResult = {
    fileList,
    content: '',
    count: fileList.length || 0,
    question: this.searchQuery.trim(),
  };

  // 3. 无结果时设置默认提示
  if (!res || fileList.length === 0) {
    searchResult.content = '未找到符合相似度要求的文档。';
  }
  
  // 4. 添加到结果列表
  this.searchFileList.push(searchResult);

  // 5. 等待DOM更新后滚动到底部
  this.$nextTick(() => {
    this.scrollToBottom();
  });

  // 6. 执行流式响应获取AI生成内容
  await this.aiStreamSearchFileList();
  
  // 7. 更新最近搜索记录
  await this.getRecentSearchList();
  
  // 8. 清空输入框
  setTimeout(() => {
    this.searchQuery = '';
    this.loading = false;
  }, 1000);
}

这种两阶段设计确保了:

  1. 先快速返回文件列表,提供即时反馈
  2. 再通过流式响应逐步生成详细内容
  3. 最后更新搜索历史,完善用户体验

4. Markdown与数学公式渲染

为了支持复杂内容展示,我们集成了markdown-itmarkdown-it-katex

typescript 复制代码
renderedMarkdown(content: string) {
  const md = new MarkdownIt({
    html: true, // 启用HTML标签
    linkify: true, // 自动识别URL
    typographer: true, // 启用智能引号等
  });
  
  // 添加数学公式支持
  md.use(markdownItKatex, {
    blockClass: 'katex-block',
    errorColor: '#cc0000',
    throwOnError: false,
    macros: {
      "\\RR": "\\mathbb{R}",
      "\\p": "\\frac{\\partial #1}{\\partial #2}",
    }
  });
  
  // 自定义渲染规则
  md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
    const token = tokens[idx];
    token.attrPush(['target', '_blank']); // 增加target="_blank"
    token.attrPush(['rel', 'noopener noreferrer']); // 增加安全属性
    return self.renderToken(tokens, idx, options);
  };

  return md.render(content || '');
}

在模板中使用:

html 复制代码
<div class="answer-header">
  <span v-html="renderedMarkdown(item.content)"></span>
</div>

5. UI设计与交互优化

组件采用精心设计的UI布局,确保良好的用户体验:

html 复制代码
<div class="section-list" ref="sectionList">
  <div class="qa-list" v-for="(item, index) in searchFileList" :key="index">
    <!-- 用户问题 -->
    <div class="question-item">
      <div class="question-content" @click="copyQuestion(item)">
        <span class="text">{{ item.question }}</span>
      </div>
    </div>

    <!-- AI 回复 -->
    <div class="answer-item">
      <div class="answer-content">
        <!-- 支持Markdown和数学公式 -->
        <div class="answer-header">
          <span v-html="renderedMarkdown(item.content)"></span>
        </div>
        
        <!-- 相关文件展示 -->
        <div class="result-header" v-if="item?.fileList && item.fileList.length > 0">
          <div class="result-count">
            <img src="@/assets/file-ai-list-search.svg" alt="" />
            <span>共找到{{ item?.fileList.length }}个相关文件</span>
          </div>
          <div class="result-actions">
            <span @click="toggleFileList(index)">
              {{ fileListStatus[index] ? '展开' : '收起' }}
            </span>
          </div>
        </div>
        
        <!-- 文件列表(可展开/收起) -->
        <div class="file-list" v-show="!fileListStatus[index]">
          <div class="file-item" v-for="file in item.fileList" :key="file.fileID"
               @click="viewFile(file)">
            <div class="file-info">
              <img :src="iconSrc(file.fiType || '')" alt="" />
              <span class="file-ai-name">{{ file.oldFileName }}</span>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>

关键交互细节:

  • 问题内容可点击复制
  • 文件列表可展开/收起
  • 支持文件图标根据类型动态显示
  • 搜索历史标签支持点击重搜

6. 实用工具方法

typescript 复制代码
// 滚动到最新消息
scrollToBottom() {
  const sectionList = this.$refs.sectionList as HTMLElement;
  if (sectionList) {
    const lastQaList = sectionList.querySelectorAll('.qa-list')[this.searchFileList.length - 1];
    if (lastQaList) {
      lastQaList.scrollIntoView({
        behavior: 'smooth',
        block: 'start',
      });
    }
  }
}

// 复制问题内容
copyQuestion(item: any) {
  const textToCopy = item.question || '';
  navigator.clipboard.writeText(textToCopy).then(() => {
    message.success('复制成功');
  }).catch(() => {
    message.warning('复制失败');
  });
}

// 获取文件图标
get iconSrc() {
  return (suffix: string) => {
    return requireIcon(suffix);
  };
}

四、性能优化与错误处理

1. 请求中断机制

使用AbortController实现请求可取消:

typescript 复制代码
const ctrl = new AbortController();
// 传递信号
await fetchEventSource(url, { signal: ctrl.signal });
// 需要中断时
ctrl.abort();

2. 错误处理策略

typescript 复制代码
onerror: (error) => {
  console.error('发生错误:', error);
  ctrl.abort(); // 确保请求终止
  this.loading = false;
  // 显示用户友好错误
  this.$message.error('服务器连接中断,请稍后重试');
},

3. 空状态处理

typescript 复制代码
// 处理完成状态
if (data.status === 'completed') {
  const lastItem = this.searchFileList[this.searchFileList.length - 1];
  // 确保空内容时有提示
  if (!lastItem.content) {
    lastItem.content = '未找到符合相似度要求的文档。';
  }
  // 确保fileList为数组
  if (!Array.isArray(lastItem.fileList)) {
    lastItem.fileList = [];
  }
  lastItem.count = lastItem.fileList.length || 0;
}

五、部署与集成

组件完整集成到企业文档管理系统中,需要处理的集成点:

  1. 认证与授权:在请求头中添加token
  2. 文件预览:集成现有文件预览组件
typescript 复制代码
viewFile(item: IFile) {
  this.detailFileModal.open(item.fileID);
  // 添加文件访问记录
  ManageModule.addFileRecord(item.fileID);
}
  1. 环境变量配置 :使用process.env.VUE_APP_BASE_URL配置API地址

六、总结与展望

通过fetchEventSource实现的AI智能文件搜索助手,完美解决了传统搜索体验的不足:

  • 实时反馈:流式响应让等待感消失
  • 自然交互:对话式搜索降低使用门槛
  • 精准结果:AI理解用户真实意图
  • 无缝集成:与现有文件系统完美融合

未来优化方向

  1. 增加上下文感知,支持多轮对话
  2. 优化提示工程,提高搜索准确率
  3. 添加个性化推荐,基于用户历史行为
  4. 支持多语言处理,满足国际化需求

七、完整代码示例

以下是组件的核心部分,完整代码请参考文末GitHub链接:

vue 复制代码
<template>
  <div style="overflow: hidden; height: 100vh">
    <main-header-component
      :is-show-store-btn="false"
      :is-show-search="false"
      :is-show-upload-btn="false"
    ></main-header-component>
    <a-spin size="large" :spinning="loading" tip="AI大模型智能搜索中,请稍后...">
      <div class="ai-search-container">
        <div class="top-section">
          <div class="section-content">
            <div class="ai-search-header">
              <div class="header-avatar">
                <img src="@/assets/file-ai-search-logo.svg" alt="" class="avatar-img" />
              </div>
              <div class="header-info">
                <div style="display: flex; align-items: center; gap: 10px">
                  <span class="title">文件搜索助手</span>
                  <span class="result-actions" style="cursor: pointer" @click="handleShowSearch">
                    {{ data.isShowSearch ? '收起' : '展开' }}
                  </span>
                </div>
              </div>
            </div>

            <div class="ai-search-desc" v-if="data.isShowSearch">
              AI搜索以自然语言处理和语义理解为核心,通过智能分类、个性化推荐及跨平台实时检索技术,精准捕捉用户意图并快速定位目标内容,同时支持多模态数据的关联分析,实现"对话式"高效搜索体验。
            </div>

            <div class="recent-searches" v-if="data.isShowSearch && recentSearches.length > 0">
              <span class="label">最近搜索</span>
              <div class="tag-list">
                <div class="search-item" v-for="tag in recentSearches" :key="tag.siId"
                     :class="{ active: selectedTag === tag.siId }" @click="onTagClick(tag)">
                  <img v-if="selectedTag === tag.siId" src="@/assets/file-ai-question-search.svg" alt="" />
                  <span class="item-text">{{ tag.content?.length > 10 ? tag.content.slice(0, 10) + '...' : tag.content }}</span>
                </div>
              </div>
            </div>
          </div>
          
          <!-- 问答列表 -->
          <div class="section-list" ref="sectionList">
            <!-- ...问答项渲染 -->
          </div>
        </div>
        
        <!-- 底部输入区域 -->
        <div class="bottom-section">
          <div class="input-area">
            <a-textarea
              v-model="searchQuery"
              :disabled="loading"
              placeholder="输入内容关键字开始智能检索,AI 助手帮您快速定位"
              :auto-size="{ minRows: 1, maxRows: 3 }"
              @pressEnter.prevent="handleSearch"
            />
            <div class="send-button" :class="{ disabled: !searchQuery.trim() || loading }" 
                 @click="handleSearch">
              <img src="@/assets/file-ai-send.svg" alt="发送" />
            </div>
          </div>
          <div class="hint-area">
            <span class="hint-text">内容由各平台大模型生成,不能完全保证准确性和完整性,不代表我们的态度或观点</span>
          </div>
        </div>
      </div>
    </a-spin>
    <file-detail-component ref="detailFileModal"></file-detail-component>
  </div>
</template>

这个实现不仅展示了fetchEventSource在AI应用中的强大能力,更提供了一套完整的企业级解决方案。通过精心设计的UI/UX和稳定的错误处理机制,为用户提供了流畅、可靠的智能搜索体验。希望这篇文章能为你的AI应用开发提供有价值的参考!