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

发布于:2025-03-12 ⋅ 阅读:(19) ⋅ 点赞:(0)

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

介绍

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

实现

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

<!-- 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 聊天服务发送请求,并读取其流式响应。以下是实现的关键代码段。

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);  
                    }  
                }  
            });  
        }  
    }  
}