简介:基于deepseek大模型api实现的小程序项目,前端入口为uniapp小程序,AI页面为内嵌H5实现。后端为node.js+sqlite实现。支持快速回复/深度思考模型,调用whisper模型,实现语言转文本功能等。
功能预览
登录入口
微信授权获取用户信息
AI h5页面
历史会话
切换模型
markdown-代码块
markdown-代码块预览
markdown-图表
深度思考
录音
整体效果
✨ 核心特性
🚀 流式数据处理
- 由于微信小程序这里对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 # 全局样式与设计系统变量
🔧 技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| Vue | 3.5.13 | 前端框架 |
| TypeScript | 5.8 | 类型安全 |
| Vite | 6.3.5 | 构建工具 |
| Markdown-it | 14.1.0 | Markdown 渲染 |
| highlight.js | 内置 | 代码高亮 |
| SCSS | 1.94.2 | 样式预处理 |
| Element Plus | 2.10.4 | UI 组件库(可选) |
📥 快速开始
前置要求
- 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());
}
);
});
}
}
🔄 后续优化方向
- 语音转文本实现(当前已经实现接口,前端未渲染)
- 全局异常上报(未处理)
- 图片上传处理
- 导出/情况聊天记录
- 兼容性(各类浏览器兼容问题未测试)
目前存在问题
- 语言录音按钮未实现触摸事件触发
- 点赞,重新生成未对接(后端messageid与前端id冲突掉)
- 流式输出时,消息过渡依然存在闪烁跳动的效果(后续优化)
- 由于工作原因,后续细节无法继续优化,感兴趣的可以新拉取分支继续完善~
📄 项目说明
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.项目地址
-
(H5页面)项目地址 :github.com/dolt-y/AI-H…
-
小程序入口地址 :github.com/dolt-y/unia…
-
后端服务: github.com/dolt-y/unia…