在 UniApp 中实现stream流式输出 AI 聊天功能,AI输出内容用Markdown格式展示

在 UniApp 中实现流式 AI 聊天功能

介绍

在现代 Web 开发中,流式 API 响应能够显著提升用户体验,尤其是在与 AI 聊天接口进行交互时。本文将介绍如何在 UniApp 中使用 Fetch API 实现一个流式响应的 AI 聊天功能,包括实时更新聊天内容和滚动到底部的功能。

实现

用 Markdown 格式展示 AI 输出的内容

javascript 复制代码
<!-- index.vue -->
<view v-else class="bot-message" :key="'bot-msg-' + index">
  <view class="avatar-container">
    <image
      class="message-avatar"
      src="/static/images/icon_robot.png"
    />
  </view>
  <view class="message-content bot-bubble">
    <u-loading-icon
      v-if="
        isSendLoading &&
        index == chatMessages.length - 1 &&
        !getText(message.content)
      "
    ></u-loading-icon>
    <text v-else>
        // 用Markdown格式展示
      <AiMarkdownViewer
        class="message-text"
        :content="message.content"
      />
      <!-- {{ message.content || '服务异常,请重试' }} -->
    </text>
  </view>
</view>


<!-- AiMarkdownViewer.vue -->
// 使用showdown插件
<template>
	<view
		class="markdown-container"
		v-html="parsedContent"
		ref="markdownContainer"
	></view>
</template>

<script>
import showdown from 'showdown'

export default {
	name: 'AiMarkdownViewer',
	props: {
		content: {
			type: String,
			required: true,
		},
	},
	data() {
		return {
			converter: new showdown.Converter({
				tables: true,
				tasklists: true,
				simplifiedAutoLink: true,
				strikethrough: true,
				extensions: [this.tableEnhancement()],
			}),
		}
	},
	computed: {
		parsedContent() {
			if (this.content) {
				const processed = this.content.replace(
					/^```markdown\n([\s\S]*?)\n```$/gm, // 添加m标志处理多行
					(match, content) => {
						return content
					}
				)
				return this.converter.makeHtml(processed)
			} else {
				return '微警灌云还在学习中,请您咨询当地派出所'
			}
		},
	},
	methods: {
		tableEnhancement() {
			return {
				type: 'output',
				filter: (text) => {
					// 为表格添加容器和样式类
					return text
						.replace(
							/<table>/g,
							'<div class="table-wrapper"><table class="data-table">'
						)
						.replace(/<\/table>/g, '</table></div>')
						.replace(/<td>/g, '<td class="data-cell">')
						.replace(/<th>/g, '<th class="header-cell">')
				},
			}
		},
	},
}
</script>
<style scoped lang="scss">
.markdown-container {
	margin: 0 auto;
	// padding: 20px;
	width: 100%;
	font-size: 26rpx;
	line-height: 1.7;
	color: #374151;
  white-space: normal; 
}
/deep/ {
	.table-wrapper {
		overflow-x: auto;
		margin: 1em 0;
		border: 1px solid #ebeef5;
		border-radius: 4px;
		box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
		.data-table {
			width: 100%;
			min-width: 600px;
			border-collapse: collapse;
			font-size: 28rpx;
			line-height: 1.5;

			.header-cell {
				background-color: #f8f9fa;
				color: #606266;
				font-weight: 600;
				padding: 12px 16px;
				border-bottom: 2px solid #ebeef5;
				white-space: nowrap;
			}

			.data-cell {
				padding: 12px 16px;
				border-bottom: 1px solid #ebeef5;
				color: #606266;
				min-width: 80px;

				&:empty::before {
					content: ' ';
					display: inline-block;
					width: 1px;
				}
			}

			tr:hover {
				background-color: #f5f7fa;
			}

			tr:nth-child(even) {
				background-color: #fafafa;
			}

			td:first-child,
			th:first-child {
				border-left: 1px solid #ebeef5;
			}

			td:last-child,
			th:last-child {
				border-right: 1px solid #ebeef5;
			}
		}
	}
}
/deep/ {
	h1,
	h2,
	h3,
	h4,
	h5,
	h6 {
		margin: 1em 0;
		font-weight: 600;
		color: #1f2937;

		&:not(h1) {
			border-bottom: 0.5px solid #e5e7eb;
			padding-bottom: 0.4em;
		}
	}

	h1 {
		font-size: 1.5em;
	}
	h2 {
		font-size: 1.4em;
	}
	h3 {
		font-size: 1.3em;
	}
	h4 {
		font-size: 1.2em;
	}
	h5 {
		font-size: 1.1em;
	}
	h6 {
		font-size: 1em;
	}

	// 列表样式
	ul,
	ol {
		margin: 0.8em 0;
		padding-left: 1.5em;

		li {
			margin: 0.4em 0;
			&::marker {
				color: #6b7280;
			}
		}
	}

	// 分割线
	hr {
		border: 0;
		height: 0.5px;
		background: #e5e7eb;
		margin: 1.5em 0;
	}

	// 代码块
	pre {
		background-color: #f9fafb;
		border-radius: 8px;
		padding: 1em;
		margin: 1.2em 0;
		border: 0.5px solid #e5e7eb;

		code {
			font-family: 'JetBrains Mono', Consolas, monospace;
			font-size: 13px;
			color: #374151;
			line-height: 1.6;
		}
	}

	// 行内代码
	code:not(pre code) {
		background-color: #f3f4f6;
		padding: 0.2em 0.4em;
		border-radius: 4px;
		font-size: 0.9em;
		color: #dc2626;
	}

	// 引用块
	blockquote {
		border-left: 3px solid #e5e7eb;
		margin: 1em 0;
		padding: 0.5em 1em;
		color: #4b5563;
		background-color: #f8fafc;
		border-radius: 0 4px 4px 0;
	}

	// 链接
	a {
		color: #3b82f6;
		text-decoration: none;
		&:hover {
			text-decoration: underline;
		}
	}
}
/deep/ {
	pre {
		code {
			display: block;
			width: 100%;
			overflow-x: auto;
		}
	}
}
</style>

我们需要使用 Fetch API 向 AI 聊天服务发送请求,并读取其流式响应。以下是实现的关键代码段。

javascript 复制代码
async getAIChat(params, aiMessage) {  
    // 使用 Fetch API 进行流式请求  
    const response = await fetch(`${config.aiBaseUrl}/api/v1/chat/completions`, {  
        method: 'POST',  
        headers: {  
            Authorization: `Bearer ${config.apikey}`,  
            'Content-Type': 'application/json',  
        },  
        body: JSON.stringify(params),  
    });  

    // 检测响应状态  
    if (!response.ok) {  
        const errorData = await response.json();  
        throw new Error(errorData.message || '请求失败');  
    }  

    this.isSendLoading = false; // 更新加载状态  

    // 使用 response.body.getReader() 开始逐块读取流式响应,使用 UTF-8 编码解码数据。
    const reader = response.body.getReader();  
    const decoder = new TextDecoder('utf-8');  
    let done = false;  

    // 用于流式读取数据  
    while (!done) {  
        const { done: readerDone, value } = await reader.read();  
        done = readerDone;  

        if (value) {  
            // 逐块解码并拼接  
            const chunkText = decoder.decode(value, { stream: !done });  
            // 解析接收到的 JSON 数据  
            const lines = chunkText.split('\n'); // 按行分割  

            lines.forEach((line) => {  
                if (line.startsWith('data:')) {  
                    const jsonString = line.substring(5).trim();  
                    try {  
                        const jsonData = JSON.parse(jsonString);  

                        // 提取内容部分  
                        const content = jsonData.choices?.[0]?.delta?.content || '';  

                        if (content) {  
                            aiMessage.content += content; // 更新聊天内容  

                            // 清除开头的换行符  
                            if (aiMessage.content.startsWith('\n')) {  
                                aiMessage.content = aiMessage.content.slice(1);  
                            }  

                            // 刷新页面渲染  
                            this.$forceUpdate(); // 确保视图更新  

                            // 滚动到底部  
                            this.$nextTick(() => {  
                                this.scrollToBottom(); // 调用滚动到底部的方法  
                            });  
                        }  
                    } catch (error) {  
                        console.error('Error parsing JSON:', error);  
                    }  
                }  
            });  
        }  
    }  
}  
相关推荐
平凡的阿泽5 分钟前
我用 Trae 实现了AI辩论赛
前端
对空六课10 分钟前
指标口径生效时间的版本查询实现
大数据·服务器·前端·算法·数据分析
对空六课15 分钟前
RFM分层边界变化的回溯计算
大数据·服务器·前端·数据库·人工智能·算法·数据分析
IT_陈寒19 分钟前
Vite打包的诡异行为,让我差点提交了个错误版本
前端·人工智能·后端
鹏多多20 分钟前
前端是如何死去的?| 完整时间线
前端·javascript·程序员
coding漫漫长路26 分钟前
海报挪进Worker画,canvas字体又变回默认了
前端
看浪的路人29 分钟前
第2讲:ReAct 模式从零实现——不用框架,手写一个 Agent 内核
前端·react.js·前端框架
独孤九剑打醒他35 分钟前
【原创开源·航空终极篇】氢能航空动力升级:双燃料掺氢、富氧补气与露天储氢安全模型
前端·架构·开源·硬件工程·能源
律宏阔38 分钟前
阿里云 ESA + Pages 域名部分电脑无法访问
前端
Lank_M43 分钟前
canvas 第一次画字没用上字体,字体却是它要来的
前端