本文将介绍使用 Uniapp 从 0 开始搭建一个 对话式大模型微信小程序 ,不使用第三方插件,服务端使用 Dify API 作为示例。服务端配置参照 微信小程序 API 接口要求 进行配置。首页包含了多个模型选项,点击“开始”后,使用对应的模型开始一段对话。对话界面布局合理,支持 markdown 的加粗和列表语法。支持 Dify API 的流式模式响应模式(SSE,Server-Sent Events)。对话式大模型微信小程序 完整代码附于文末。
1 界面展示


2 模型选择页面设计
模型选择页面功能比较简单,选择器使用 picker-view 控件实现。将模型名称存储在变量 apps 中,选择器读取变量生成选项
<picker-view v-if="visible" indicator-style="height: 50px;" :value="app_index" @change="bindChange">
<picker-view-column>
<view class="item" v-for="(item,index) in apps" :key="index">{{item}}</view>
</picker-view-column>
</picker-view>
改变选项后,将选项编号付给变量 app_index。按下“开始”按钮后,切换至对话页面,并将当前 app_index 对应的模型名称和 Dify API 密钥作为参数传入。
<navigator open-type="navigate"
:url="'/pages/dify/dify?difyApiKey='+difyApiKey[app_index]+'&appName='+apps[app_index]">
<button type="primary">开始</button>
</navigator>
对话界面在 onLoad 中读取参数,可以直接使用或者转换为页面内的参数。
onLoad: function(option) {
this.difyApiKey = option.difyApiKey;
this.chatMessages.push({
type: 'system',
content: '欢迎使用三衡橘井 ' + option.appName + ',有什么可以帮您的 ^ ^',
});
},
程序中三衡橘井的 LOGO 使用豆包生成。借此感叹下发展速度,几年前涉及到文字图片生成就完全没法用了。
3 对话界面设计
设计 对话式大模型微信小程序 之初,我想找一个现成插件直接调用,找了很多都不是很满意,后发现自己实现也不是很麻烦,自定义程度还高。
对话缓存于变量 chatMessages 中,每条信息包含 type 和 content 两类内容。
this.chatMessages.push({
type: 'system',
content: '欢迎使用三衡橘井 ' + option.appName + ',有什么可以帮您的 ^ ^',
});
type 包含 system 和 user 两种,显示聊天内容进行区分。对于 user 类型内容右对齐并调用相应 CSS 样式。
<view class="message" v-for="(msg, index) in chatMessages" :key="index" :id='"message"+index'>
<view :style="{textAlign:msg.type==='user'?'right':'left'}">
<rich-text :nodes="convertMarkdownToHtml(msg.content)"
:class="{'user-message':msg.type==='user'}"></rich-text>
</view>
</view>
.message {
padding: 20px;
}
.user-message {
padding: 9px 16px;
background: rgba(0, 0, 0, 0.04);
border-radius: 12px;
max-width: 450px;
width: fit-content;
text-align: right;
display: inline-block;
}
再啰嗦一下关于跳至页面底部的方法。本文使用 scroll-view 的 scroll-into-view 实现。为每一个选项 view 定义一个 id,触发跳至页面底部的函数时,使用 scroll-into-view 传入缓存最后一个信息对应 view 的 id。
<scroll-view class="chat-history" scroll-y scroll-with-animation :scroll-into-view="scrollIntoView"
ref="chatHistory" :scroll-top="scrollTop">
<view class="message" v-for="(msg, index) in chatMessages" :key="index" :id='"message"+index'>
<!-- codes -->
</view>
</scroll-view>
scrollToButtom() {
this.scrollIntoView = "message" + (this.chatMessages.length - 1);
},
设计过程看到有很多方法,但是无法在微信小程序实现。
4 数据解析(SSE & markdown)
发送的请求直接照搬手册,需要单独注意下 Authorization 项和超时时长的设置。
Uniapp 没有直接的 SSE 支持。本文使用 onChunkReceived 每次收到数据包就立即解析,实现了 SSE 的效果。同时在网络状况良好的情况下类似打字机效果。
requestTask.onChunkReceived((res) => {
let rawData = String.fromCharCode.apply(null, new Uint8Array(res.data));
// 拆分多个 JSON 数据
const jsonDataList = rawData.split('\n\n');
jsonDataList.forEach(jsonData => {
jsonData = jsonData.trim();
if (jsonData.startsWith('data: ')) {
jsonData = jsonData.replace('data: ', '')
const parseddata = JSON.parse(jsonData);
if (parseddata.event === "message") {
if (this.chatMessages.length > 0 && this.chatMessages[this
.chatMessages
.length - 1].type === 'system') {
this.chatMessages[this.chatMessages.length - 1].content +=
parseddata.answer;
} else {
this.chatMessages.push({
type: 'system',
content: parseddata.answer,
});
if (parseddata.conversation_id) {
this.conversation_id = parseddata.conversation_id;
}
}
this.scrollToButtom();
}
}
});
})
每个数据包内会包含多个数据,且数据不止对话内容,所以其中包含了拆解和数据类型的判断。为保证对话的连续性,存储 conversation_id 用于下次发送请求时使用。
对话的显示使用 rich-text 控件以支持加粗、列表等显示设置。这里对 markdown 的加粗、列表与换行进行了处理。
convertMarkdownToHtml(markdown) {
// 检查 markdown 是否为 undefined 或 null
if (!markdown) {
return '';
}
// 简单的 Markdown 加粗转换
let html = markdown.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// 处理 Markdown 列表 - 符号
html = html.replace(/^(- )(.*)$/gm, '<li>$2</li>');
// 处理换行符
html = html.replace(/\n/g, '<br>');
// 整理列表格式
html = html.replace(/<\/li><br>/g, '</li>');
html = html.replace(/(?<!<\/li>)<li>/g, '<br><li>');
html = html.replace(/<\/li>(?!<li>)/g, '</li><br>');
return html;
}
5 完整代码
5.1 模型选择页面
<template>
<view>
<view class="app-title">
<image style="width:300px; height: 130px;margin: auto;" mode="aspectFit" src="~@/static/applogo.png">
</image>
</view>
<picker-view v-if="visible" indicator-style="height: 50px;" :value="app_index" @change="bindChange">
<picker-view-column>
<view class="item" v-for="(item,index) in apps" :key="index">{{item}}</view>
</picker-view-column>
</picker-view>
<view style="height: 40rpx;"></view>
<navigator open-type="navigate"
:url="'/pages/dify/dify?difyApiKey='+difyApiKey[app_index]+'&appName='+apps[app_index]">
<button type="primary">开始</button>
</navigator>
</view>
</template>
<script>
export default {
data() {
const apps = ["智能导诊", "茶饮推荐", "保健品推荐"]
const difyApiKey = ["app-key1",
"app-key2", "app-key3"
]
const app_index = 0
return {
title: 'picker-view',
apps,
difyApiKey,
app_index: [0],
visible: true,
}
},
methods: {
bindChange(e) {
this.app_index = e.detail.value
console.log(this.app_index)
}
}
}
</script>
<style>
.app-title {
margin-top: 80rpx;
margin-bottom: 80rpx;
display: flex;
}
picker-view {
width: 100%;
height: 600rpx;
}
.item {
line-height: 100rpx;
text-align: center;
}
button {
margin: 30rpx;
margin-bottom: 30rpx;
}
</style>
5.2 对话页面
<template>
<view class="container">
<!-- 聊天记录 -->
<scroll-view class="chat-history" scroll-y scroll-with-animation :scroll-into-view="scrollIntoView"
ref="chatHistory" :scroll-top="scrollTop">
<view class="message" v-for="(msg, index) in chatMessages" :key="index" :id='"message"+index'>
<view :style="{textAlign:msg.type==='user'?'right':'left'}">
<rich-text :nodes="convertMarkdownToHtml(msg.content)"
:class="{'user-message':msg.type==='user'}"></rich-text>
</view>
</view>
</scroll-view>
<!-- 输入框 -->
<view class="input-area">
<!-- 设置 adjust-position 为 false -->
<input v-model="inputMessage" placeholder="描述你的问题" class="input" cursor-spacing="15" @confirm="sendMessage"
adjust-position="false" />
<button @click="sendMessage" class="send-button">发送</button>
</view>
</view>
</template>
<script>
export default {
data() {
return {
difyApiUrl: 'https://dify-api-url/v1/chat-messages',
difyApiKey: '',
title: '',
chatMessages: [],
scrollIntoView: "",
inputMessage: '',
conversation_id: '',
user_id: Math.round(Math.random() * 1000000).toString(),
scrollTop: 0,
};
},
onLoad: function(option) {
this.difyApiKey = option.difyApiKey;
this.chatMessages.push({
type: 'system',
content: '欢迎使用三衡橘井 ' + option.appName + ',有什么可以帮您的 ^ ^',
});
},
methods: {
scrollToButtom() {
this.scrollIntoView = "message" + (this.chatMessages.length - 1);
},
sendMessage() {
if (this.inputMessage.trim() !== '') {
this.chatMessages.push({
type: 'user',
content: this.inputMessage
});
this.inputMessage = '';
this.scrollToButtom();
const requestData = {
inputs: {},
query: this.chatMessages[this.chatMessages.length - 1].content,
response_mode: 'streaming',
conversation_id: this.conversation_id,
user: this.user_id,
files: [],
}
const requestTask = uni.request({
url: this.difyApiUrl,
method: 'POST',
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + this.difyApiKey,
},
data: requestData,
timeout: 60000,
enableChunked: true,
success: (res) => {
if (res.statusCode === 200) {
this.chatMessages.push({
type: 'system',
content: res.data.answer
});
} else {
console.log('响应数据中没有 message 字段');
}
this.scrollToButtom();
},
fail: (err) => {
console.error('Dify API request error:', err);
this.chatMessages.push({
type: 'system',
content: 'failed'
});
}
})
requestTask.onChunkReceived((res) => {
let rawData = String.fromCharCode.apply(null, new Uint8Array(res.data));
// 拆分多个 JSON 数据
const jsonDataList = rawData.split('\n\n');
jsonDataList.forEach(jsonData => {
jsonData = jsonData.trim();
try {
if (jsonData.startsWith('data: ')) {
jsonData = jsonData.replace('data: ', '')
const parseddata = JSON.parse(jsonData);
if (parseddata.event === "message") {
if (this.chatMessages.length > 0 && this.chatMessages[this
.chatMessages
.length - 1].type === 'system') {
this.chatMessages[this.chatMessages.length - 1].content +=
parseddata.answer;
} else {
this.chatMessages.push({
type: 'system',
content: parseddata.answer,
});
if (parseddata.conversation_id) {
this.conversation_id = parseddata.conversation_id;
}
}
this.scrollToButtom();
}
}
} catch (error) {
console.log(rawData);
console.log(jsonDataList);
console.error('JSON 解析错误:', error);
}
});
})
}
},
convertMarkdownToHtml(markdown) {
// 检查 markdown 是否为 undefined 或 null
if (!markdown) {
return '';
}
// 简单的 Markdown 加粗转换
let html = markdown.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// 处理 Markdown 列表 - 符号
html = html.replace(/^(- )(.*)$/gm, '<li>$2</li>');
// 处理换行符
html = html.replace(/\n/g, '<br>');
// 整理列表格式
html = html.replace(/<\/li><br>/g, '</li>');
html = html.replace(/(?<!<\/li>)<li>/g, '<br><li>');
html = html.replace(/<\/li>(?!<li>)/g, '</li><br>');
return html;
}
}
}
</script>
<style>
.container {
display: flex;
flex-direction: column;
height: calc(100vh);
}
.chat-history {
flex: 1;
overflow-y: auto;
}
.input-area {
display: flex;
align-items: center;
padding: 10px;
}
.input {
flex: 1;
padding: 8px;
border: 1px solid #ccc;
border-radius: 5px;
margin-right: 10px;
}
.send-button {
height: 74rpx;
border: none;
border-radius: 5px;
color: white;
background-color: #1aad19;
font-size: 15px;
white-space: nowrap;
outline: none;
}
.message {
padding: 20px;
}
.user-message {
padding: 9px 16px;
background: rgba(0, 0, 0, 0.04);
border-radius: 12px;
max-width: 450px;
width: fit-content;
text-align: right;
display: inline-block;
}
</style>