最近这阵子,身边不少朋友都在鼓捣AI应用。有做网页版的,有做桌面端的,热闹得很。我就琢磨着,能不能用uniapp搞一个跨端的AI对话工具,一套代码扔出去,小程序、H5、App全都能跑。说干就干,折腾了两周,踩了不少坑,趁热乎劲儿把整个过程记下来。
一、这事儿值不值得搞
先说说我为什么选uniapp。其实市面上做跨端的框架不少,Taro、Flutter、React Native各有各的拥趸。但我这边的情况是:团队本来就熟悉Vue生态,手上还有好几个维护中的uniapp项目,技术栈统一能省不少沟通成本。再加上uniapp这两年迭代挺快,对Vue3的支持也稳了,社区插件也越来越丰富,选它没毛病。
不过真正让我下定决心的,是看到不少同行已经把AI能力塞进小程序里了。有的做智能客服,有的做文案生成,有的干脆做了个随身翻译。这说明一件事:在移动端跑AI应用,技术上已经走得通了。剩下的问题就是怎么做得顺手、怎么把体验搞好。
二、先把摊子支起来
项目初始化没啥好说的,HBuilderX里新建个项目,选Vue3版本的uniapp模板。但有个小细节值得提一嘴:如果你的目标是多端发布,建议一开始就把条件编译的架子搭好,不然后面改起来挺头疼的。
目录结构我是这么规划的:
├── pages
│ └── chat
│ └── index.vue # 对话主页面
├── utils
│ ├── ai-client.js # AI接口封装
│ ├── sse-parser.js # SSE数据流解析
│ └── message-handler.js # 消息处理逻辑
├── store
│ └── chat.js # 对话状态管理
├── components
│ ├── chat-bubble.vue # 消息气泡组件
│ └── typing-indicator.vue # 输入中动效
└── static
└── icons # 图标资源
这里把AI相关的逻辑全部抽到utils里,页面层只负责渲染和交互。这样做的好处是:后面如果换模型供应商,改动的范围很小,不用在页面代码里翻来翻去。
三、对接大模型API,这才是重头戏
选模型这事儿我犹豫过。一开始想用ChatGPT的接口,但国内访问不稳定,小程序里更是容易出幺蛾子。后来把目光转向国内厂商,试了通义千问和DeepSeek,最后选了DeepSeek。原因很简单:它的API兼容OpenAI格式,迁移成本低,而且对流式输出的支持比较完善。
下面是我封装的ai-client.js核心部分,去掉了业务无关的代码:
// utils/ai-client.js
// 注意:实际项目里apiKey要走后端,这里仅作演示
const BASE_URL = 'https://api.deepseek.com/v1';
const API_KEY = 'your-api-key-here'; // 生产环境务必放服务端
export function createChatStream(messages, callbacks) {
const { onChunk, onDone, onError } = callbacks;
// 小程序和H5的请求方式不一样,这里做条件编译
// #ifdef MP-WEIXIN
return requestViaWechat(messages, onChunk, onDone, onError);
// #endif
// #ifdef H5
return requestViaFetch(messages, onChunk, onDone, onError);
// #endif
}
// H5端直接用fetch,天然支持ReadableStream
async function requestViaFetch(messages, onChunk, onDone, onError) {
try {
const response = await fetch(`${BASE_URL}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: 'deepseek-chat',
messages: messages,
stream: true,
temperature: 0.7
})
});
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('n');
// 保留最后一个不完整的行
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data:')) continue;
const dataStr = trimmed.slice(5).trim();
if (dataStr === '[DONE]') {
onDone && onDone();
return;
}
try {
const parsed = JSON.parse(dataStr);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
onChunk && onChunk(content);
}
} catch (e) {
// SSE数据偶尔不完整,跳过即可
continue;
}
}
}
onDone && onDone();
} catch (err) {
onError && onError(err);
}
}
眼尖的朋友可能发现了,上面只贴了H5端的实现,小程序端的requestViaWechat还没写。别急,这就是踩坑的地方。
四、小程序的坑,一个比一个隐蔽
uniapp在H5端跑流式请求没什么障碍,fetch的ReadableStream用起来很顺手。但到了小程序端,uni.request不支持流式读取,这就尴尬了。你没法像H5那样边接收边渲染,只能等整个响应回来再处理。
我翻了不少资料,试过几种方案:
- 方案A:轮询——发个请求,后端把结果缓存起来,前端隔一秒问一次。可行性有,但体验太差,延迟感明显,而且浪费资源。
- 方案B:WebSocket——把对话通道改成WebSocket,由后端转发大模型的流式响应。这个方案体验最好,但需要后端多搭一层服务。
- 方案C:分片返回——后端把大模型的输出按段落切分,前端分段请求。折中方案,实现复杂度中等。
我最终选了方案B,用WebSocket做中转。后端用Node.js搭了个简单的转发服务,前端代码大概长这样:
// utils/ai-client.js 中的小程序部分
// #ifdef MP-WEIXIN
function requestViaWechat(messages, onChunk, onDone, onError) {
const socketTask = uni.connectSocket({
url: 'wss://your-backend.com/chat-stream',
header: {
'Authorization': `Bearer ${API_KEY}`
}
});
socketTask.onOpen(() => {
socketTask.send({
data: JSON.stringify({
model: 'deepseek-chat',
messages: messages,
temperature: 0.7
})
});
});
socketTask.onMessage((res) => {
const data = JSON.parse(res.data);
if (data.type === 'chunk') {
onChunk && onChunk(data.content);
} else if (data.type === 'done') {
onDone && onDone();
socketTask.close();
} else if (data.type === 'error') {
onError && onError(new Error(data.message));
socketTask.close();
}
});
socketTask.onError((err) => {
onError && onError(err);
});
// 返回一个可中断的对象
return {
abort: () => {
socketTask.close();
}
};
}
// #endif
这里有个细节值得注意:socketTask.close()的时机要把握好。如果后端还没发完数据就关连接,用户看到的就是半截回复,体验很差。我在onDone回调里才关闭,确保数据完整。
五、消息状态管理,别搞得太复杂
对话功能的状态管理其实不复杂,核心就是维护一个消息列表。我用的是uniapp内置的Vuex(其实现在应该叫Pinia了,但老项目惯性还在),store/chat.js简化后如下:
// store/chat.js
import { reactive } from 'vue';
import { createChatStream } from '@/utils/ai-client.js';
export const useChatStore = () => {
const state = reactive({
messages: [], // 所有消息
isGenerating: false, // 是否正在生成回复
currentReply: '', // 当前正在流式输出的回复内容
abortController: null // 用于中断请求
});
// 发送用户消息并获取AI回复
async function sendMessage(content) {
if (state.isGenerating || !content.trim()) return;
// 添加用户消息
state.messages.push({
role: 'user',
content: content,
timestamp: Date.now()
});
// 添加一个空的AI消息占位
const aiMsgIndex = state.messages.length;
state.messages.push({
role: 'assistant',
content: '',
timestamp: Date.now(),
isStreaming: true
});
state.isGenerating = true;
state.currentReply = '';
// 构建发送给API的消息历史(只传role和content)
const apiMessages = state.messages
.filter(m => !m.isStreaming)
.map(m => ({ role: m.role, content: m.content }));
const streamController = createChatStream(apiMessages, {
onChunk: (chunk) => {
state.currentReply += chunk;
state.messages[aiMsgIndex].content = state.currentReply;
},
onDone: () => {
state.messages[aiMsgIndex].isStreaming = false;
state.isGenerating = false;
state.currentReply = '';
// 可以在这里把对话记录存到本地
saveToLocal(state.messages);
},
onError: (err) => {
state.messages[aiMsgIndex].content = '抱歉,出了点问题,请重试。';
state.messages[aiMsgIndex].isStreaming = false;
state.isGenerating = false;
console.error('AI请求失败:', err);
}
});
state.abortController = streamController;
}
// 中断生成
function abortGeneration() {
if (state.abortController && state.abortController.abort) {
state.abortController.abort();
}
// 找到正在流式输出的消息,标记为完成
const streamingMsg = state.messages.find(m => m.isStreaming);
if (streamingMsg) {
streamingMsg.isStreaming = false;
if (!streamingMsg.content) {
streamingMsg.content = '(已中断)';
}
}
state.isGenerating = false;
state.currentReply = '';
}
// 本地存储(简化版)
function saveToLocal(messages) {
try {
const toSave = messages.slice(-50); // 只保留最近50条
uni.setStorageSync('chat_history', JSON.stringify(toSave));
} catch (e) {
// 存储满了就静默失败
}
}
return {
state,
sendMessage,
abortGeneration
};
};
这个状态管理有几个设计上的考量:
- 消息用push而非赋值——保证数组引用不变,方便后续做虚拟列表优化(虽然当前对话量不大,但养成习惯没坏处)。
- 流式消息用isStreaming标记——这样UI层可以针对性地显示打字动画或光标闪烁效果。
- 中断功能单独抽出来——用户可能会后悔发出去的消息,给个停止生成的按钮是基本素养。
六、界面实现,少即是多
对话界面的核心是一个滚动列表加一个输入框。uniapp里用scroll-view配合v-for就能搞定。但有两个体验细节我想强调:
第一,自动滚到底部的时机。很多人直接在发消息后调scrollTop,但流式输出时内容在持续变长,你得在每次onChunk后都滚动一次。我的做法是用nextTick配合scroll-into-view,让最新消息始终可见。
第二,输入框的防抖处理。用户手快起来可能连点好几下发送按钮,不做防抖就会发出重复消息。这个在sendMessage函数入口处用isGenerating状态拦了一道,但更严谨的做法是加个简单的锁。
chat-bubble组件的核心代码:
<!-- components/chat-bubble.vue -->
<template>
<view class="bubble-wrapper" :class="isUser ? 'user-bubble' : 'ai-bubble'">
<view class="avatar">
<image v-if="isUser" src="/static/icons/user-avatar.png" mode="aspectFill"></image>
<image v-else src="/static/icons/ai-avatar.png" mode="aspectFill"></image>
</view>
<view class="bubble-content">
<text class="message-text">{{ displayContent }}</text>
<text v-if="isStreaming" class="cursor-blink">|</text>
<text class="time-text">{{ formatTime(timestamp) }}</text>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
role: { type: String, default: 'user' },
content: { type: String, default: '' },
timestamp: { type: Number, default: 0 },
isStreaming: { type: Boolean, default: false }
});
const isUser = computed(() => props.role === 'user');
const displayContent = computed(() => props.content || '思考中...');
function formatTime(ts) {
if (!ts) return '';
const date = new Date(ts);
const h = String(date.getHours()).padStart(2, '0');
const m = String(date.getMinutes()).padStart(2, '0');
return `${h}:${m}`;
}
</script>
消息气泡的样式我用了类名来控制左右对齐,而不是行内样式。user-bubble和ai-bubble两个类分别处理不同的对齐方向和背景色,这部分CSS写在对应的样式文件里(这里就不展开了,每个项目的设计规范不一样)。
七、多端适配的那些事儿
uniapp的口号是”一套代码多端运行”,但实际做下来,完全零适配是不现实的。我在这个项目里遇到的平台差异主要有这几处:
| 问题点 | H5端表现 | 小程序端表现 | 处理方式 |
|---|---|---|---|
| 流式请求 | fetch + ReadableStream,原生支持 | uni.request不支持流式,需转WebSocket | 条件编译,两套实现 |
| 键盘弹起 | 自动调整视口,基本无感 | 需要手动处理输入框位置,否则被键盘遮挡 | 监听键盘高度,动态调整bottom值 |
| 文件存储 | localStorage即可 | 有存储上限,需清理旧数据 | 只保留最近50条,超出自动删旧 |
| 网络超时 | 默认超时较长 | 默认60秒,流式对话可能不够 | 小程序端设置更长的超时时间 |
这些差异不是uniapp框架的问题,而是各平台本身的限制。搞清楚哪些是框架能抹平的,哪些必须自己处理,开发起来心里就有底了。
八、上线前别忘了这些
功能跑通之后,还有几件事得做:
- API Key绝对不能放前端。我的做法是搭了个简单的Node中间层,前端请求打到自己的服务端,由服务端带着Key去调大模型接口。这样Key不会暴露,还能在中间层做限流和日志。
- 内容安全要留意。如果是小程序上线,记得接一下内容安全审核接口。用户发的内容和AI回复的内容都得过一遍,不然审核可能被卡。
- 错误提示要友好。网络超时、模型繁忙、返回格式异常……这些情况都得有对应的用户提示,别直接甩个”请求失败”就完事了。
- 首次加载体验。对话历史如果存了太多,首页加载会慢。我做了个分页加载,首次只拉最近20条,往上滑再加载更早的记录。
九、写在最后
整个项目从搭环境到调通流程,再到处理各种边界情况,前后花了大概两周的业余时间。说实话,uniapp在跨端这件事上确实省了不少力,尤其是H5和小程序共用一套Vue组件,改动量比我预期的少很多。
但也有遗憾的地方。流式输出在小程序端的体验始终不如H5,WebSocket中转虽然能用,但多了一层延迟。如果未来uniapp官方能对小程序端的流式请求做些原生支持,那开发体验会上一个台阶。
这玩意儿目前我自己在手机上也用了几天,偶尔问个问题、让它帮忙润色段文字,还挺顺手。后续打算把对话历史加上搜索功能,再接入个语音输入,让它在通勤路上也能方便地用起来。
如果你也在做类似的项目,或者有更好的实现思路,欢迎交流。写代码这事儿,踩坑多了自然就熟了,分享出来也算没白踩。

