基于Vue 3 + TypeScript + Vite 构建的智能问答助手项目

一、项目介绍

这是一个基于 Vue 3 + TypeScript + Vite 构建的智能问答助手项目。项目采用现代前端技术栈,具有流式对话、语音输入、富文本解析等特色功能,页面交互简洁友好,按钮防抖和数据校验,提供流畅的用户体验和良好的开发维护性。

二、项目开发背景

  • 继deepseek大火后,很多公司开始推出自己的应用层AI产品,最近收到通知,要求火速上线一个AI问答产品,以彰显公司在AI领域迈出关键的一步。
  • 领导要求实现一个类似于deepseek的建议版AI问答产品,输入方式支持文本和语音输入两种,需要支持pc端和移动端功能演示。
  • 需求梳理:基于vue3+vite+ts+element 创建一个AI问答系统,需要移动端适配并且支持pc端展示,输入方式支持文本输入和语音输入,对话方式需支持直接输出(简单高效的)、打字机输出(一个字一个字,适合交换强的,趣味性的)、流式输出(一行一行或者一段一段,适合快速获取信息的)。

三、技术栈

  • Vue 3 :使用 Vue 3 作为前端框架,采用组合式 API 和 <script setup> 语法。
  • TypeScript:全面的类型支持,提供更可靠的代码质量。
  • Vite:现代化构建工具,提供极速的开发体验。
  • Element Plus:美观实用的 UI 组件库。
  • Vue Router:灵活的路由管理方案。
  • Marked + DOMPurify:安全的 Markdown 渲染。
  • Highlight.js:代码语法高亮支持。
  • Axios:可靠的 HTTP 请求处理。
  • Fetch:SSE 请求调用。
  • Trae: 不懂就问编辑器,主要是free。

四、核心功能

  • 流式对话: 支持流式文本渲染,提供更自然的对话体验
  • 打字机输出: 一个字一个字的,交互强,有趣味
  • 语音交互: 支持语音输入,提升用户交互效率
  • Markdown 渲染: 支持富文本格式和代码高亮显示
  • 响应式设计: 完善的移动端适配,支持多端访问
  • 智能问答: 集成 AI 模型,提供准确的招生信息咨询服务

五、效果演示、亮点

基于vue3+vite+ts+element实现,预设问题列表,消息复制,刷新,反馈,停止,消息展示方式支持打字机输出模式和流式输出,消息同时支持富文本解析,问题输入方式支持文本输入和语音输入。支持自适应移动端和pc端,具有良好的页面交互和兼容性。是一个简易轻便开箱即用的前端AI问答系统。

  • 普通对话(打字机模式)、流式对话、语音识别转换成文字
  • 支持移动端效果展示

六、项目结构

bash 复制代码
```
├── src/                  # 源代码目录
│   ├── assets/          # 静态资源(图片、样式等)
│   ├── components/      # 公共组件
│   │   ├── chat/       # 对话相关组件
│   │   │   ├── ChatContainer.vue  # 聊天容器组件:管理对话界面布局和消息列表
│   │   │   ├── ChatInput.vue      # 输入组件:处理用户输入和语音识别
│   │   │   ├── MessageBubble.vue  # 消息气泡组件:展示对话消息内容
│   │   │   └── TypeWriter.vue     # 打字机效果组件:实现文字打字机效果
│   │   └── MarkdownRenderer.vue  # Markdown渲染组件
│   ├── config/         # 配置文件
│   ├── router/         # 路由配置
│   ├── services/       # API服务
│   │   ├── aiService.ts       # AI服务接口
│   │   ├── streamService.ts   # 流式服务
│   │   └── api.ts             # 基础API
│   ├── type/           # TypeScript类型定义
│   ├── utils/          # 工具函数
│   ├── views/          # 页面视图
│   │   ├── ChatView.vue      # 普通对话页面
│   │   └── StreamView.vue    # 流式对话页面
│   └── main.ts         # 应用入口文件
├── public/             # 公共资源目录
├── .env.*              # 环境配置文件
└── vite.config.ts      # Vite配置文件
```

七、主要功能模块实现

  • 打字机模式: 实现思路:延时器+字符串方法slice ,隔一个时间间隔speed取一个字符。
xml 复制代码
<template>
  <!-- 打字机效果组件 -->
  <div>
  <slot :text="displayText">
  <markdown-renderer :markdown="displayText" />
  </slot>
  </div>
  </template>

<script setup lang="ts">
import { ref, watch, onUnmounted } from "vue";
import MarkdownRenderer from "../MarkdownRenderer.vue";

// 组件属性定义
const props = defineProps<{
  text: string; // 要显示的文本
speed?: number; // 打字速度
delay?: number; // 开始延迟
  }>();

// 定义事件
const emit = defineEmits<{
  complete: []; // 打字完成事件
textUpdate: [text: string]; // 文本更新事件
}>();

// 组件状态
const displayText = ref("");
let currentIndex = 0;
let timer: number | null = null;

// 打字效果实现
const startTyping = () => {
  if (currentIndex < props.text.length) {
    displayText.value = props.text.slice(0, currentIndex + 1);
    emit("textUpdate", displayText.value);
    currentIndex++;
    timer = window.setTimeout(startTyping, props.speed || 30);
  } else {
    emit("complete");
  }
};

// 监听文本变化
watch(
  () => props.text,
  () => {
    if (timer) {
      clearTimeout(timer);
    }
    currentIndex = 0;
    displayText.value = "";
    timer = window.setTimeout(startTyping, props.delay || 0);
  },
  { immediate: true }
);

// 组件卸载时清理定时器
onUnmounted(() => {
  if (timer) {
    clearTimeout(timer);
  }
});
</script>

<style scoped>
.typewriter {
  display: inline-block;
}

/* 确保 markdown 内容正确显示 */
:deep(.markdown-renderer) {
  word-break: break-word;
}
</style>
  • 流式对话: 实现思路:sse(服务端推送),客户端解析字符进行一段一段渲染数据据。fetchAPI本身支持流式读取响应体,通过response.body获取readableStream对象,创建reader,然后选好调用read()方法,直到done为true.每次读取value时,创建TextDecoder对象,处理数据,转换成字符串,然后去掉一些多余的字符,返回到页面展示。

a.流式接口:

typescript 复制代码
import config from "@/config";

class StreamService {
  private abortController: AbortController | null = null;

  async streamChat(
    message: string,
    assistantId: string,
    onChunk: (chunk: string) => void,
    streamSuccess?: () => void,
    onError?: (error: Error) => void,
    onComplete?: () => void
  ): Promise<() => void> {
  this.abortController = new AbortController();

try {
  const response = await fetch(`${config.baseURL}/chat`, {
    method: "POST",
    signal: this.abortController.signal,
    headers: {
      "Content-Type": "application/json",
      Accept: "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive"
    },
    body: JSON.stringify({
      m: msg
    })
  });

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  if (!response.body) {
    throw new Error("ReadableStream not supported");
  }

  streamSuccess?.();

  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { value, done } = await reader.read();
    if (done) {
      onComplete?.();
      this.abortController = null;
      break;
    }

    const chunk = decoder.decode(value);
    let messages = chunk.replace(/"data:"/g, ""); // 去掉"data:"
    messages = messages.replace(/""/g, "");//去掉""
    messages = messages.replace(/"/g, ""); //去掉"(左引号)
    messages = messages.replace(/\n\n/g, "");//去掉"(右引号)
    messages = messages.replace(/\ndata:/g, "\n");//换行符替换

    if (messages) onChunk(messages);
  }
} catch (error: unknown) {
  if (error instanceof Error && error.name === "AbortError") {
    console.log("Fetch aborted");
    return () => {
      this.cancelStream();
      onComplete?.();
    };
  }

  console.error("Stream Error:", error);
  if (onError) {
    onError(error instanceof Error ? error : new Error("流式连接失败"));
  }
} finally {
  this.abortController = null;
}

return () => {
  this.cancelStream();
  onComplete?.();
};
}

cancelStream(): void {
  if (this.abortController) {
    this.abortController.abort();
    this.abortController = null;
  }
}
}

export const streamService = new StreamService();

b.组件调用:

typescript 复制代码
// 流式渲染请求
import { streamService } from "../services/fetchStreamService";
const messages = ref<Array<ChatMessage>>([]); // 聊天消息列表
const loading = ref(false); // 是否请求中

let currentCleanup: (() => void) | null = null; // 缓存当前请求 调用取消

const handleSendMessage = async (content: string) => {
  loading.value = true;
  // 添加用户消息
  const userMessage: ChatMessage = {
    chatMessageId: "user", //用户消息添加一个默认的ID
    content,
    role: "user",
    timestamp: Date.now()
  };
  messages.value.push(userMessage);
  const assistantMessage = {
    chatMessageId: "",
    role: "assistant" as const,
    content: "",
    timestamp: Date.now()
  };
  try {
    const streamSuccess = () => {
      messages.value.push(assistantMessage);
      return assistantMessage; // 返回消息对象供后续使用
    };
    // 流式回调处理
    const streamCallback = (chunk: string) => {
      assistantMessage.content += chunk;
      console.log("assistantMessage", assistantMessage.content);
      // 强制更新最后一条消息的内容
      messages.value = [
        ...messages.value.slice(0, -1),
        {
          ...messages.value[messages.value.length - 1],
          chatMessageId: assistantMessage.chatMessageId,
          content: assistantMessage.content
        }
      ];
    };

    try {
      currentCleanup = await streamService.streamChat(
        content,
        assistantId.value || "",
        streamCallback,
        streamSuccess,
        undefined,
        () => {
          loading.value = false;
          currentCleanup = null;
        }
      );
    } catch (error) {
      console.error("Stream chat error:", error);
      resetMessage();
    }
  } catch (error) {
    resetMessage();
  }
};

const resetMessage = () => {
  loading.value = false;
  // 请求失败添加默认信息展示
  messages.value.push({
    chatMessageId: "fail", //加载失败消息ID
    content: "加载失败,请重试",
    role: "assistant",
    timestamp: Date.now()
  });
};
  • 语音识别: 实现思路:考虑兼容多端,使用webapi MediaRecorder录音,获取录音文件后上传到服务器生成文件地址,然后调用阿里的语音转文字接口返回字符串。
ini 复制代码
// 初始化录音器
const initRecorder = async () => {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: true,
      video: false
    });
    mediaRecorder = new MediaRecorder(stream);

    mediaRecorder.ondataavailable = (event) => {
      if (event.data.size > 0) {
        audioChunks.push(event.data);
      }
    };

    return true;
  } catch (error: any) {
    console.error("初始化-录音初始化失败:", error);
    let errorMessage = "无法访问麦克风";
    if (error.name === "NotAllowedError") {
      errorMessage = "请允许浏览器使用麦克风并刷新页面重试";
    } else if (error.name === "NotFoundError") {
      errorMessage = "未找到麦克风设备,请检查设备连接";
    }
    ElMessage.error(errorMessage);
    return false;
  }
};

// 开始录音
const startVoiceInput = async (event: MouseEvent | TouchEvent) => {
  // 检查当前状态
  if (isRecording.value || isRecognizing.value || props.disabled) {
    return;
  }
  console.log("startVoiceInput", event instanceof MouseEvent, isMobile.value);
  if (!mediaRecorder) {
    const initialized = await initRecorder();
    if (!initialized) return;
  }
  try {
    isCanceling.value = false;
    audioChunks = [];
    mediaRecorder?.start();
    isRecording.value = true;
    // 设置2分钟录音限制
    recordingTimer = window.setTimeout(() => {
      if (isRecording.value) {
        stopVoiceInput();
        ElMessage.info("录音已达到30s,自动发送");
      }
    }, 30000); // 30 = 300000毫秒
    ElMessage.success("开始录音...");
  } catch (error) {
    console.error("开始录音失败:", error);
    ElMessage.error("开始录音失败");
  }
};

//取消发送
const cancelVoiceInput = () => {
  console.log("取消");
  isCanceling.value = true;
  stopVoiceInput();
};

// 停止语音输入
const stopVoiceInput = async () => {
  console.log("stopVoiceInput-------------");
  if (!mediaRecorder || !isRecording.value) return;
  try {
    isRecording.value = false;
    // 清除录音计时器
    if (recordingTimer) {
      clearTimeout(recordingTimer);
      recordingTimer = null;
    }

    // 如果是上滑取消,则不处理录音数据
    if (isCanceling.value) {
      mediaRecorder.stop();
      audioChunks = [];
      isRecognizing.value = false;
      ElMessage.info("已取消录音");
      return;
    }

    // 停止录音前确保状态正确
    if (mediaRecorder.state === "inactive") return;

    isRecognizing.value = true;
    mediaRecorder.stop();
    // 等待录音数据处理完成
    const audioBlob = await new Promise<Blob>((resolve, reject) => {
      let timeout = setTimeout(() => {
        reject(new Error("录音数据处理超时"));
      }, 3000);

      mediaRecorder!.onstop = () => {
        clearTimeout(timeout);
        if (audioChunks.length === 0) {
          reject(new Error("未获取到录音数据"));
          return;
        }
        const blob = new Blob(audioChunks, { type: "audio/wav" });
        resolve(blob);
      };
    });

    // 验证音频大小
    if (audioBlob.size < 1024) {
      // 约1KB,相当于约1秒的音频
      throw new Error("录音时间过短,请至少说话1秒以上");
    }
    // 将音频数据转换为文件并通过FormData上传
    const formData = new FormData();
    formData.append("file", audioBlob, "record.wav");
    const uploadResponse = await API.upload(formData);
    if (!uploadResponse) {
      throw new Error("音频文件上传失败");
    }
    // 将音频转换为文本
    const voiceTextResponse = await API.voiceTransText(uploadResponse);
    isRecognizing.value = false;
    isRecording.value = false;
    if (!voiceTextResponse) {
      throw new Error("语音转文字失败");
    }
    message.value = voiceTextResponse;
    await handleSend();
  } catch (error: any) {
    isRecognizing.value = false;
    isRecording.value = false;
    console.error("语音处理失败:", isRecording.value, error);
    ElMessage.error(error?.message || "语音处理失败");
  } finally {
    // 清理录音数据并重置状态
    audioChunks = [];
  }
};

// 发送消息处理
const handleSend = () => {
  const trimmedMessage = message.value.trim();
  if (!trimmedMessage || props.disabled) return;
  if (trimmedMessage.length > 120) {
    ElMessage.warning("消息长度不能超过120个字符");
    return;
  }
  emit("send", trimmedMessage);
  message.value = "";
  if (isRecording.value) {
    stopVoiceInput();
  }
};

八、pc端展示和移动端适配

ini 复制代码
//项目使用实现移动端适配:

 /* - 使用rem适配
  * - 支持响应式布局
  * - 优化移动端交互体验
  */
import { debounce } from "lodash-es";

const doc: Document = window.document;
const docEl: HTMLElement = doc.documentElement;

// 设置根元素字体大小,用于rem适配
const setRem = debounce(() => {
  // 获取视口宽度,并限制最大宽度为768px
  const maxWidth = 768;
  const clientWidth = Math.min(docEl.clientWidth, maxWidth);

  // 基于750px设计稿,1rem = 100px
  // 基于375px设计稿,1rem = 50px
  const baseSize = 7.5;
  docEl.style.fontSize = `${clientWidth / baseSize}px`;
}, 100);

// 初始化rem
window.addEventListener("load", setRem);

// 监听窗口变化和DOM加载完成事件
if (window.addEventListener) {
  window.addEventListener("resize", setRem, false);
  doc.addEventListener("DOMContentLoaded", setRem, false);
}

九、服务端接口

  • 普通aixos接口
typescript 复制代码
/**
 * AI服务接口封装
 * 用于处理与AI后端API的通信
 */
import axios from "axios";
import config from "@/config";
import type { AxiosInstance } from "axios";
import { ElMessage } from "element-plus";

class AIChatService {
  private instance: AxiosInstance;
  private baseURL = config.baseURL;

  constructor() {
    // 创建 axios 实例
    this.instance = axios.create({
      baseURL: this.baseURL,
      timeout: 30000,
      headers: {
        "Content-Type": "application/json"
      }
    });

    // 请求拦截器
    this.instance.interceptors.request.use(
      (config) => {
        // 在这里可以添加 token 等认证信息
        // const token = localStorage.getItem("token");
        // if (token) {
        //   config.headers.Authorization = `Bearer ${token}`;
        // }
        return config;
      },
      (error) => {
        let message = "请求配置错误";

        if (error.code === "ECONNABORTED") {
          message = "请求超时,正在重试...";
        } else if (error.message?.includes("Network Error")) {
          message = "网络连接失败,请检查网络设置";
        } else if (error.config) {
          message = `请求参数错误: ${error.config.url}`;
        }

        console.error("Request Config Error:", error);
        ElMessage.warning(message);
        return Promise.reject(error);
      }
    );

    // 响应拦截器
    this.instance.interceptors.response.use(
      (response) => {
        const { data } = response;
        // 检查服务端返回的数据结构
        if (data.code !== undefined) {
          // 假设服务端返回格式为 { code: number, message: string, data: any }
          if (data.code !== 200 && data.code !== "0") {
            const errorMsg = data.message || "系统繁忙,请稍后重试";
            ElMessage.error(errorMsg);
            return Promise.reject(errorMsg);
          }
          return data.data; // 只返回实际数据部分
        }
        return data; // 保持原有返回格式
      },
      (error) => {
        let message = "服务暂时不可用,请稍后重试";

        if (error.response) {
          // 服务器返回错误状态码
          switch (error.response.status) {
            case 400:
              message = "请求参数格式不正确";
              break;
            case 401:
              message = "登录已过期,请重新登录";
              // 可以在这里处理登录过期逻辑
              break;
            case 403:
              message = "没有权限访问该资源";
              break;
            case 404:
              message = "请求的资源不存在";
              break;
            case 500:
              message = "服务器内部错误,请稍后重试";
              break;
            default:
              message = `请求失败 (${error.response.status})`;
          }
        } else if (error.request) {
          // 请求发出但未收到响应
          // message = "无法连接到服务器,请检查网络";
        }

        console.error("Request Error:", error);
        // ElMessage.error(message || "信号弱,请稍后重试!!");
        return Promise.reject(error);
      }
    );
  }

  // GET 请求方法
  async get<T>(url: string, params?: any, config?: any): Promise<T> {
    try {
      // 合并请求配置
      const requestConfig = { params, ...config };
      // 确保headers被正确合并
      if (config?.headers) {
        requestConfig.headers = {
          ...this.instance.defaults.headers,
          ...config.headers
        };
      }
      const response = await this.instance.get(url, requestConfig);
      return response as T;
    } catch (error: any) {
      throw error;
      console.error("POST Request Error:", error);
    }
  }

  // POST 请求方法
  async post<T>(url: string, data?: any, config?: any): Promise<T> {
    try {
      const requestConfig = { ...config };
      // 确保headers被正确合并
      if (config?.headers) {
        requestConfig.headers = {
          ...this.instance.defaults.headers,
          ...config.headers
        };
      }
      const response = await this.instance.post(url, data, requestConfig);
      return response as T;
    } catch (error) {
      console.error("POST Request Error:", error);
      throw error;
    }
  }
}

export const aiService = new AIChatService();
  • 流式接口: (同上 七、主要功能模块实现>流式对话>a.流式接口)

十、后续功能:

  1. 模型切换
  2. 历史会话
  3. 个人信息
  4. 输入方式支持文件上传
相关推荐
Xlbb.27 分钟前
SpiderX:专为前端JS加密绕过设计的自动化工具
前端·javascript·自动化
beibeibeiooo32 分钟前
【ES6】01-ECMAScript基本认识 + 变量常量 + 数据类型
前端·javascript·ecmascript·es6
庸俗今天不摸鱼1 小时前
【万字总结】前端全方位性能优化指南(三)
前端·性能优化·gpu
前端南玖1 小时前
深入理解Base64编码原理
前端·javascript
aircrushin1 小时前
【PromptCoder + Trae 最新版】三分钟复刻 Spotify 页面
前端·人工智能·后端
木木黄木木2 小时前
从零开始实现一个HTML5飞机大战游戏
前端·游戏·html5
NoneCoder2 小时前
工程化与框架系列(30)--前端日志系统实现
前端·状态模式
计算机毕设定制辅导-无忧学长2 小时前
HTML 基础夯实:标签、属性与基本结构的学习进度(一)
前端·学习·html
斯~内克2 小时前
深度解析ECharts.js:构建现代化数据可视化的利器
前端·信息可视化·echarts