一、SSE 流式返回是什么?

Server-Sent Events (SSE)
是一种在客户端与服务器之间建立持久性单向连接的技术,服务器可以通过该连接向客户端发送任意数量的数据。SSE 利用 HTTP
协议,使用特定格式的数据来发送事件给客户端。它允许服务器主动向客户端推送信息,而无需客户端发出请求。SSE
通常用于实现实时更新、通知和事件驱动的应用程序,例如实时聊天、股票市场更新、新闻推送等。

SSE 基于 HTTP 协议,通过简单的 GET 请求即可开启一个持久连接。服务器会使用 Content-Type:
text/event-stream 来标记返回的数据流,随后可以通过定期发送数据保持连接。当数据到达客户端时,浏览器会自动触发
message 事件进行处理。SSE 支持的数据流格式较为简单,每条数据都以事件块的形式发送,并以双换行符结束。

在实际应用中,SSE 可以用于以下场景:

  • 实时通知和警报:如实时股票行情、新闻推送等。
  • 聊天应用:虽然 WebSocket 更适用于双向通信,但在某些场景下,SSE 可以用于实现简单的聊天应用。
  • 服务器监控:实时获取服务器运行状态、日志等信息。

SSE 与 WebSocket 都是常用于实时数据推送的技术,但相比 WebSocket,SSE
的优势在于实现简单、数据流控制更稳定且具有自动重连机制。对于需要单向数据流(即服务器向客户端推送)的场景,SSE
是一种轻量级而高效的选择。此外,SSE 还具有较好的兼容性,能够在主流浏览器中良好运行。

反映在浏览器控制台是这样的:

在这里插入图片描述

二、前端怎么处理?

这里一般都是二次封装的开放接口,而一般axios不支持流式响应,要借用一些插件,本文在这里直接用fetch请求 ,fetch对于SSE请求有天然优势

封装一个接口方法如下:

export async function sendChatMessage(
	query: string,
	conversationId: string | null = null,
	onChunk: (
		event: string,
		content: string,
		messageId?: string,
		convId?: string,
		metadata?: any
	) => void,
	abortController?: AbortController
) {
	try {
		/// 构建请求对象
		const reqParams: any = {
			query,
			response_mode: 'streaming',
			user: 'web-user-' + Date.now().toString(),
			inputs: {},
		};

		// 设置请求头
		const headers = {
			'Content-Type': 'application/json',
			Authorization: `Bearer ${API_KEY}`,
		};

		// 发送请求
		const response = await fetch(`${API_BASE_URL}/chat-messages`, {
			method: 'POST',
			headers,
			body: JSON.stringify(reqParams),
			signal: abortController?.signal,
		});

		// const response = await getModelList(JSON.stringify(reqParams));

		if (!response.ok) {
			throw new Error(`API请求失败: ${response.status}`);
		}

		// 处理SSE流式响应
		const reader = response.body?.getReader();
		if (!reader) {
			throw new Error('无法读取响应流');
		}
		const decoder = new TextDecoder();
		let buffer = '';
		let currentMessageId = '';
		let currentConversationId = '';
		let isDone = false; // 添加一个标志变量
		while (!isDone) {
			const { done, value } = await reader.read();
			isDone = done; // 更新标志变量
			if (done) break;

			// 将二进制数据转换为文本
			buffer += decoder.decode(value, { stream: true });

			// 处理buffer中的事件
			while (buffer.includes('\n\n')) {
				const eventEndIndex = buffer.indexOf('\n\n');
				const eventData = buffer.substring(0, eventEndIndex);
				buffer = buffer.substring(eventEndIndex + 2);
				console.log('eventData:', eventData);

				// 解析事件数据
				if (eventData.startsWith('data: ')) {
					try {
						// console.log('eventData:', eventData);
						const jsonStr = eventData.substring(6); // 去掉 'data: ' 前缀
						const data = JSON.parse(jsonStr);
						// console.log('data:', jsonStr, data);

						// 根据事件类型处理
						if (data.event === 'message') {
							// 保存消息ID和会话ID
							if (data.message_id) {
								currentMessageId = data.message_id;
							}
							if (data.conversation_id) {
								currentConversationId = data.conversation_id;
							}
							// 回调传递内容
							onChunk('message', data.answer || '', currentMessageId, currentConversationId);
						} else if (data.event === 'agent_message') {
							// 保存消息ID和会话ID
							if (data.message_id) {
								currentMessageId = data.message_id;
							}
							if (data.conversation_id) {
								currentConversationId = data.conversation_id;
							}
							// 回调传递内容
							onChunk('message', data.answer || '', currentMessageId, currentConversationId);
						} else if (data.event === 'agent_thought') {
							// 这里可以选择展示思考过程或者不展示
							// 如果想展示思考过程,可以取data.thought字段
							if (data.thought && data.thought.trim() !== '') {
								onChunk('thought', data.thought, data.message_id, data.conversation_id);
							}
						} else if (data.event === 'message_end') {
							// 消息结束事件
							// 提取元数据信息并传递
							const metadata = data.metadata || {};
							onChunk('message_end', '', currentMessageId, currentConversationId, metadata);

							// 输出元数据信息到控制台
							if (metadata) {
								console.log('消息元数据:', metadata);
							}
						} else if (data.event === 'error') {
							// 错误事件
							onChunk('error', data.message || '发生错误', currentMessageId, currentConversationId);
							console.error('Dify API错误:', data);
						} else if (data.event === 'message_file') {
							// 文件事件 - 目前只处理图片
							if (data.type === 'image' && data.url) {
								onChunk('file', `![图片](${data.url})`, currentMessageId, currentConversationId);
							}
						}
					} catch (e) {
						console.error('解析事件数据失败:', e);
					}
				}
			}
		}

		return { messageId: currentMessageId, conversationId: currentConversationId };
	} catch (e) {
		// 如果是由于中断导致的错误,不抛出异常
		if (abortController?.signal.aborted) {
			onChunk('message', '\n[回答已停止]', '', '');
			return { messageId: '', conversationId: '' };
		}

		console.error('Dify API调用错误:', e);
		onChunk('error', '\n[API调用失败,请重试]', '', '');
		throw e;
	}
}

这里用的事原生fetch 直接请求 ,如果你需要封装一下类似token或者其他参数到请求头 的话,这里提供一个我的封装方法,可以截取有用的逻辑,如下:

import { MessagePlain } from './dialog';
import { useUserStore } from '@/store/user';
import { updateToken } from '@/utils/freshToken';
import website from '@/config/website';

// 请求队列
let queue: Array<{ config: RequestConfig; resolve: (value: any) => void }> = [];

// 请求配置类型
interface RequestConfig extends RequestInit {
	url: string;
	headers?: Record<string, string>;
	withoutToken?: boolean;
	withoutTenantId?: boolean;
	withoutShopId?: boolean;
	data: any;
}

// 创建 fetch 请求
const request = async (config: RequestConfig): Promise<any> => {
	const userStore = useUserStore();

	// 请求拦截:添加 Token 和租户信息
	if (!config.headers) config.headers = {};
	if (!config.headers['withoutToken']) {
		config.headers['Authorization'] = userStore.getToken
			? `Bearer ${userStore.getToken}`
			: undefined;
	} else {
		delete config.headers['withoutToken'];
	}

	// 添加租户信息
	if (!config.headers['withoutTenantId']) {
		config.headers['Switch-Tenant-Id'] =
			userStore.getSwitchTenantId || (userStore.userInfo && userStore.userInfo?.tenantId);
	} else {
		delete config.headers['withoutTenantId'];
	}

	// 添加店铺信息
	if (website.clientId !== 'unified') {
		if (!config.headers['withoutShopId']) {
			config.headers['Switch-Shop-Id'] =
				userStore.getSwitchShopId || (userStore.userInfo && userStore.userInfo?.shopId);
		} else {
			delete config.headers['withoutShopId'];
		}
	}

	try {
		const response = await fetch(config.url, config);

		// 响应拦截:处理状态码和错误信息
		if (!response.ok) {
			// const errorData = await response.json();
			const errorData = await response;
			throw { response, data: errorData };
		}
		const data = await response;
		// const data = await response.json();

		// 业务逻辑错误处理
		if (data.code === 1 && data.msg) {
			MessagePlain({
				type: 'error',
				message: data.msg,
			});
			throw data; // 抛出业务错误
		}

		return data; // 返回正常数据
	} catch (error) {
		// 错误处理
		if (error.response) {
			const { response, data } = error;
			const { status } = response;

			// 401 处理:Token 过期
			if (status === 401) {
				if (
					[
						'您的密码已过期,请联系管理员修改密码!',
						'用户名不存在!',
						'TOKEN 已过期,请重新登录!',
					].includes(data?.msg)
				) {
					MessagePlain({
						type: 'error',
						message: data.msg,
					});
					throw error; // 特殊错误直接抛出
				}

				if (userStore.getRefreshing) {
					// 正在刷新 Token,加入队列
					return new Promise(resolve => {
						queue.push({ config, resolve });
					});
				}

				userStore.setRefreshing(true);
				const isTokenRefresh = await updateToken();
				userStore.setRefreshing(false);

				if (isTokenRefresh) {
					// 刷新 Token 成功,重试队列中的请求
					const retryRequests = queue.map(async ({ config, resolve }) => {
						config.headers = config.headers || {};
						config.headers['Authorization'] = `Bearer ${userStore.getToken}`;
						const response = await fetch(config.url, config);
						const data = await response.json();
						resolve(data);
					});

					await Promise.all(retryRequests);
					queue = []; // 清空队列

					// 重试当前请求
					config.headers = config.headers || {};
					config.headers['Authorization'] = `Bearer ${userStore.getToken}`;
					const response = await fetch(config.url, config);
					const data = await response.json();
					return data;
				} else {
					throw error; // 刷新 Token 失败
				}
			}

			// 其他状态码处理
			let message = '';
			switch (status) {
				case 403:
					message = '无权访问';
					break;
				case 404:
					message = '资源不存在';
					break;
				case 500:
					message = '服务器出现异常了,请联系管理员';
					break;
				default:
					message = '网络异常';
					break;
			}

			if (message) {
				MessagePlain({
					type: 'error',
					message,
				});
			}
		}

		throw error; // 抛出其他错误
	}
};

// 封装 GET 请求
export const get = (url: string, config?: Omit<RequestConfig, 'url' | 'method'>) => {
	return request({
		url,
		method: 'GET',
		...config,
	});
};

// 封装 POST 请求
export const post = (
	url: string,
	data?: any,
	config?: Omit<RequestConfig, 'url' | 'method' | 'body'>
) => {
	return request({
		url,
		method: 'POST',
		body: data,
		headers: {
			'Content-Type': 'application/json',
			...config?.headers,
		},
		...config,
	});
};

// 封装 PUT 请求
export const put = (
	url: string,
	data?: any,
	config?: Omit<RequestConfig, 'url' | 'method' | 'body'>
) => {
	return request({
		url,
		method: 'PUT',
		body: data,
		headers: {
			'Content-Type': 'application/json',
			...config?.headers,
		},
		...config,
	});
};

// 封装 DELETE 请求
export const del = (url: string, config?: Omit<RequestConfig, 'url' | 'method'>) => {
	return request({
		url,
		method: 'DELETE',
		...config,
	});
};

export default request;

然后再ts文件中引用就行
那么这个sendChatMessage改怎么用呢?
如下是一个小助手的简易代码,仅供参考

<template>
	<div class="drag-ball">
		<div class="flex-center" ref="floatingButton">
			<div @click.stop="handleClick">
				<el-tooltip class="box-item" effect="dark" content="智能助手" placement="left-start">
					<div v-if="activeStatus" class="robot"></div>
					<div v-else class="active-robot"></div>
				</el-tooltip>
			</div>

			<!-- ai智能助手对话框 -->
			<div class="box-ai" v-if="!activeStatus" ref="boxAi" id="boxAi" :style="panelStyle">
				<div class="title-top" style="width: 100%">
					<div class="title-box">
						<img class="icon" src="@/assets/ai-helper/robot.svg" alt="" />
						<div class="title">智能助手</div>
					</div>
				</div>
				<div class="box-content">
					<!-- 聊天界面 -->
					<div class="chat-window">
						<div
							v-for="(message, index) in messages"
							:key="index"
							:class="['message', message.sender]"
						>
							<!-- 助手消息 -->
							<div v-if="message.role === 'assistant'" class="assistant-message">
								<!-- 成功时模拟打字效果 -->
								<div class="user-avatar">
									<img src="@/assets/ai-helper/robot.svg" alt="用户头像" />
								</div>
								<div class="assistant-text">
									<span class="content" v-html="formatContent(message.content)"></span>
									<!-- 重试 -->
									<div class="retry-button" v-if="stopStatus && index == messages.length - 1">
										<span>已停止,点击</span>
										<el-button type="text" @click.stop="handleContinue">重新生成</el-button>
									</div>
								</div>
							</div>
							<div v-if="index === 0 || shouldDisplayTimestamp(index)" class="timestamp">
								<span class="line"></span>
								{{ formatTimestamp(message.timestamp) }}
								<span class="line"></span>
							</div>
							<!-- 用户消息 -->
							<div v-if="message.role === 'user'" class="user-message">
								<div class="user-text">
									<p>{{ message.content }}</p>
								</div>
								<div class="user-avatar">
									<img src="@/assets/ai-helper/robot.svg" alt="用户头像" />
								</div>
							</div>
						</div>
					</div>
				</div>
				<!-- 底部按钮 -->
				<div class="bottom-area">
					<div class="heistory">
						<el-button>
							<el-icon><Clock /></el-icon>
							<span>历史对话</span>
						</el-button>
						<el-date-picker
							v-model="dateValue"
							type="date"
							placeholder="历史对话"
							:editable="false"
							:clearable="false"
							popper-class="custom-date-picker"
							ref="datePicker"
						></el-date-picker>
					</div>
					<div class="input-area">
						<el-input
							v-model="userInput"
							type="text"
							placeholder="请输入您的问题"
							@keyup.enter="handleSend"
							@input="handleInput"
						>
							<template #append>
								<el-button
									ref="sendButton"
									class="send-button"
									:color="isChange ? '#2575F7' : '#DCDFE6'"
									:disabled="isLoading"
									:loading="isLoading"
									@click.stop="handleSend"
									round="false"
									v-if="!isAnswer"
									style="background-color: var(--el-button-bg-color) !important"
								>
									<template #icon>
										<el-icon>
											<img v-if="isChange" src="@/assets/ai-helper/send-blank.svg" alt="" />
											<img v-else src="@/assets/ai-helper/send.svg" alt="" />
										</el-icon>
									</template>
									<!-- {{ isLoading ? '发送中...' : '发送' }} -->
								</el-button>
								<el-button
									v-else
									:color="'#2575F7'"
									@click.stop="handleStop"
									style="background-color: var(--el-button-bg-color) !important"
									ref="stopButton"
								>
									<template #icon>
										<el-icon><img src="@/assets/ai-helper/stop.svg" alt="" /></el-icon>
									</template>
								</el-button>
							</template>
						</el-input>
					</div>
				</div>
			</div>
		</div>
	</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import DOMPurify from 'dompurify';
import {
	sendChatMessage,
	getConversationId,
	updataMessage,
	getHistoryDate,
	getHistoryList,
} from '@/api/ai-helper';
import type { ChatMessage } from '@/api/ai-helper';
import { formatContent } from '@/hooks/aiHelper';
import { dateFormat } from '@/utils/date';
const router = useRouter();
import { useUserStore } from '@/store/user';
const userStore = useUserStore();

const store = useUserStore().$state;

const props = defineProps({
	position: {
		type: Object,
		required: true,
	},
	setActivePanel: {
		type: Function,
		required: true,
	},
	activePanel: {
		type: String,
		default: null,
	},
});
const panelId = 'box-ai'; // 当前面板的唯一 ID
const boxAi = ref(null);
const sendButton = ref(null);
const stopButton = ref(null);
const activeStatus = ref(true);
const activeData = ref({});
const floatingButton = ref(null);

const userInput = ref('');
const messages = ref<ChatMessage[]>([]);
const isLoading = ref(false);
// 当前响应的消息ID
let currentResponseId = '';

const panelStyle = ref({});
const dateValue = ref();
// 添加一个空的助手消息
const assistantId = ref('');
const isAnswer = ref(false); // 是否正在回答
const isChange = ref(false); // 是否正在输入
const abortController = ref(null); // 用于取消请求
// 当前会话ID
const conversationId = ref<string | null>(null);
// 最近一次响应的元数据
const lastMetadata = ref<any>(null);
// 停止状态
const stopStatus = ref(false);
// 存一份用户输入信息
const userInputValue = ref('');

const datePicker = ref(null);

watch(
	() => props.position,
	() => {
		panelStyle.value = setStyle();
	}
);
// 监听 activePanel 变化
watch(
	() => props.activePanel,
	newActivePanel => {
		console.log('newActivePanel', newActivePanel);
		if (newActivePanel !== panelId) {
			activeStatus.value = true; // 如果其他面板打开,则关闭当前面板
		}
	}
);
onMounted(() => {
	console.log('boxAi', userStore.userInfo);
	getConversationId({ userId: userStore.userInfo.id }).then(res => {
		conversationId.value = res.data;
	});
});
const setStyle = () => {
	const { x, y, width, height } = props.position;
	// 面板尺寸
	const panelWidth = 542; // 面板宽度
	const panelHeight = 720; // 面板高度
	const offset = 20; // 距离父组件的偏移量

	// 判断显示方向
	let left = x + width + offset; // 默认显示在右侧
	if (x + width + panelWidth + offset > window.innerWidth) {
		left = x - panelWidth - offset; // 如果右侧空间不足,显示在左侧
	}

	// 限制面板在可视范围内
	left = Math.max(0, Math.min(left, window.innerWidth - panelWidth));
	const top = Math.max(0, Math.min(y, window.innerHeight - panelHeight));

	return {
		left: `${left}px`,
		top: `${top}px`,
		width: `${panelWidth}px`,
	};
};

// 生成唯一ID
function generateId(): string {
	return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
const timeThreshold = 1000 * 60 * 30; // 30分钟 时间戳显示间隔

const shouldDisplayTimestamp = index => {
	if (index === 0) return true; // 第一条消息总是显示时间
	const timeDiff = messages.value[index].timestamp - messages.value[index - 1].timestamp;
	return timeDiff > timeThreshold;
};

const formatTimestamp = timestamp => {
	return dateFormat(new Date(timestamp), 'yyyy-MM-dd hh:mm:ss');
};
// 添加用户消息
const addUserMessage = (content: string) => {
	messages.value.push({
		id: generateId(),
		role: 'user',
		content,
		timestamp: Date.now(),
	});
};

// 添加助手消息
const addAssistantMessage = (content: string = '') => {
	currentResponseId = generateId();
	messages.value.push({
		id: currentResponseId,
		role: 'assistant',
		content,
		timestamp: Date.now(),
	});
	assistantId.value = currentResponseId;
	scrollToBottom();

	return currentResponseId;
};
// 存储对话消息
const savaMessage = (content: string) => {
	const params = {
		content,
		conversationId: conversationId.value,
		messageId: '',
		role: 'assistant',
		userId: userStore.userInfo.id,
		userName: userStore.userInfo.username,
		timestamp: Date.now(),
		metadata: lastMetadata.value,
		deleteFlag: '',
		createTime: '',
		updatedTime: '',
	};
	updataMessage(params);
};
const handleClick = () => {
	activeStatus.value = !activeStatus.value;
	console.log('activeStatus', activeStatus.value);
	if (!activeStatus.value) {
		nextTick(() => {
			closeAllSubmenus();
			scrollToBottom();
			if (messages.value.length === 0) {
				addAssistantMessage(
					'您好, 我是您的智能助手。您可以向我提问 **云上流程****水厂大屏**在哪里等问题。'
				);
			}
		});
	}
};
watch(activeStatus, newValue => {
	if (newValue) {
		activeData.value = {};
		document.removeEventListener('click', handleClickOutside);
	}
	if (!activeStatus.value) {
		props.setActivePanel(panelId); // 通知父组件当前面板已打开
	}
});
// 监听点击事件,用于关闭所有子菜单
const handleClickOutside = event => {
	const dateDom = document.getElementsByClassName('custom-date-picker')[0];
	// 检查点击的目标是否是 dateDom 或其子元素
	const isClickInsideDateDom = dateDom && dateDom.contains(event.target);
	if (
		!activeStatus.value &&
		datePicker.value &&
		boxAi.value &&
		!boxAi.value.contains(event.target) &&
		!isClickInsideDateDom
	) {
		activeStatus.value = true;
	}
};
// 关闭所有子菜单
const closeAllSubmenus = () => {
	document.addEventListener('click', handleClickOutside);
};

// 直接插入可能会带来 XSS 攻击的风险。建议在插入前对 HTML 内容进行清理
const sanitizeHtml = html => {
	return DOMPurify.sanitize(html);
};
// 模拟打字效果
const typeText = (message, htmlContent, delay = 30) => {
	message.displayText = ''; // 清空当前显示内容

	// 将 HTML 内容拆分为标签和文本
	const regex = /(<[^>]+>|[^<]+)/g;
	const segments = htmlContent.match(regex) || [];

	let index = 0;
	const interval = setInterval(() => {
		if (index < segments.length) {
			message.displayText += segments[index];
			index++;
			scrollToBottom();
		} else {
			clearInterval(interval);
		}
	}, delay);
};

// 更新助手消息内容
function updateAssistantMessage(id: string, content: string) {
	const message = messages.value.find(msg => msg.id === id);
	if (message) {
		message.content += content;
	}
}

// 处理元数据
function processMetadata(metadata: any) {
	if (!metadata) return;

	// 保存元数据供后续展示
	lastMetadata.value = metadata;

	// 可以在这里进行额外的处理,例如提取特定信息等
	console.log('处理元数据:', metadata);
}
// 监听输入框变化
const handleInput = (e: any) => {
	if (e.trim() === '') {
		isChange.value = false;
	} else {
		isChange.value = true;
	}
};

const handleSend = async () => {
	if (userInput.value.trim() && !isLoading.value) {
		isLoading.value = true;
		isAnswer.value = true;

		// 创建新的AbortController
		abortController.value = new AbortController();
		addUserMessage(userInput.value);
		addAssistantMessage('[正在处理...]');
		userInputValue.value = JSON.parse(JSON.stringify(userInput.value));
		try {
			const result = await sendChatMessage(
				userInput.value,
				conversationId.value,
				(event, content, msgId, convId, metadata) => {
					userInput.value = '';
					// 处理不同的事件类型
					if (event === 'message') {
						// 如果是第一个消息块,清除状态提示
						const message = messages.value.find(msg => msg.id === assistantId.value);
						if (message && message.content === '[正在处理...]') {
							message.content = '';
						}
						// 更新消息内容
						updateAssistantMessage(assistantId.value, content);
						scrollToBottom();
					} else if (event === 'thought') {
						// // 处理思考过程
						// if (!hasThought) {
						// 	// 首次收到思考内容
						// 	thoughtId = addThoughtMessage(`[思考] ${content}`);
						// 	hasThought = true;
						// } else {
						// 	// 更新思考内容
						// 	updateThoughtMessage(thoughtId, `[思考] ${content}`);
						// }
						scrollToBottom();
					} else if (event === 'message_end') {
						// 消息结束,保存会话ID
						if (convId) {
							conversationId.value = convId;
						}
						userInput.value = '';
						isLoading.value = false;
						isChange.value = false;
						isAnswer.value = false;

						// 处理元数据
						if (metadata) {
							processMetadata(metadata);
						}

						console.log('会话:', messages.value);
						// 存 历史记录
						// savaMessage(userInputValue.value);
					} else if (event === 'error') {
						// 错误消息
						const message = messages.value.find(msg => msg.id === assistantId.value);
						if (message) {
							if (message.content === '[正在处理...]') {
								message.content = content;
							} else {
								message.content += content;
							}
						}
						isChange.value = false;
						isAnswer.value = false;
					} else if (event === 'file') {
						// 文件(例如图片)
						updateAssistantMessage(assistantId.value, content);
						scrollToBottom();
					}
				},
				abortController.value
			);
		} catch (error) {
			// 添加错误消息
			console.error('处理查询时出错:', error);
			// 如果出错,显示错误信息
			const message = messages.value.find(msg => msg.id === assistantId.value);
			if (message) {
				if (message.content === '[正在处理...]') {
					message.content = '[发生错误,请重试]';
				} else {
					message.content += '\n[发生错误,请重试]';
				}
			}
			isChange.value = false;
			isAnswer.value = false;
		} finally {
			console.log('会话:', messages.value);
			isLoading.value = false;
			isAnswer.value = false;
			isChange.value = false;
			userInput.value = '';
			abortController.value = null;
			scrollToBottom();
		}
	}
};
// 停止查询
const handleStop = () => {
	if (abortController.value) {
		abortController.value.abort();
		abortController.value = null;
		stopStatus.value = true;
	}
	isAnswer.value = false;
};
// 继续查询
const handleContinue = () => {
	stopStatus.value = false;
	// 去除最后两条消息,重新发起
	messages.value.splice(-2, 2);
	console.log('userInputValue.value', userInputValue.value);
	userInput.value = userInputValue.value;
	handleSend();
};
// 滚动到底部
const scrollToBottom = () => {
	const chatWindow = document.querySelector('.chat-window');
	if (chatWindow) {
		chatWindow.scrollTop = chatWindow.scrollHeight;
	}
};
</script>

<style scoped lang="scss">
.timestamp {
	display: flex;
	align-items: center;
	justify-content: center;
	color: #a8abb2;
	margin-top: 24px;

	.line {
		display: inline-block;
		width: 24px;
		height: 1px;
		background: #e5e6eb;
		margin: 0 8px;
	}
}

.flex-center {
	z-index: 9;

	.robot {
		width: 20px;
		height: 20px;
		// border-radius: 50%;
		background: #fff;
		display: flex;
		justify-content: center;
		align-items: center;
		background-image: url('@/assets/ai-helper/robot.svg');
		background-size: 20px 20px; /* 图像尺寸 20px x 20px */
		background-position: center; /* 图像居中 */
		background-repeat: no-repeat; /* 防止图像重复 */
		transition: background-image 0.3s ease;
		position: relative;
	}
	.robot:hover {
		background-image: url('@/assets/ai-helper/hover.svg');
	}
	.active-robot {
		width: 20px;
		height: 20px;
		// border-radius: 50%;
		background: #fff;
		display: flex;
		justify-content: center;
		align-items: center;
		background-image: url('@/assets/ai-helper/hover.svg');
		background-size: 20px 20px; /* 图像尺寸 20px x 20px */
		background-position: center; /* 图像居中 */
		background-repeat: no-repeat; /* 防止图像重复 */
		position: relative;
	}
}
.box-ai {
	position: fixed;
	right: 58px;
	width: 542px;
	background: #ffffff;
	box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.1);
	border-radius: 4px;
	border: 1px solid #e5e6eb;
	z-index: 2001;

	.title-top {
		width: 100%;
		height: 42px;
		display: flex;
		justify-content: center;
		align-items: center;
	}
	.title-box {
		// height: 100%;
		padding: 12px 0;
		width: calc(100% - 16px);
		display: flex;
		border-bottom: 1px solid #e5e6eb;
	}
	.icon {
		width: 16px;
		height: 16px;
		margin-right: 4px;
	}
	.title {
		width: 84px;
		height: 18px;
		font-family:
			PingFangSC,
			PingFang SC;
		font-weight: 500;
		font-size: 14px;
		color: #86909c;
		line-height: 18px;
		text-align: left;
		font-style: normal;
	}
	.box-content {
		padding: 4px;
	}
}
.chat-window {
	height: 500px;
	overflow-y: auto;
	border-radius: 4px;
	padding: 10px;
	margin-bottom: 20px;
	background-color: #fff;
}

.message {
	margin-bottom: 10px;
}

.user-message {
	// text-align: right;
	display: flex;
	justify-content: flex-end;

	.user-text {
		display: inline-block;
		// padding-right: 32px;
	}
}
.user-avatar {
	width: 32px;
	height: 32px;
	background: #eef4ff;
	border-radius: 50%;
	display: inline-block;
	text-align: center;
	line-height: 37px;
}
.user-message p {
	background-color: #007bff;
	color: white;
	display: inline-block;
	padding: 8px 12px;
	border-radius: 12px;
}

.assistant-message {
	// text-align: left;
	display: flex;
	justify-content: flex-start;

	.content {
		background-color: #e9ecef;
		color: black;
		display: inline-block;
		padding: 8px 12px;
		border-radius: 12px;
	}
	.assistant-text {
		display: inline-block;
		padding-left: 10px;
		padding-right: 114px;
		position: relative;
	}
	.retry-button {
		position: absolute;
		color: #a8abb2;
		font-size: 14px;
	}
}

.error-message {
	color: #dc3545;
	font-weight: bold;
}
.bottom-area {
	padding: 0 12px 15px;

	.heistory {
		margin-bottom: 8px;

		span {
			margin-left: 4px;
		}
	}
}

.input-area {
	display: flex;
	align-items: center;
	justify-content: space-between;
	height: 32px;
}

.input-area input {
	flex: 1;
	padding: 10px;
	border: 1px solid #ccc;
	border-radius: 4px;
	margin-right: 10px;
}

.input-area button {
	padding: 10px 20px;
	border: none;
	border-radius: 4px;
	cursor: pointer;
}

.input-area button:disabled {
	background-color: #ccc;
	cursor: not-allowed;
}

// .input-area button:hover:not(:disabled) {
// 	background-color: #0056b3;
// }

.loading-indicator {
	text-align: left;
	margin-top: 10px;
}

.typing-animation {
	display: inline-block;
}

.typing-animation span {
	display: inline-block;
	width: 8px;
	height: 8px;
	background-color: #007bff;
	border-radius: 50%;
	margin: 0 2px;
	animation: typing 1s infinite;
}

.typing-animation span:nth-child(2) {
	animation-delay: 0.2s;
}

.typing-animation span:nth-child(3) {
	animation-delay: 0.4s;
}

@keyframes typing {
	0% {
		transform: translateY(0);
	}
	50% {
		transform: translateY(-5px);
	}
	100% {
		transform: translateY(0);
	}
}
</style>

主要的逻辑就是handleSend 方法,用于消息,并且处理返回数据,反显至页面

三、如果后端返回是纯文本,且全部返回时,需要前端实现流式输出的话

可以借鉴这个方法

// 模拟打字效果
const typeText = (message, htmlContent, delay = 30) => {
	message.displayText = ''; // 清空当前显示内容

	// 将 HTML 内容拆分为标签和文本
	const regex = /(<[^>]+>|[^<]+)/g;
	const segments = htmlContent.match(regex) || [];

	let index = 0;
	const interval = setInterval(() => {
		if (index < segments.length) {
			message.displayText += segments[index];
			index++;
			scrollToBottom();
		} else {
			clearInterval(interval);
		}
	}, delay);
};

四、 中断、停止、继续

这里需要用到fetch请求中的一个参数 signal
在封装的 fetch 函数中 添加 在这里插入图片描述
然后post 函数增加
在这里插入图片描述
在使用时 如下:
在这里插入图片描述
或者直接使用原生fetch请求
在这里插入图片描述

五、实现效果

如下:
在这里插入图片描述

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐