简介:基于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 变量的深浅主题支持
- 动画细节:平滑的消息滑入、加载转圈、脉冲效果等
🏗️ 项目结构
wechat-ai-fontend/
├── public/ # 静态资源
├── src/
│ ├── App.vue # 页面入口,组合聊天、录音、鉴权能力
│ ├── assets/ # 图标与图片资源
│ ├── components/ # 聊天 UI 组件
│ ├── hook/
│ │ ├── useChatAuth.ts # H5 自动登录与用户信息处理
│ │ ├── useChatConversation.ts # 会话与消息主流程
│ │ ├── useChatRecording.ts # 录音与语音识别请求
│ │ ├── useChatScroll.ts # 滚动到底部逻辑
│ │ └── useChatStream.ts # SSE 流式响应处理
│ ├── utils/
│ │ ├── api.ts # 接口路径与后端基地址拼接
│ │ ├── request.ts # 普通请求封装
│ │ ├── streamRequest.ts # SSE 请求封装
│ │ ├── markdown.ts # Markdown 渲染配置
│ │ ├── media.ts # 图片 URL 处理
│ │ └── type.ts # 业务类型定义
│ ├── style.scss # 全局主题样式
│ └── main.ts # 应用入口
├── package.json
└── README.md
wechat-ai-backend/
├── clients/
│ └── openaiClient.js # OpenAI / DeepSeek 客户端封装
├── controllers/ # 控制器层,负责请求入参与响应
├── docs/swagger/ # Swagger 注释定义
├── errors/ # 业务错误类型
├── middleware/
│ ├── auth.js # JWT 鉴权
│ ├── upload.js # 图片/音频上传处理
│ └── whisper.js # whisper.cpp CLI 封装
├── repositories/ # SQLite 数据访问层
├── routes/ # 路由定义
├── services/ # 业务编排层
├── temp/ # 语音识别临时文件目录
├── uploads/ # 图片上传目录
├── config.js # 环境变量加载
├── db.js # SQLite 初始化与建表
├── index.js # 服务入口
├── swagger.js # Swagger 规范聚合
└── wechat-mini.db # 本地数据库文件
🔧 技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| 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 动画和布局稳定
/**
* * @param messages 消息列表
* @param nextMessageId 获取下一条消息ID的函数
* @param params 请求参数
* @returns
*/
async function streamAssistantReply(
messages: ChatMessage[],
nextMessageId: () => number,
params: StreamAssistantParams
) {
const {
requestMessages,
sessionId,
selectedModel,
imageFile,
onDoneSession,
} = params;
isAssistantTyping.value = true;
const assistantMsg = createAssistantMessage(nextMessageId());
messages.push(assistantMsg);
scrollToBottom(`message-${assistantMsg.id}`);
const queue: string[] = []; // 待渲染字符队列
let isAnimating = false; // 动画锁
let displayBuffer = ''; // 当前已显示的纯文本缓冲区
let hasThinkingUpdate = false; // 思考内容是否有更新的标记
// 消费队列的渲染循环
const flushQueue = () => {
// 只要队列有内容或思考内容有更新,就认为需要滚动
const shouldScroll = queue.length > 0 || hasThinkingUpdate;
if (queue.length === 0 && !hasThinkingUpdate) {
isAnimating = false;
return;
}
if (queue.length > 0) {
// 自适应步长:队列堆积越多,消费速度越快,平衡流畅度与实时性
const step = queue.length > 50 ? 5 : queue.length > 20 ? 2 : 1;
const chunk = queue.splice(0, step).join('');
displayBuffer += chunk;
assistantMsg.content = renderMarkdown(displayBuffer);
}
// 思考内容有更新,更新标记并刷新视图
if (hasThinkingUpdate) {
hasThinkingUpdate = false;
}
const index = messages.findIndex((m) => m.id === assistantMsg.id);
if (index !== -1) {
messages[index] = { ...assistantMsg };
}
if (shouldScroll) {
scrollToBottom(`message-${assistantMsg.id}`);
}
requestAnimationFrame(flushQueue);
};
return new Promise<void>((resolve) => {
let gotFirst = false;
const requestData = imageFile
? (() => {
const formData = new FormData();
formData.append('messages', JSON.stringify(requestMessages));
formData.append('stream', 'true');
formData.append('model', selectedModel);
formData.append('image', imageFile);
if (
sessionId !== undefined &&
sessionId !== null &&
sessionId !== ''
) {
formData.append('sessionId', String(sessionId));
}
return formData;
})()
: {
messages: requestMessages,
sessionId,
stream: true,
model: selectedModel,
};
streamFetch({
url: api.chat,
data: requestData,
onMessage: (chunk: string) => {
if (!chunk) return;
if (!gotFirst) {
gotFirst = true;
assistantMsg.status = 'success';
}
// 流式返回的数据不要立刻渲染,先放入队列等待消费,保持后续渲染的平滑度
queue.push(...chunk.split(''));
if (!isAnimating) {
isAnimating = true;
flushQueue();
}
},
onThinking: (thinking) => {
if (!gotFirst) {
gotFirst = true;
assistantMsg.status = 'success';
}
assistantMsg.reasoning_content += thinking;
hasThinkingUpdate = true;
if (!isAnimating) {
isAnimating = true;
flushQueue();
}
},
onDone: (doneSessionId) => {
// 网络请求结束,等待队列消费完毕
onDoneSession?.(doneSessionId ?? sessionId);
assistantMsg.status = 'done';
const checkDone = setInterval(() => {
if (queue.length === 0) {
clearInterval(checkDone);
isAssistantTyping.value = false;
assistantMsg.status = 'done';
resolve();
}
}, 50);
},
onError: (err) => {
assistantMsg.status = 'error';
isAssistantTyping.value = false;
console.error('流式请求出错:', err);
resolve();
},
});
});
}
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.项目地址
现有问题: 线上前端拉取录音的api 只能在localhost或https环境下才能调用。