欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 文旅 > 艺术 > 接入大模型!前端怎么处理SSE流式返回呢?

接入大模型!前端怎么处理SSE流式返回呢?

2025/3/18 22:12:26 来源:https://blog.csdn.net/weixin_49549509/article/details/146316183  浏览:    关键词:接入大模型!前端怎么处理SSE流式返回呢?

一、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和会话IDif (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和会话IDif (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"><divv-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-pickerv-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-inputv-model="userInput"type="text"placeholder="请输入您的问题"@keyup.enter="handleSend"@input="handleInput"><template #append><el-buttonref="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-buttonv-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;// 创建新的AbortControllerabortController.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') {// 消息结束,保存会话IDif (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请求
在这里插入图片描述

五、实现效果

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

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词