跳至正文
首页 » 从 0 开始搭建一个 对话式大模型微信小程序

从 0 开始搭建一个 对话式大模型微信小程序

本文将介绍使用 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>

6 参考

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注