实现多模型同时对话
功能特点:
1、抽离对话框成单独组件ChatBox.vue,在新增模型对比窗口时可重复利用
2、通过sse与后台实时数据流,通过定时器实现打字效果
3、适应深度思考内容输出,可点击展开与闭合
4、可配置模型参数,本地存储当前模型参数和对话记录,页面关闭时清除
5、通过是否响应<think>标签来识别是否有深度思考内容
6、通过响应的finishReason字段,识别回答是否已停止(null正常回答,stop回答结束,length超出文本)
安装插件
highlight.js、markdown-it
创建对话窗口组件ChatBox.vue
<template>
<el-card class="box-card">
<div slot="header" class="clearfix">
<div class="header-item-box">
<vxe-select v-model="modelType">
<vxe-option
v-for="(item, i) in modelTypeList"
:key="i"
:value="item.id"
:label="item.modelName"
></vxe-option>
</vxe-select>
<div>
<vxe-button
@click="handleDeleteCurModel"
type="text"
icon="iconfont icon-zhiyuanfanhui9"
v-if="modelIndex !== 1"
></vxe-button>
<vxe-button
@click="handleParamsConfig"
type="text"
icon="vxe-icon-setting"
></vxe-button>
</div>
</div>
</div>
<div ref="logContainer" class="talk-box">
<div class="talk-content">
<el-row
v-for="(item, i) in contentList"
:key="i"
class="chat-assistant"
>
<transition name="fade">
<div
:class="['answer-cont', item.type === 'user' ? 'end' : 'start']"
>
<img v-if="item.type == 'assistant'" :src="welcome.icon" />
<div :class="item.type === 'user' ? 'send-item' : 'answer-item'">
<div
v-if="item.type == 'assistant'"
class="hashrate-markdown"
v-html="item.message"
/>
<div v-else>{{ item.message }}</div>
</div>
</div>
</transition>
</el-row>
</div>
</div>
<ModelParamConfig ref="ModelParamConfigRef"></ModelParamConfig>
</el-card>
</template>
<script>
import hljs from 'highlight.js'
import 'highlight.js/styles/a11y-dark.css'
import MarkdownIt from 'markdown-it'
import { chatSubmit, chatCancel } from '@/api/evaluationServer/modelTalk.js'
import { mapGetters } from 'vuex'
import { sse } from '@/utils/sse.js'
import ModelParamConfig from '@/views/evaluationServer/modelTalk/components/ModelParamConfig.vue'
window.hiddenThink = function (index) {
// 隐藏思考内容
if (
document.getElementById(`think_content_${index}`).style.display == 'none'
) {
document.getElementById(`think_content_${index}`).style.display = 'block'
document
.getElementById(`think_icon_${index}`)
.classList.replace('vxe-icon-arrow-up', 'vxe-icon-arrow-down')
} else {
document.getElementById(`think_content_${index}`).style.display = 'none'
document
.getElementById(`think_icon_${index}`)
.classList.replace('vxe-icon-arrow-down', 'vxe-icon-arrow-up')
}
}
export default {
props: {
modelTypeList: {
type: Array,
default: () => []
},
modelIndex: {
type: Number,
default: 1
},
/**
* 模型窗口
*/
modelDomIndex: {
type: Number,
default: 0
}
},
components: { ModelParamConfig },
computed: {
...mapGetters(['token'])
},
data() {
return {
modelType: '',
inputMessage: '',
contentList: [],
answerTitle: '',
thinkTime: null,
startAnwer: false,
startTime: null,
endTime: null,
typingInterval: null,
msgHight: null,
welcome: {
title: '',
desc: '',
icon: require('@/assets/images/kubercloud-logo.png')
},
markdownIt: {},
historyList: [], //记录发送和回答的纯文本 。user提问者,assistant回答者
lastScrollHeight: 0
}
},
mounted() {
setTimeout(() => {
this.markdownIt = MarkdownIt({
html: true,
linkify: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value
} catch (__) {
console.log(__)
}
}
return ''
}
})
if (this.modelTypeList && this.modelTypeList.length) {
this.modelType = this.modelTypeList[0].id
}
}, 500)
},
methods: {
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
},
handleDeleteCurModel() {
this.$emit('handleDeleteCurModel', this.modelIndex)
},
clearHistory() {
this.contentList = []
this.historyList = []
},
async sendMessage({ message }) {
this.inputMessage = message
const name = this.modelTypeList.find(
item => item.id === this.modelType
)?.name
if (!name) return
let params = {
historyList: [...this.historyList],
text: this.inputMessage,
deployId: this.modelType,
temperature: 1,
maxTokens: 1024,
topP: 1,
seed: '',
stopSequence: '',
modelName: name
}
let modelParams = sessionStorage.getItem(
'modelTalkParams-' + this.modelIndex
)
if (modelParams) {
modelParams = JSON.parse(modelParams)
params = {
...params,
...modelParams,
modelName: name
}
}
const res = await chatSubmit(params)
const { code, obj } = res.data
if (code == 1) {
this.chatId = obj
const thinkIngTxt = this.$t('modelTalk.tips.thinkIng') //思考中……
this.contentList.push({ type: 'user', message: this.inputMessage })
this.historyList.push({ role: 'user', content: this.inputMessage })
this.contentList.push({
type: 'assistant',
message: `<div class="think-time">${thinkIngTxt}</div>`
})
this.answerTitle =
this.answerTitle || this.contentList[0].message.substring(0, 20)
this.scrollToBottom()
this.lastScrollHeight = 0
this.connectSSE(obj)
}
},
// 启动连接
connectSSE(chatId) {
this.inputMessage = ''
let buffer = ''
let displayBuffer = ''
this.startTime = null
this.endTime = null
this.thinkTime = null
let len = this.contentList.length
let index = len % 2 === 0 ? len - 1 : len
let historylen = this.historyList.length
let historyIndex = historylen % 2 === 0 ? historylen - 1 : historylen
this.isTalking = true
let anwerContent = ''
this.connectionId = sse.connect(
{
url: '/api/stream/chat',
params: {
chatId
}
},
{
onOpen: id => {
console.log(`连接[${id}]已建立`)
},
onMessage: async (data, id) => {
await this.sleep(10)
try {
var { content, finishReason } = data
} catch (e) {
console.log('e: ', e)
}
if (data && content) {
let answerCont = content
buffer += answerCont
anwerContent += answerCont
this.$set(this.historyList, historyIndex, {
role: 'assistant',
content: anwerContent
})
const thinkIngTxt = this.$t('modelTalk.tips.thinkIng') //思考中……
const deeplyPonderedTxt = this.$t('modelTalk.tips.deeplyPondered') //已深度思考
// 单独记录时间
if (
answerCont.includes('<think>') ||
answerCont.includes('</think>')
) {
// 执行替换逻辑
if (answerCont.includes('<think>')) {
answerCont = `<div class="think-time">${thinkIngTxt}</div><section id="think_content_${index}">`
buffer = buffer.replaceAll('<think>', answerCont)
this.startTime = Math.floor(new Date().getTime() / 1000)
}
if (answerCont.includes('</think>')) {
answerCont = `</section>`
this.endTime = Math.floor(new Date().getTime() / 1000)
// 获取到结束直接后,直接展示收起按钮
this.thinkTime = this.endTime - this.startTime
buffer = buffer
.replaceAll(
`<div class="think-time">${thinkIngTxt}</div>`,
`<div class="think-time">${deeplyPonderedTxt}(${this.thinkTime}S)<i id="think_icon_${index}" onclick="hiddenThink(${index})" class="vxe-icon-arrow-down"></i></div>`
)
.replaceAll('</think>', answerCont)
.replaceAll(
`<section id="think_content_${index}"></section>`,
''
)
}
// 避免闪动 直接修改数据,这里不需要打字效果
displayBuffer = buffer // 同步displayBuffer避免断层
this.$set(this.contentList, index, {
type: 'assistant',
message: this.markdownIt.render(buffer)
})
this.scrollToBottomIfAtBottom()
} else {
// 逐字效果
if (!this.typingInterval) {
this.typingInterval = setInterval(() => {
if (displayBuffer.length < buffer.length) {
const remaining = buffer.length - displayBuffer.length
// 暂定一次性加3个字符
const addChars = buffer.substr(
displayBuffer.length,
Math.min(3, remaining)
)
displayBuffer += addChars
let markedText = this.markdownIt.render(displayBuffer)
this.$set(this.contentList, index, {
type: 'assistant',
message: markedText
})
this.scrollToBottomIfAtBottom()
} else {
clearInterval(this.typingInterval)
this.typingInterval = null
}
}, 40)
}
}
} else {
if (['stop', 'length'].includes(finishReason)) {
this.scrollToBottomIfAtBottom()
this.isTalking = false
this.$emit('handleModelAnswerEnd', {
modelIndex: this.modelIndex,
contentList: this.contentList,
finishReason: finishReason
})
}
}
},
onError: (err, id) => {
console.error(`连接[${id}]错误:`, err)
},
onFinalError: (err, id) => {
console.log(`连接[${id}]已失败`)
}
}
)
},
disconnectSSE() {
sse.close()
},
async handleModelStop() {
const res = await chatCancel({ chatId: this.chatId })
const { code } = res.data
if (code == 1) {
this.handleCleanOptionAndData()
}
},
handleCleanOptionAndData() {
this.disconnectSSE()
this.isTalking = false
setTimeout(() => {
//清除强制停止的对话记录
this.historyList = this.historyList.slice(0, -2)
}, 100)
},
scrollToBottom() {
this.$nextTick(() => {
const logContainer = document.querySelectorAll(
`.chat-content-box .el-card__body`
)[this.modelDomIndex]
if (logContainer) {
logContainer.scrollTop = logContainer.scrollHeight
}
})
},
scrollToBottomIfAtBottom() {
this.$nextTick(() => {
const logContainer = document.querySelectorAll(
`.chat-content-box .el-card__body`
)[this.modelDomIndex]
if (!logContainer) return
const threshold = 100
const distanceToBottom =
logContainer.scrollHeight -
logContainer.scrollTop -
logContainer.clientHeight
// 获取上次滚动位置
const lastScrollHeight = this.lastScrollHeight || 0
// 计算新增内容高度
const deltaHeight = logContainer.scrollHeight - lastScrollHeight
// 如果新增内容高度超过阈值50%,强制滚动
if (deltaHeight > threshold / 2) {
logContainer.scrollTop = logContainer.scrollHeight
}
// 否则正常滚动逻辑
else if (distanceToBottom <= threshold) {
logContainer.scrollTop = logContainer.scrollHeight
}
// 更新上次滚动位置记录
this.lastScrollHeight = logContainer.scrollHeight
})
/* logContainer.scrollTo({
top: logContainer.scrollHeight,
behavior: 'smooth'
}) */
},
handleParamsConfig() {
const modelRow = this.modelTypeList.find(
item => item.id === this.modelType
)
this.$refs.ModelParamConfigRef.handleShow({
...modelRow,
modelIndex: this.modelIndex
})
}
}
}
</script>
<style lang="scss" scoped>
.box-card {
flex: 1;
margin-bottom: 5px;
height: 100%;
display: flex;
flex-direction: column;
.header-item-box {
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-text-box {
overflow: hidden;
overflow-y: auto;
}
::v-deep .el-card__body {
padding: 20px;
flex: 1;
overflow-y: auto;
.talk-box {
.talk-content {
background-color: #fff;
color: #324659;
overflow-y: auto;
box-sizing: border-box;
padding: 0px 20px;
.chat-assistant {
display: flex;
margin-bottom: 10px;
.send-item {
max-width: 60%;
word-break: break-all;
padding: 10px;
background: #eef6ff;
border-radius: 10px;
color: #000000;
white-space: pre-wrap;
font-size: 13px;
}
.answer-item {
line-height: 30px;
color: #324659;
}
}
.answer-cont {
position: relative;
display: flex;
width: 100%;
> img {
width: 32px;
height: 32px;
margin-right: 10px;
}
&.end {
justify-content: flex-end;
}
&.start {
justify-content: flex-start;
}
}
}
.chat-sse {
min-height: 100px;
max-height: 460px;
}
.chat-message {
height: calc(100vh - 276px);
}
.thinking-bubble {
height: calc(100vh - 296px);
}
}
.chat-add {
width: 111px;
height: 33px;
background: #dbeafe;
border-radius: 6px !important;
font-size: 14px !important;
border: 0px;
color: #516ffe !important;
&:hover {
background: #ebf0f7;
}
.icon-tianjia1 {
margin-right: 10px;
font-size: 14px;
}
}
.talk-btn-cont {
text-align: right;
height: 30px;
margin-top: 5px;
}
}
}
</style>
创建主页面index.vue
<template>
<div class="x-container-wrapper chat-page-box">
<div class="chat-content-box">
<template v-for="(item, i) in chatBoxs">
<ChatBox
:key="item.id"
v-if="item.show"
:ref="el => setChatBoxRef(el, item.id)"
:modelTypeList="modelTypeList"
:modelIndex="item.id"
:modelDomIndex="getModelDomIndex(i)"
@handleDeleteCurModel="handleDeleteCurModel"
@handleModelAnswerEnd="handleModelAnswerEnd"
></ChatBox>
</template>
</div>
<div class="middle-option-box">
<vxe-button
type="text"
icon="iconfont icon-qingchu"
:disabled="hasAnsweringStatus"
@click="handelAllHistoryAnswer"
></vxe-button>
<vxe-button
type="text"
icon="vxe-icon-square-plus-square"
:disabled="hasAnsweringStatus"
style="font-size: 24px"
@click="handleAddModel"
></vxe-button>
</div>
<div class="bottom-send-box">
<div class="talk-send">
<textarea
@keydown="handleKeydown"
ref="input"
v-model="inputMessage"
@input="adjustInputHeight"
:placeholder="$t('modelTalk.placeholder.sendMessage')"
:rows="2"
/>
<div class="talk-btn-cont" style="text-align: right; font-size: 18px">
<!-- 发送消息 -->
<vxe-button
v-if="!hasAnsweringStatus"
type="text"
@click="sendMessage"
:disabled="sendBtnDisabled"
icon="vxe-icon-send-fill"
style="font-size: 24px"
></vxe-button>
<!-- 停止回答 -->
<vxe-button
v-else
type="text"
@click="handleModelStop"
icon="vxe-icon-radio-checked"
style="font-size: 24px"
></vxe-button>
</div>
</div>
</div>
</div>
</template>
<script>
import 'highlight.js/styles/a11y-dark.css'
import { listModels } from '@/api/evaluationServer/modelTalk.js'
import ChatBox from '@/views/evaluationServer/modelTalk/components/ChatBox.vue'
import * as notify from '@/utils/notify'
export default {
components: { ChatBox },
data() {
return {
modelType: '',
modelTypeList: [],
inputMessage: '',
eventSourceChat: null,
answerTitle: '',
thinkTime: null,
startAnwer: false,
startTime: null,
endTime: null,
typingInterval: null,
msgHight: null,
chatBoxs: [
{ id: 1, content: '', show: true, isAnswerIng: false },
{ id: 2, content: '', show: false, isAnswerIng: false },
{ id: 3, content: '', show: false, isAnswerIng: false }
]
}
},
mounted() {
this.getModelList()
},
methods: {
async getModelList() {
const params = { offset: 0, limit: '10000' }
const res = await listModels(params)
const { code, rows } = res.data
if (code == 1) {
this.modelTypeList = rows
if (rows && rows.length) {
this.modelType = rows[0].id
}
}
},
/* 清除提问和回答记录 */
handelAllHistoryAnswer() {
this.chatBoxs.forEach(item => {
item.content = ''
const ref = this.$refs[`ChatBoxRef${item.id}`]
if (ref && ref.clearHistory) {
ref.clearHistory()
}
})
notify.success(this.$t('modelTalk.tips.cleanrecorded'))
},
/* 增加模型窗口,最多三个 */
handleAddModel() {
const hasUnShow = this.chatBoxs.some(item => !item.show)
if (hasUnShow) {
const unShowRow = this.chatBoxs.filter(item => !item.show)
if (unShowRow && unShowRow.length) {
const index = this.chatBoxs.findIndex(
item => item.id === unShowRow[0].id
)
this.chatBoxs[index].show = true
}
} else {
notify.warning(this.$t('modelTalk.tips.maxModelNum3'))
}
},
/* 获取当前模型窗口位于第几个dom */
getModelDomIndex(i) {
if (!i) return i
const hasShowModels = this.chatBoxs.filter(res => res.show)
const hasShowLength = hasShowModels.length
if (hasShowLength === 3) return i
if (hasShowLength === 2) return 1
},
// enter键盘按下的换行赋值为空
adjustInputHeight(event) {
if (event.key === 'Enter' && !event.shiftKey) {
this.inputMessage = ''
event.preventDefault()
return
}
this.$nextTick(() => {
const textarea = this.$refs.input
textarea.style.height = 'auto'
// 最高200px
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'
this.msgHight = textarea.style.height
})
},
/* 如果按下Enter键 */
handleKeydown(event) {
if (event.isComposing) {
return
}
if (event.key === 'Enter') {
//Enter+shift 换行
if (event.shiftKey) {
return
} else {
// 按Enter,阻止默认行为,发送
event.preventDefault()
this.sendMessage()
}
}
},
/* 主动停止模型回答 */
handleModelStop() {
this.chatBoxs.forEach(item => {
if (!item.isAnswerIng) return
const ref = this.$refs[`ChatBoxRef${item.id}`]
if (ref?.handleModelStop) {
ref.handleModelStop().finally(() => {
this.handleModelAnswerEnd({
modelIndex: item.id,
finishReason: 'stop'
})
})
}
})
},
/* 处理模型回答结束,更改回答状态 */
handleModelAnswerEnd(data) {
const { modelIndex, finishReason } = data
//stop正常响应结束,length输出长度达到限制
if (['stop', 'length'].includes(finishReason)) {
this.$set(this.chatBoxs[modelIndex - 1], 'isAnswerIng', false)
}
},
setChatBoxRef(el, id) {
if (el) {
this.$refs[`ChatBoxRef${id}`] = el
} else {
delete this.$refs[`ChatBoxRef${id}`]
}
},
/* 发送消息 */
sendMessage() {
if (!this.inputMessage.trim()) return
if (!this.modelTypeList.length || !this.modelType) {
//请选择模型
notify.warning(this.$t('modelTalk.tips.seleModel'))
return
}
this.$nextTick(() => {
this.chatBoxs.forEach((item, i) => {
const ref = this.$refs[`ChatBoxRef${item.id}`]
if (ref && ref.sendMessage) {
this.chatBoxs[i].isAnswerIng = true
ref.sendMessage({
message: this.inputMessage,
modelIndex: item.id
})
}
})
this.inputMessage = ''
})
},
/* 删除当前对话模型 */
handleDeleteCurModel(index) {
this.chatBoxs[index - 1].show = false
if (sessionStorage.getItem(`modelTalkParams-${index}`)) {
sessionStorage.removeItem(`modelTalkParams-${index}`)
}
}
},
//清除sessionStorage中存储的模型参数
beforeDestroy() {
this.chatBoxs.forEach(item => {
sessionStorage.removeItem(`modelTalkParams-${item.id}`)
})
},
computed: {
//输入框文本不为空,且不是回答中的状态:发送按钮可用
sendBtnDisabled() {
return !this.inputMessage.trim()
},
//存在回答中的状态
hasAnsweringStatus() {
return this.chatBoxs.some(item => item.show && item.isAnswerIng)
}
}
}
</script>
<style lang="scss">
// 尝试使用 @import 替代 @use 引入文件
@import '~@/assets/css/styles/chat-box-markdown.scss';
</style>
<style lang="scss" scoped>
.chat-page-box {
display: flex;
flex-direction: column;
.chat-content-box {
flex: 1;
overflow: hidden;
padding-top: 10px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
gap: 10px;
}
}
.middle-option-box {
height: 30px;
line-height: 30px;
margin-top: 10px;
::v-deep .vxe-button {
.iconfont {
font-size: 24px !important;
}
}
}
.bottom-send-box {
width: 100%;
min-height: 124px;
padding: 10px 0;
.talk-send {
height: 100%;
background: #f1f2f7;
border-radius: 10px;
border: 1px solid #e9e9eb;
padding: 5px 10px;
img {
cursor: pointer;
}
textarea {
width: 100%;
padding: 10px;
resize: none;
overflow: auto;
// min-height: 48px;
height: 60px !important;
line-height: 1.5;
box-sizing: border-box;
font-family: inherit;
border: 0px;
background: #f1f2f7;
}
textarea:focus {
outline: none !important;
}
}
}
</style>
样式scss
.hashrate-markdown {
font-size: 14px;
}
.hashrate-markdown ol,
.hashrate-markdown ul {
padding-left: 2em;
}
.hashrate-markdown pre {
border-radius: 6px;
line-height: 1.45;
overflow: auto;
display: block;
overflow-x: auto;
background: #2c2c36;
color: rgb(248, 248, 242);
padding: 16px 8px;
}
.hashrate-markdown h1,
.hashrate-markdown h2,
.hashrate-markdown h3 {
// font-size: 1em;
}
.hashrate-markdown h4,
.hashrate-markdown h5,
.hashrate-markdown h6 {
font-weight: 600;
line-height: 1.7777;
margin: 0.57142857em 0;
}
.hashrate-markdown li {
margin: 0.5em 0;
}
.hashrate-markdown strong {
font-weight: 600;
}
.hashrate-markdown p {
white-space: pre-wrap;
word-break: break-word;
line-height: 24px;
color: #324659;
font-size: 14px;
}
.hashrate-markdown hr {
background-color: #e8eaf2;
border: 0;
box-sizing: content-box;
height: 1px;
margin: 12px 0;
min-width: 10px;
overflow: hidden;
padding: 0;
}
.hashrate-markdown table {
border-collapse: collapse;
border-spacing: 0;
display: block;
max-width: 100%;
overflow: auto;
width: max-content;
}
.hashrate-markdown table tr {
border-top: 1px solid #e8eaf2;
}
.hashrate-markdown table td,
.hashrate-markdown table th {
border: 1px solid #e8eaf2;
padding: 6px 13px;
}
.hashrate-markdown table th {
background-color: #f3f2ff;
font-weight: 600;
}
.hashrate-markdown section {
margin-inline-start: 0px;
border-left: 2px solid #e5e5e5;
padding-left: 10px;
color: #718096;
margin-bottom: 5px;
font-size: 12px;
p {
color: #718096;
font-size: 12px;
margin: 8px 0;
}
}
.think-time {
height: 36px;
background: #f1f2f7;
border-radius: 10px;
line-height: 36px;
font-size: 12px;
display: inline-flex;
padding: 0px 15px;
margin-bottom: 20px;
color: #1e1e1e;
>i{
line-height: 36px;
margin-left: 5px;
}
}
封装sse.js
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { getToken } from '@/utils/auth' // 假设从auth工具获取token
class SSEService {
constructor() {
this.connections = new Map() // 存储所有连接 { connectionId: { controller, config } }
this.DEFAULT_ID_PREFIX = 'sse_conn_'
this.MAX_RETRIES = 3 // 最大自动重试次数
this.BASE_RETRY_DELAY = 1000 // 基础重试延迟(ms)
// 默认请求头(可通过setDefaultHeaders动态更新)
this.DEFAULT_HEADERS = {
'Content-Type': 'application/json',
'X-Auth-Token': getToken() || ''
}
}
/**
* 创建SSE连接
* @param {Object} config - 连接配置
* @param {String} config.url - 接口地址
* @param {'GET'|'POST'} [config.method='GET'] - 请求方法
* @param {Object} [config.params={}] - 请求参数
* @param {Object} [config.headers={}] - 自定义请求头
* @param {String} [config.connectionId] - 自定义连接ID
* @param {Object} handlers - 事件处理器
* @returns {String} connectionId
*/
connect(config = {}, handlers = {}) {
const connectionId = config.connectionId || this._generateConnectionId()
this.close(connectionId)
// 合并headers(自定义优先)
const headers = {
...this.DEFAULT_HEADERS,
...(config.headers || {}),
'X-Auth-Token': getToken() || '' // 确保token最新
}
// 构建请求配置
const requestConfig = {
method: config.method || 'GET',
headers,
signal: this._createController(connectionId),
openWhenHidden: true // 页面隐藏时保持连接
}
// 处理URL和参数
const requestUrl = this._buildUrl(
config.url,
config.params,
requestConfig.method
)
if (requestConfig.method === 'POST') {
requestConfig.body = JSON.stringify(config.params || {})
}
// 存储连接信息
this.connections.set(connectionId, {
config: { ...config, connectionId },
controller: requestConfig.signal.controller,
retryCount: 0
})
// 发起连接
this._establishConnection(requestUrl, requestConfig, connectionId, handlers)
return connectionId
}
/**
* 实际建立连接(含自动重试逻辑)
*/
async _establishConnection(url, config, connectionId, handlers) {
const connection = this.connections.get(connectionId)
try {
await fetchEventSource(url, {
...config,
onopen: async response => {
if (response.ok) {
connection.retryCount = 0
handlers.onOpen?.(connectionId)
} else {
throw new Error(`SSE连接失败: ${response.status}`)
}
},
onmessage: msg => {
try {
const data = msg.data ? JSON.parse(msg.data) : null
handlers.onMessage?.(data, connectionId)
} catch (err) {
handlers.onError?.(err, connectionId)
}
},
onerror: err => {
if (connection.retryCount < this.MAX_RETRIES) {
const delay =
this.BASE_RETRY_DELAY * Math.pow(2, connection.retryCount)
setTimeout(() => {
connection.retryCount++
this._establishConnection(url, config, connectionId, handlers)
}, delay)
} else {
handlers.onFinalError?.(err, connectionId)
this.close(connectionId)
}
throw err // 阻止库默认的重试逻辑
}
})
} catch (err) {
console.error(`[SSE ${connectionId}] 连接异常:`, err)
}
}
/**
* 关闭指定连接
*/
close(connectionId) {
const conn = this.connections.get(connectionId)
if (conn?.controller) {
conn.controller.abort()
this.connections.delete(connectionId)
}
}
/**
* 关闭所有连接
*/
closeAll() {
this.connections.forEach(conn => conn.controller?.abort())
this.connections.clear()
}
/**
* 更新默认请求头
*/
setDefaultHeaders(headers) {
this.DEFAULT_HEADERS = { ...this.DEFAULT_HEADERS, ...headers }
}
// -------------------- 工具方法 --------------------
_generateConnectionId() {
return `${this.DEFAULT_ID_PREFIX}${Date.now()}_${Math.random()
.toString(36)
.slice(2, 7)}`
}
_buildUrl(baseUrl, params = {}, method) {
const url = new URL(baseUrl, window.location.origin)
if (method === 'GET' && params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) url.searchParams.set(key, value)
})
}
return url.toString()
}
_createController(connectionId) {
const controller = new AbortController()
const conn = this.connections.get(connectionId)
if (conn) conn.controller = controller
return controller.signal
}
}
export const sse = new SSEService()
sse响应数据格式
{"content":"<think>","reasoningContent":null,"created":1754036279,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"8b593756-2aeb-416e-9839-645abb8dfcef"}
{"content":"我是","reasoningContent":null,"created":1754036279,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"8b593756-2aeb-416e-9839-645abb8dfcef"}
{"content":"Deep","reasoningContent":null,"created":1754036279,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"8b593756-2aeb-416e-9839-645abb8dfcef"}
{"content":"</think>","reasoningContent":null,"created":1754036930,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"0548d17e-72b5-4219-857d-054155d59096"}
{"content":"我可以","reasoningContent":null,"created":1754036930,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"0548d17e-72b5-4219-857d-054155d59096"}
{"content":"理解","reasoningContent":null,"created":1754036930,"finishReason":null,"modelName":"DS-1.5B","deployId":null,"id":"0548d17e-72b5-4219-857d-054155d59096"}
{"content":"","reasoningContent":null,"created":1754036930,"finishReason":"stop","modelName":"DS-1.5B","deployId":null,"id":"0548d17e-72b5-4219-857d-054155d59096"}
ModelParamConfig组件属于参数配置表单,可根据实际需求开发