基于deepseek实现的ai问答小程序

435 阅读7分钟

简介:基于deepseek大模型api实现的小程序项目,前端入口为uniapp小程序,AI页面为内嵌H5实现。后端为node.js+sqlite实现。支持快速回复/深度思考模型,调用whisper模型,实现语言转文本功能等。

功能预览

登录入口

截屏2025-12-04 18.38.30.png

微信授权获取用户信息

截屏2025-12-04 18.38.45.png

AI h5页面

截屏2025-12-04 18.38.55.png

历史会话

截屏2025-12-04 18.39.52.png

切换模型

截屏2025-12-04 18.39.59.png

markdown-代码块

截屏2025-12-11 14.11.46.png

markdown-代码块预览

截屏2025-12-11 14.12.13.png

markdown-图表

截屏2025-12-11 14.14.23.png

深度思考

截屏2025-12-11 14.15.31.png

录音

截屏2025-12-11 14.22.18.png

整体效果

lovegif_1764845391382.gif

lovegif_1765438657565.gif

✨ 核心特性

🚀 流式数据处理

  • 由于微信小程序这里对SSE的流式处理不支持,只有chunks的模式,故采用H5的方式来实现。
  • SSE(Server-Sent Events)流式接收:实现了完整的流式数据处理机制,在收到第一块数据时立即渲染
  • 分块解析与缓冲:支持多行 SSE 事件的正确解析,处理不完整的数据块
  • 实时渲染反馈:使用 requestAnimationFrame 优化渲染性能,避免频繁 DOM 更新导致的卡顿

💬 对话管理

  • 消息追踪系统:自动生成唯一消息 ID,维护消息状态(pending/success/error)
  • 会话隔离:支持多会话并行,可创建、切换、管理历史会话
  • 消息快照存储:保存用户消息与 AI 回复的完整上下文

🎨 交互优化

  • 思考中加载动画:AI 回复延迟时的优雅 UX——流式数据到达时即刻消失
  • 自动滚动定位:智能滚动到最新消息,支持指定消息定位,避免内容变化导致的滚动位置偏移
  • 消息操作面板:支持复制、点赞、重新生成等交互功能

🎯 多模型支持

  • 模型切换器:支持在对话过程中动态选择不同 AI 模型
    • 快速问答模式(deepseek-chat)
    • 深度思考模式(deepseek-reasoner)

📝 Markdown 渲染

  • 完整的 Markdown 支持:包括代码块、表格、列表、引用等
  • 代码高亮:使用 highlight.js 实现多语言代码着色
  • HTML 安全渲染:配置化处理,支持链接自动新窗口打开

🎨 设计系统

  • 现代渐变设计:渐变色(紫→紫→粉)贯穿整个应用
  • 响应式布局:基于 CSS 变量的深浅主题支持
  • 动画细节:平滑的消息滑入、加载转圈、脉冲效果等

🏗️ 项目结构

src/
├── components/              # Vue 组件库
│   ├── MessageItem.vue      # 单条消息渲染组件(含 Markdown 解析、操作面板)
│   ├── InputArea.vue        # 输入框及工具栏(含模型选择、录音、新建会话)
│   ├── HistroySessions.vue  # 历史会话管理弹窗
│   └── HelloWorld.vue       # 示例组件
├── utils/
│   ├── type.ts              # TypeScript 类型定义(ChatMessage、Session 等)
│   ├── request.ts           # HTTP 请求封装(get/post)
│   ├── streamRequest.ts     # SSE 流式请求实现
│   └── markdown.ts          # Markdown 渲染引擎配置
├── assets/                  # 静态资源
│   ├── regenerate-icon.svg  # 重新生成按钮图标
│   ├── copy-icon.svg        # 复制按钮图标
│   ├── like-icon.svg        # 点赞按钮图标
│   ├── thinking-icon.svg    # 思考中加载动画
│   └── logo1.png            # AI 角色头像
├── App.vue                  # 主应用组件(核心业务逻辑)
├── main.ts                  # 应用入口
└── style.scss               # 全局样式与设计系统变量

🔧 技术栈

技术版本用途
Vue3.5.13前端框架
TypeScript5.8类型安全
Vite6.3.5构建工具
Markdown-it14.1.0Markdown 渲染
highlight.js内置代码高亮
SCSS1.94.2样式预处理
Element Plus2.10.4UI 组件库(可选)

📥 快速开始

前置要求

  • Node.js >= 20.18.0
  • npm 或 yarn

安装依赖

npm install

开发服务器

npm run dev

访问 http://localhost:5173

生产构建

npm run build

构建输出到 dist/ 目录

预览构建结果

npm run preview

🎯 核心业务流程

对话流程

用户输入 → 发送消息
    ↓
创建 User Message (pending)
显示在消息列表 → 自动滚动到底部
    ↓
调用 streamFetch(POST /api/ai/chat)
Assistant Message 创建 (pending)
    ↓
SSE 数据流开始接收
    ├─ 第一块数据到达 → status 变为 success(思考中 icon 消失)
    ├─ 持续接收 → 实时渲染 Markdown 内容
    └─ 滚动到最新消息
    ↓
流结束 (onDone) → 最终更新消息状态
    ↓
用户可进行操作:复制、点赞、重新生成

会话管理流程

新建会话 → POST /api/ai/sessions
    ↓
获取 session.id → 用于后续对话上下文关联
    ↓
切换历史会话 → GET /api/ai/sessions/{id}/messages
    ↓
加载历史消息 → 等待 DOM 稳定 → 智能滚动到底部

🔐 API 接口约定

对话接口

POST /api/ai/chat
Body: {
  messages: Array<{ role: string; content: string }>,
  sessionId?: string | number,
  stream: true,
  model: string  // "deepseek-chat" | "deepseek-reasoner"
}
Response: 流式 SSE
  data: 文本块
  [可选] event: 事件类型
  [可选] id: 事件 ID

会话接口

POST /api/ai/sessions
Body: { title: string; summary?: string }
Response: { session: { id: string | number; ... } }

GET /api/ai/sessions/{id}/messages
Response: { messages: Array<HistoryMessage> }
  HistoryMessage = {
    role: "assistant" | "user",
    content: string,
    created_at?: string
  }

💡 亮点分析

1. 高性能流式渲染

  • 使用 requestAnimationFrame 进行 Markdown 渲染节流,避免频繁重排/重绘
  • SSE 流数据的分块处理和缓冲机制确保即便数据包不完整也能正确解析
  • 流式更新时的自动滚动采用两帧 rAF 等待,确保 CSS 动画和布局稳定

2. 响应式适配

  • 使用rem单位做响应式适配,目前支持各种大小屏幕的设备,兼容PC端的良好显示。
关键代码

// 用于设置rem单位的字体大小(动态)
function setRem() {
    const baseWidth = 375; // 设计稿宽度
    const minFontSize = 6; // 最小字体
    const maxFontSize = 16; // 最大字体(PC屏或大屏限制)
    const html = document.documentElement;
    const width = html.clientWidth;

    let fontSize = (width / baseWidth) * 12;

    if (fontSize < minFontSize) fontSize = minFontSize;
    if (fontSize > maxFontSize) fontSize = maxFontSize;

    html.style.fontSize = fontSize + 'px';
}
setRem();
window.addEventListener('resize', setRem);

3. 语言转文本实现

import { ElMessage } from 'element-plus';

class NativeRecorder {
  audioContext: AudioContext | null = null;
  processor: ScriptProcessorNode | null = null;
  stream: MediaStream | null = null;
  source: MediaStreamAudioSourceNode | null = null;
  pcmData: Float32Array[] = [];

  async start(): Promise<void> {
    const devices = await navigator.mediaDevices.enumerateDevices();
    const hasMic = devices.some(d => d.kind === "audioinput");

    if (!hasMic) {
      ElMessage.error("未检测到可用的麦克风设备");
      throw new Error("未检测到可用的麦克风设备");
    }

    try {
      this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    } catch (err) {
      ElMessage.error("无法访问麦克风,请检查权限");
      console.error("getUserMedia 错误:", err);
      throw err;
    }

    this.audioContext = new AudioContext({ sampleRate: 16000 });
    this.source = this.audioContext.createMediaStreamSource(this.stream);

    this.processor =
      this.audioContext.createScriptProcessor?.(4096, 1, 1) ||
      null;

    if (!this.processor) {
      ElMessage.error("当前浏览器不支持音频处理节点");
      throw new Error("ScriptProcessorNode 不支持");
    }

    this.source.connect(this.processor);
    this.processor.connect(this.audioContext.destination);

    this.processor.onaudioprocess = (e: AudioProcessingEvent) => {
      const input = e.inputBuffer.getChannelData(0);
      this.pcmData.push(new Float32Array(input));
    };

    console.log("录音开始");
  }

  async stop(): Promise<Blob> {
    return new Promise((resolve) => {
      this.stream?.getTracks().forEach(t => t.stop());

      try {
        this.processor?.disconnect();
        this.source?.disconnect();
      } catch (e) { }

      const pcm = this.mergePCM(this.pcmData);
      const wavBlob = this.encodeWAV(pcm);

      this.pcmData = [];
      resolve(wavBlob);
    });
  }

  private mergePCM(chunks: Float32Array[]): Float32Array {
    const total = chunks.reduce((sum, c) => sum + c.length, 0);
    const result = new Float32Array(total);
    let offset = 0;

    chunks.forEach(c => {
      result.set(c, offset);
      offset += c.length;
    });

    return result;
  }

  private encodeWAV(pcmData: Float32Array): Blob {
    const buffer = new ArrayBuffer(44 + pcmData.length * 2);
    const view = new DataView(buffer);

    const writeString = (offset: number, str: string) => {
      for (let i = 0; i < str.length; i++) {
        view.setUint8(offset + i, str.charCodeAt(i));
      }
    };

    writeString(0, "RIFF");
    view.setUint32(4, 36 + pcmData.length * 2, true);
    writeString(8, "WAVE");
    writeString(12, "fmt ");
    view.setUint32(16, 16, true);
    view.setUint16(20, 1, true);
    view.setUint16(22, 1, true);
    view.setUint32(24, 16000, true);
    view.setUint32(28, 16000 * 2, true);
    view.setUint16(32, 2, true);
    view.setUint16(34, 16, true);
    writeString(36, "data");
    view.setUint32(40, pcmData.length * 2, true);

    let offset = 44;
    for (let i = 0; i < pcmData.length; i++, offset += 2) {
      const s = Math.max(-1, Math.min(1, pcmData[i]));
      view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
    }

    return new Blob([buffer], { type: "audio/wav" });
  }
}

export default new NativeRecorder();

import { execFile } from "child_process";
import path from "path";
import { fileURLToPath } from "url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

/**
 * Whisper.cpp 封装类
 */
export class Whisper {
    /**
     * @param {string} whisperRoot whisper.cpp 根目录绝对路径
     * @param {string} modelName   模型名称
     */
    constructor(whisperRoot, modelName = "ggml-tiny.bin") {
        this.whisperRoot = whisperRoot;
        this.cliPath = path.resolve(whisperRoot, "build/bin/whisper-cli");
        this.modelPath = path.resolve(whisperRoot, `models/${modelName}`);
    }

    /**
     * 转写音频
     * @param {string} audioFile 绝对路径 audio.wav
     * @param {string} language 默认中文
     * @returns {Promise<string>} 默认自动识别语言,返回文本
     */
    transcribe(audioFile, language = "auto") {
        return new Promise((resolve, reject) => {
            execFile(
                this.cliPath,
                ["-m", this.modelPath, "-f", audioFile, "--language", language],
                (error, stdout, stderr) => {
                    if (error) {
                        console.error("Whisper 转写失败:", stderr);
                        return reject(error);
                    }
                    resolve(stdout.trim());
                }
            );
        });
    }
}

🔄 后续优化方向

  • 语音转文本实现(当前已经实现接口,前端未渲染)
  • 全局异常上报(未处理)
  • 图片上传处理
  • 导出/情况聊天记录
  • 兼容性(各类浏览器兼容问题未测试)

目前存在问题

  1. 语言录音按钮未实现触摸事件触发
  2. 点赞,重新生成未对接(后端messageid与前端id冲突掉)
  3. 流式输出时,消息过渡依然存在闪烁跳动的效果(后续优化)
  4. 由于工作原因,后续细节无法继续优化,感兴趣的可以新拉取分支继续完善~

📄 项目说明

1.后端配置env 文件

WX_APP_ID=   // 微信appid
WX_APP_SECRET= // 密钥
JWT_SECRET=
PORT=3000
DB_FILE=./wechat-mini.db // sqlite数据库
OPENAI_API_KEY= // deepseek 密钥
OPENAI_BASE_URL=https://api.deepseek.com 

2.whisper模型

拉取 git clone github.com/ggerganov/w…

bash ./models/download-ggml-model.sh xxx你需要的模型

cmake编译

mkdir build

cd build

cmake ..

make -j4

3.项目地址