vue2实现类似chatgpt和deepseek的AI对话流打字机效果,实现多模型同时对话
实现多模型同时对话
功能特点:
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-optionv-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-rowv-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'"><divv-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 = messageconst name = this.modelTypeList.find(item => item.id === this.modelType)?.nameif (!name) returnlet 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.dataif (code == 1) {this.chatId = objconst 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 = 0this.connectSSE(obj)}},// 启动连接connectSSE(chatId) {this.inputMessage = ''let buffer = ''let displayBuffer = ''this.startTime = nullthis.endTime = nullthis.thinkTime = nulllet len = this.contentList.lengthlet index = len % 2 === 0 ? len - 1 : lenlet historylen = this.historyList.lengthlet historyIndex = historylen % 2 === 0 ? historylen - 1 : historylenthis.isTalking = truelet 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 = contentbuffer += answerContanwerContent += answerContthis.$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.startTimebuffer = 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 += addCharslet 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 = falsethis.$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.dataif (code == 1) {this.handleCleanOptionAndData()}},handleCleanOptionAndData() {this.disconnectSSE()this.isTalking = falsesetTimeout(() => {//清除强制停止的对话记录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) returnconst threshold = 100const 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-buttontype="text"icon="iconfont icon-qingchu":disabled="hasAnsweringStatus"@click="handelAllHistoryAnswer"></vxe-button><vxe-buttontype="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-buttonv-if="!hasAnsweringStatus"type="text"@click="sendMessage":disabled="sendBtnDisabled"icon="vxe-icon-send-fill"style="font-size: 24px"></vxe-button><!-- 停止回答 --><vxe-buttonv-elsetype="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.dataif (code == 1) {this.modelTypeList = rowsif (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 iconst hasShowModels = this.chatBoxs.filter(res => res.show)const hasShowLength = hasShowModels.lengthif (hasShowLength === 3) return iif (hasShowLength === 2) return 1},// enter键盘按下的换行赋值为空adjustInputHeight(event) {if (event.key === 'Enter' && !event.shiftKey) {this.inputMessage = ''event.preventDefault()return}this.$nextTick(() => {const textarea = this.$refs.inputtextarea.style.height = 'auto'// 最高200pxtextarea.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) returnconst 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()) returnif (!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 = trueref.sendMessage({message: this.inputMessage,modelIndex: item.id})}})this.inputMessage = ''})},/* 删除当前对话模型 */handleDeleteCurModel(index) {this.chatBoxs[index - 1].show = falseif (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工具获取tokenclass 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 = 0handlers.onOpen?.(connectionId)} else {throw new Error(`SSE连接失败: ${response.status}`)}},onmessage: msg => {try {const data = msg.data ? JSON.parse(msg.data) : nullhandlers.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 = controllerreturn 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组件属于参数配置表单,可根据实际需求开发