接入大模型!前端怎么处理SSE流式返回呢?
这里一般都是二次封装的开放接口,而一般axios不支持流式响应,要借用一些插件,本文在这里直接用fetch请求 ,fetch对于SSE请求有天然优势。是一种在客户端与服务器之间建立持久性单向连接的技术,服务器可以通过该连接向客户端发送任意数量的数据。SSE 支持的数据流格式较为简单,每条数据都以事件块的形式发送,并以双换行符结束。SSE 与 WebSocket 都是常用于实时数据推送的技术,但相比
一、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', ``, 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请求
五、实现效果
如下:
更多推荐

所有评论(0)