uniapp跨端实战笔记:把大模型装进口袋,AI对话工具开发全记录

2026-07-02 0 964

最近这阵子,身边不少朋友都在鼓捣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
    };
};
                
            

这个状态管理有几个设计上的考量:

  1. 消息用push而非赋值——保证数组引用不变,方便后续做虚拟列表优化(虽然当前对话量不大,但养成习惯没坏处)。
  2. 流式消息用isStreaming标记——这样UI层可以针对性地显示打字动画或光标闪烁效果。
  3. 中断功能单独抽出来——用户可能会后悔发出去的消息,给个停止生成的按钮是基本素养。

六、界面实现,少即是多

对话界面的核心是一个滚动列表加一个输入框。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官方能对小程序端的流式请求做些原生支持,那开发体验会上一个台阶。

这玩意儿目前我自己在手机上也用了几天,偶尔问个问题、让它帮忙润色段文字,还挺顺手。后续打算把对话历史加上搜索功能,再接入个语音输入,让它在通勤路上也能方便地用起来。

如果你也在做类似的项目,或者有更好的实现思路,欢迎交流。写代码这事儿,踩坑多了自然就熟了,分享出来也算没白踩。

uniapp跨端实战笔记:把大模型装进口袋,AI对话工具开发全记录
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uniapp跨端实战笔记:把大模型装进口袋,AI对话工具开发全记录 https://www.taomawang.com/web/uniapp/2305.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务