zj-plugin-intelligent 1.2.5-beta.29 → 1.2.5-beta.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -951,7 +951,7 @@ eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n
|
|
|
951
951
|
/***/ (function(module, exports, __webpack_require__) {
|
|
952
952
|
|
|
953
953
|
"use strict";
|
|
954
|
-
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.detached.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.detached.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer-to-fixed-length.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer-to-fixed-length.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.find.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.find.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.for-each.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.for-each.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.at.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.at.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last-index.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last-index.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.set.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.set.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-reversed.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-reversed.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-sorted.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-sorted.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.with.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.with.js\");\nvar _markdownIt = _interopRequireDefault(__webpack_require__(/*! markdown-it */ \"./node_modules/_markdown-it@14.1.0@markdown-it/index.mjs\"));\nvar _apichat = __webpack_require__(/*! @lib/api/apichat */ \"./src/lib/api/apichat.js\");\nvar _index = __webpack_require__(/*! @lib/socket/fileManager/obs/index */ \"./src/lib/socket/fileManager/obs/index.js\");\nvar _index2 = _interopRequireDefault(__webpack_require__(/*! @lib/socket/utils/index */ \"./src/lib/socket/utils/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _apiObs = _interopRequireDefault(__webpack_require__(/*! @lib/api/apiObs */ \"./src/lib/api/apiObs.js\"));\nvar _html2canvas = _interopRequireDefault(__webpack_require__(/*! html2canvas */ \"./node_modules/_html2canvas@1.4.1@html2canvas/dist/html2canvas.js\"));\nconst md = new _markdownIt.default({\n breaks: true,\n html: false,\n linkify: true,\n typographer: true\n});\nmd.enable('table');\nvar _default = exports.default = {\n name: 'ChatDetail',\n props: {\n isMobile: {\n type: Boolean,\n default: false\n },\n activeSessionId: {\n type: String,\n default: ''\n },\n currentAgent: {\n type: String,\n default: ''\n },\n agentInfo: {\n type: Object,\n default: () => ({})\n },\n currentAgentDesc: {\n type: String,\n default: ''\n },\n showSidebar: {\n type: Boolean,\n default: false\n },\n showH5List: {\n type: Boolean,\n default: false\n },\n showAgentSelect: {\n type: Boolean,\n default: false\n },\n agentList: {\n type: Array,\n default: () => []\n }\n },\n data() {\n return {\n messages: [],\n inputText: '',\n sending: false,\n waitTimer: null,\n waitSeconds: 0,\n canceling: false,\n msgPage: 1,\n msgMaxPage: 1,\n msgTotal: 0,\n eventSource: null,\n showToolInfo: false,\n showDownBtn: false,\n lastScrollTop: 0,\n scrollTimer: null,\n contentCache: {},\n loadingSessionMsg: false,\n greeting: '',\n _scrollTimer: null,\n _userScrolledUp: false,\n voiceMode: false,\n isRecording: false,\n recordDuration: 0,\n recordingTimer: null,\n audioContext: null,\n audioStream: null,\n mediaRecorder: null,\n recordedChunks: [],\n audioSource: null,\n isCancelSwipe: false,\n recordStartY: 0,\n _pendingSendText: null\n };\n },\n computed: {\n waitClass() {\n if (this.waitSeconds > 15) return 'wait-15';\n if (this.waitSeconds > 10) return 'wait-10';\n if (this.waitSeconds > 5) return 'wait-5';\n return '';\n },\n currentAgentName() {\n if (!this.currentAgent) return '';\n const a = this.agentList.find(x => x.agentCode === this.currentAgent);\n return a ? a.agentName || a.agentCode : this.currentAgent;\n }\n },\n watch: {\n contentCache: {\n handler(val) {\n // 保持响应性\n },\n immediate: true\n },\n activeSessionId(val) {\n if (val && this._pendingSendText) {\n const text = this._pendingSendText;\n this._pendingSendText = null;\n this.$nextTick(() => this._sendMessage(text));\n }\n }\n },\n mounted() {\n if (window.visualViewport) {\n window.visualViewport.addEventListener('resize', this.onViewportResize, {\n passive: true\n });\n }\n },\n beforeDestroy() {\n if (window.visualViewport) {\n window.visualViewport.removeEventListener('resize', this.onViewportResize);\n }\n this.releaseAudio();\n this.contentCache = {};\n clearTimeout(this._scrollTimer);\n if (this.eventSource) {\n this.eventSource.onerror = null;\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n if (this._typeTimer) {\n clearInterval(this._typeTimer);\n this._typeTimer = null;\n }\n },\n methods: {\n getInitial(name) {\n if (!name) return '?';\n return name.charAt(0);\n },\n formatTime(t) {\n if (!t) return '';\n const d = new Date(t.replace(' ', 'T'));\n const now = new Date();\n const diff = (now - d) / 1000;\n if (diff < 60) return '刚刚';\n if (diff < 3600) return Math.floor(diff / 60) + '分钟前';\n if (diff < 86400) return Math.floor(diff / 3600) + '小时前';\n return d.getMonth() + 1 + '/' + d.getDate();\n },\n escapeHtml(text) {\n if (!text) return '';\n return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\n },\n renderMarkdown(text) {\n if (!text) return '';\n try {\n return md.render(text);\n } catch (err) {\n return this.escapeHtml(text);\n }\n },\n renderStreaming(text, msgKey) {\n if (!text) return '';\n const cache = this.contentCache[msgKey];\n const lines = text.split('\\n');\n const lastLineIdx = lines.length - 1;\n if (!cache) {\n try {\n const html = md.render(text);\n this.$set(this.contentCache, msgKey, {\n renderedLines: lastLineIdx,\n html,\n fullText: text\n });\n return html;\n } catch {\n return this.escapeHtml(text);\n }\n }\n if (text === cache.fullText) return cache.html;\n cache.fullText = text;\n\n // 行数没变,仅末尾追加字符,直接拼接尾行\n if (lastLineIdx === cache.renderedLines) {\n return cache.html + this.escapeHtml(lines[lastLineIdx]);\n }\n\n // 场景1:行数变少/文本回退截断,全量重渲染\n if (lastLineIdx < cache.renderedLines) {\n try {\n const fullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (fullLines.length > 0) {\n newBaseHtml = md.render(fullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n return cache.html + (lastLine || '');\n } catch {\n return this.escapeHtml(text);\n }\n }\n\n // 场景2:新增完整行\n if (cache.renderedLines < lastLineIdx) {\n const allFullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (allFullLines.length > 0) {\n newBaseHtml = md.render(allFullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n }\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n cache.renderedLines = lastLineIdx;\n return cache.html + (lastLine || '');\n },\n getChatAvatarStyle(agent, avatar) {\n if (avatar) {\n return {\n backgroundImage: `url(${avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n const _agent = this.agentList.find(x => x.agentCode === agent);\n if (_agent && _agent.avatar) {\n return {\n backgroundImage: `url(${_agent.avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n // 没有网络图片时,根据智能体在 agentList 中的索引位置决定颜色\n const index = this.agentList.findIndex(item => item.agentCode === agent);\n if (index !== -1) {\n const colorIndex = index % 4 + 1;\n return {\n background: this.getGradientByColorIndex(colorIndex)\n };\n }\n return {\n background: 'linear-gradient(135deg, #1677ff, #4096ff)'\n };\n },\n getGradientByColorIndex(index) {\n const gradients = {\n 1: 'linear-gradient(135deg, #1677ff, #4096ff)',\n 2: 'linear-gradient(135deg, #52c41a, #73d13d)',\n 3: 'linear-gradient(135deg, #fa8c16, #ffa940)',\n 4: 'linear-gradient(135deg, #722ed1, #b37feb)'\n };\n return gradients[index] || gradients[1];\n },\n sendQuickMsg(message) {\n if (message) {\n this.inputText = message;\n this.sendMessage();\n }\n },\n formatMixText(str) {\n if (!str) return '';\n let res = str.replace(/([a-zA-Z0-9])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z0-9])/g, '$1 $2');\n res = res.replace(/([a-z])([A-Z])/g, '$1 $2');\n return res;\n },\n // 检测是否滚动到底部,控制向下箭头显示隐藏\n checkScrollBottom(forceShowWhenNotBottom = false) {\n const el = this.$refs.messagesRef;\n if (!el) {\n this.showDownBtn = false;\n return;\n }\n const {\n scrollHeight,\n scrollTop,\n clientHeight\n } = el;\n const remain = scrollHeight - scrollTop - clientHeight;\n const atBottom = remain <= 80;\n\n // 检测用户是否主动向上滚动(远离底部)\n if (remain > 80 && scrollTop < this.lastScrollTop) {\n this._userScrolledUp = true;\n }\n if (atBottom) {\n this._userScrolledUp = false;\n this.showDownBtn = false;\n } else if (this._userScrolledUp) {\n // 用户已主动上滚,保持显示\"到底部\"按钮,不自动回滚\n this.showDownBtn = true;\n } else if (this.sending && scrollTop >= this.lastScrollTop && !forceShowWhenNotBottom) {\n this.showDownBtn = false;\n } else {\n this.showDownBtn = true;\n }\n this.lastScrollTop = scrollTop;\n this.$emit('scroll-bottom-check', {\n showDownBtn: this.showDownBtn,\n lastScrollTop: this.lastScrollTop\n });\n },\n // H5 输入框获取焦点 - 滚动到底部让键盘不遮挡\n onInputFocus() {\n if (!this.isMobile) return;\n setTimeout(() => {\n window.scrollTo(0, document.body.scrollHeight);\n }, 300);\n },\n // visualViewport 变化时(如键盘弹出)调整滚动位置\n onViewportResize() {\n if (!this.isMobile) return;\n const activeEl = document.activeElement;\n if (activeEl && (activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'INPUT')) {\n window.scrollTo(0, document.body.scrollHeight);\n }\n },\n autoResizeTextarea() {\n const ta = this.$refs.taRef;\n if (!ta) return;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n const maxH = isMobile ? 100 : 180;\n ta.style.height = `${baseH}px`;\n const realH = ta.scrollHeight;\n if (realH > baseH) {\n ta.style.height = `${Math.min(realH, maxH)}px`;\n }\n this.$nextTick(() => {\n this.checkScrollBottom(true);\n });\n },\n // Ctrl+Enter换行\n newLine(e) {\n e.preventDefault();\n const ta = this.$refs.taRef;\n const start = ta.selectionStart;\n const end = ta.selectionEnd;\n this.inputText = this.inputText.slice(0, start) + '\\n' + this.inputText.slice(end);\n this.$nextTick(() => {\n ta.selectionStart = ta.selectionEnd = start + 1;\n this.autoResizeTextarea();\n });\n },\n toggleVoiceMode() {\n if (this.voiceMode) {\n this.voiceMode = false;\n this.releaseAudio();\n return;\n }\n // 进入语音模式前提前请求麦克风权限,避免触摸时异步弹窗导致 touchend 丢失\n this.ensureAudioReady().then(ready => {\n if (ready) {\n this.voiceMode = true;\n } else {\n this.voiceMode = false;\n }\n });\n },\n // 确保音频设备已就绪\n async ensureAudioReady() {\n if (this.mediaRecorder && this.audioStream) return true;\n if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {\n alert('当前环境不支持麦克风访问');\n return false;\n }\n if (!window.MediaRecorder) {\n alert('当前浏览器不支持录音功能');\n return false;\n }\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: true\n });\n this.audioStream = stream;\n\n // 检测平台支持的 MIME 类型\n const mimeType = this._getSupportedMimeType();\n const options = mimeType ? {\n mimeType\n } : {};\n const recorder = new MediaRecorder(stream, options);\n recorder.ondataavailable = e => {\n if (e.data && e.data.size > 0) {\n this.recordedChunks.push(e.data);\n }\n };\n recorder.onstop = () => {\n this._onMediaRecorderStop();\n };\n recorder.onerror = () => {\n console.error('MediaRecorder error');\n this.recordedChunks = [];\n };\n this.mediaRecorder = recorder;\n this.audioContext = new (window.AudioContext || window.webkitAudioContext)();\n if (this.audioContext.state === 'suspended') {\n await this.audioContext.resume();\n }\n return true;\n } catch (err) {\n console.error('麦克风访问失败:', err);\n alert('麦克风权限被拒绝');\n return false;\n }\n },\n // 检测支持的 MIME 类型\n _getSupportedMimeType() {\n const types = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/ogg;codecs=opus'];\n for (const t of types) {\n if (MediaRecorder.isTypeSupported(t)) return t;\n }\n return '';\n },\n // MediaRecorder 停止后的回调:解码压缩音频 → 编码 WAV → 发送\n _onMediaRecorderStop() {\n if (this.recordedChunks.length === 0) {\n console.warn('录音数据为空');\n return;\n }\n const blob = new Blob(this.recordedChunks, {\n type: this.mediaRecorder.mimeType || 'audio/webm'\n });\n this.recordedChunks = [];\n\n // 解码压缩音频为 PCM\n const reader = new FileReader();\n reader.onload = () => {\n const arrayBuffer = reader.result;\n this.audioContext.decodeAudioData(arrayBuffer, audioBuffer => {\n // 取第一个声道的数据\n const channelData = audioBuffer.getChannelData(0);\n const wavBlob = this._encodePCMToWavFromBuffer(channelData, audioBuffer.sampleRate);\n this.sendAudioBlob(wavBlob);\n }, err => {\n console.error('decodeAudioData 失败:', err);\n });\n };\n reader.onerror = () => {\n console.error('FileReader 读取失败');\n };\n reader.readAsArrayBuffer(blob);\n },\n // 从 Float32Array 编码 WAV\n _encodePCMToWavFromBuffer(channelData, sampleRate) {\n const numChannels = 1;\n const bitsPerSample = 16;\n const byteRate = sampleRate * numChannels * bitsPerSample / 8;\n const blockAlign = numChannels * bitsPerSample / 8;\n const dataSize = channelData.length * (bitsPerSample / 8);\n const headerSize = 44;\n const totalSize = headerSize + dataSize;\n const buffer = new ArrayBuffer(totalSize);\n const view = new DataView(buffer);\n this._wavWriteString(view, 0, 'RIFF');\n view.setUint32(4, totalSize - 8, true);\n this._wavWriteString(view, 8, 'WAVE');\n this._wavWriteString(view, 12, 'fmt ');\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true); // PCM\n view.setUint16(22, numChannels, true);\n view.setUint32(24, sampleRate, true);\n view.setUint32(28, byteRate, true);\n view.setUint16(32, blockAlign, true);\n view.setUint16(34, bitsPerSample, true);\n this._wavWriteString(view, 36, 'data');\n view.setUint32(40, dataSize, true);\n const pcmView = new Int16Array(buffer, headerSize, channelData.length);\n for (let i = 0; i < channelData.length; i++) {\n const s = Math.max(-1, Math.min(1, channelData[i]));\n pcmView[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;\n }\n return new Blob([buffer], {\n type: 'audio/wav'\n });\n },\n // H5语音录制 - 触摸开始\n async onRecordTouchStart(e) {\n if (this.sending) return;\n const ready = await this.ensureAudioReady();\n if (!ready) return;\n this.isCancelSwipe = false;\n this.recordStartY = e.touches[0].clientY;\n this.recordDuration = 0;\n this.recordedChunks = [];\n this.isRecording = true;\n this.mediaRecorder.start();\n this.recordingTimer = setInterval(() => {\n this.recordDuration++;\n if (this.recordDuration >= 60) {\n this.onRecordTouchEnd();\n }\n }, 1000);\n },\n // H5语音录制 - 触摸移动\n onRecordTouchMove(e) {\n if (!this.isRecording) return;\n const touch = e.touches[0];\n const moveY = touch.clientY;\n const cancelThreshold = 60;\n this.isCancelSwipe = this.recordStartY - moveY > cancelThreshold;\n },\n // H5语音录制 - 触摸结束\n onRecordTouchEnd() {\n if (!this.isRecording) return;\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n // 上滑取消:丢弃录音数据\n if (this.isCancelSwipe) {\n this.recordedChunks = [];\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n return;\n }\n // 正常发送:停止 MediaRecorder → 触发 onstop → _onMediaRecorderStop → 解码 → 发送\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n },\n // 取消录音\n cancelRecording() {\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n this.recordedChunks = [];\n this.recordDuration = 0;\n this.isCancelSwipe = false;\n },\n // 释放音频资源\n releaseAudio() {\n this.cancelRecording();\n if (this.mediaRecorder) {\n if (this.mediaRecorder.state !== 'inactive') {\n try {\n this.mediaRecorder.stop();\n } catch (e) {}\n }\n this.mediaRecorder = null;\n }\n if (this.audioSource) {\n this.audioSource.disconnect();\n this.audioSource = null;\n }\n if (this.audioContext) {\n this.audioContext.close().catch(() => {});\n this.audioContext = null;\n }\n if (this.audioStream) {\n this.audioStream.getTracks().forEach(t => t.stop());\n this.audioStream = null;\n }\n this.recordedChunks = [];\n this.voiceMode = false;\n },\n _wavWriteString(view, offset, str) {\n for (let i = 0; i < str.length; i++) {\n view.setUint8(offset + i, str.charCodeAt(i));\n }\n },\n // 发送音频\n sendAudioBlob(blob) {\n let appSeqId = _index2.default.getAppSeqId();\n let name = appSeqId + '.wav';\n const file = new File([blob], name, {\n type: 'audio/wav'\n });\n this.sendAudioMessage(file);\n },\n sendAudioMessage(file) {\n const sttKey = 'stt_' + Date.now();\n const sttMsg = {\n role: 'user',\n content: '转化中...',\n _sttStatus: 'pending',\n _sttKey: sttKey,\n createTime: new Date().toISOString(),\n _key: sttKey\n };\n this.messages.push(sttMsg);\n this.scrollToBottom(true);\n (0, _index.fileUploadToObs)({\n _file: file,\n appSeqId: file.name\n }).then(resp => {\n if (resp.CommonMsg.Status === 200) {\n return _apiObs.default.aliAudioToText({\n url: resp.obs_upload_data.obsUrl\n }).then(result => {\n if (result.code === '200' && result.data) {\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.messages.splice(idx, 1);\n }\n this.sendQuickMsg(result.data);\n } else {\n throw new Error(result.msg || '语音转文字失败');\n }\n });\n } else {\n throw new Error(resp.CommonMsg.Message || '文件上传失败');\n }\n }).catch(err => {\n console.error('语音转文字失败:', err);\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.$set(this.messages, idx, {\n ...this.messages[idx],\n _sttStatus: 'error',\n content: '语音转文字失败'\n });\n }\n this.$message.error('语音转文字失败');\n });\n this.$message.success(`录音完成 (${this.recordDuration}s)`);\n },\n clearWaitTimer() {\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n },\n loadMoreMessages() {\n const nextPage = this.msgPage + 1;\n const maxPage = Math.ceil(this.msgTotal / 10);\n if (nextPage > maxPage) {\n this.loadingMore = false;\n return;\n }\n (0, _apichat.getSessionMessages)(this.activeSessionId, nextPage, 10).then(res => {\n const list = Array.isArray(res) ? res : res.list || [];\n this.msgPage = nextPage;\n const asAsc = (list || []).slice().reverse();\n const prepend = asAsc.map(m => ({\n role: m.role,\n content: m.content || '',\n thinking: m.thinking || '',\n toolCalls: [],\n agentName: '',\n createTime: m.createTime || null,\n _key: m.createTime ? 'msg_' + m.createTime : 'msg_' + Date.now() + '_' + Math.random()\n }));\n const container = this.$refs.messagesRef;\n const oldScrollHeight = container ? container.scrollHeight : 0;\n const oldScrollTop = container ? container.scrollTop : 0;\n this.messages = [...prepend, ...this.messages];\n this.$nextTick(() => {\n if (container) {\n container.scrollTop = oldScrollTop + (container.scrollHeight - oldScrollHeight);\n }\n this.loadingMore = false;\n });\n }).catch(() => {\n this.loadingMore = false;\n });\n },\n onScrollMessages() {\n const container = this.$refs.messagesRef;\n if (!container || this.loadingMore) return;\n const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 100;\n this.checkScrollBottom();\n if (isAtBottom) return;\n if (container.scrollTop < 50 && this.msgPage < Math.ceil(this.msgTotal / 10)) {\n this.loadingMore = true;\n this.loadMoreMessages();\n }\n },\n async sendMessage() {\n const text = this.inputText.trim();\n if (!text || this.sending) return;\n if (text.length > 300) {\n this.$message({\n message: '消息内容不能超过300字',\n type: 'warning',\n duration: 2000\n });\n return;\n }\n if (!this.activeSessionId) {\n this.$emit('start-session');\n this._pendingSendText = text;\n return;\n }\n this._sendMessage(text);\n },\n async _sendMessage(text) {\n this.inputText = '';\n const ta = this.$refs.taRef;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n if (ta) {\n ta.style.height = baseH + 'px';\n }\n this.showDownBtn = false;\n this.messages.push({\n role: 'user',\n content: text,\n createTime: new Date().toISOString(),\n _key: 'user_' + Date.now() + '_' + Math.random()\n });\n const agentName = this.currentAgentName;\n const msgIdx = this.messages.push({\n role: \"assistant\",\n content: \"\",\n thinking: \"\",\n loading: true,\n toolCalls: [],\n agentName,\n createTime: new Date().toISOString(),\n _key: 'asst_' + Date.now() + '_' + Math.random()\n }) - 1;\n this.scrollToBottom(true);\n this.sending = true;\n this.waitSeconds = 0;\n if (this.waitTimer) clearInterval(this.waitTimer);\n this.waitTimer = setInterval(() => {\n this.waitSeconds++;\n }, 1000);\n let thinkingBuffer = '';\n let finalContent = '';\n let answerBuffer = '';\n let thinkDisplayed = 0;\n let typeTimer = null;\n let streamDone = false;\n typeTimer = setInterval(() => {\n if (!this.messages[msgIdx]) return;\n\n // Phase 1: 逐字展示思考内容(不 return,让 Phase 2 也能同时执行)\n if (thinkDisplayed < thinkingBuffer.length) {\n thinkDisplayed += 50;\n if (thinkDisplayed > thinkingBuffer.length) thinkDisplayed = thinkingBuffer.length;\n this.messages[msgIdx].thinking = thinkingBuffer.substring(0, thinkDisplayed);\n this.scrollToBottom();\n // 不 return,让 Phase 2 也能执行,避免 iOS 上思考内容过长阻塞回答内容展示\n }\n\n // Phase 2: 逐行展示(已完成的行整行出现,未完成的行也即时显示)\n const target = finalContent || answerBuffer;\n const msg = this.messages[msgIdx];\n const revealedLen = msg._revealedLen || 0;\n if (target.length > revealedLen) {\n const remaining = target.substring(revealedLen);\n const nextNewline = remaining.indexOf('\\n');\n if (nextNewline !== -1) {\n // 新的一行已完成 → 整行出现\n const endPos = revealedLen + nextNewline + 1;\n msg._revealedLen = endPos;\n msg.content = target.substring(0, endPos);\n this.scrollToBottom();\n return;\n }\n\n // 当前行未完成 → 逐步展示(每次 30 字符),确保 iOS 上也有渐进效果\n const revealStep = 30;\n const newRevealedLen = Math.min(revealedLen + revealStep, target.length);\n msg._revealedLen = newRevealedLen;\n msg.content = target.substring(0, newRevealedLen);\n this.scrollToBottom();\n return;\n }\n\n // Phase 3: 流已结束且内容已全部展示 → 收尾\n if (streamDone) {\n clearInterval(typeTimer);\n const key = this.messages[msgIdx]._key;\n if (key) {\n this.$delete(this.contentCache, key);\n this.$delete(this.contentCache, key + '_t');\n }\n typeTimer = null;\n this.messages[msgIdx].content = finalContent;\n this.messages[msgIdx].thinking = thinkingBuffer;\n this.messages[msgIdx].loading = false;\n this.sending = false;\n this.clearWaitTimer();\n this.scrollToBottom();\n this.$emit('session-refresh');\n this.$emit('message-sent');\n }\n }, 30);\n const url = (0, _apichat.createStreamUrl)(this.activeSessionId, text);\n if (this.eventSource) {\n this.eventSource.close();\n }\n this.eventSource = new EventSource(url);\n const es = this.eventSource;\n es.addEventListener('reasoning', e => {\n if (e.data) {\n const chunk = e.data;\n let cleanChunk = chunk.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2').replace(/\\s+([:,.!?])/g, '$1').replace(/([:,.!?])\\s+/g, '$1 ');\n cleanChunk = cleanChunk.replace(/\\s+/g, ' ');\n const lastChar = thinkingBuffer.slice(-1);\n const firstChar = cleanChunk.slice(0, 1);\n const isLetter = c => /[a-zA-Z]/.test(c);\n if (isLetter(lastChar) && isLetter(firstChar)) {\n thinkingBuffer += ' ' + cleanChunk;\n } else {\n thinkingBuffer += cleanChunk;\n }\n }\n });\n es.addEventListener('reasoning_result', e => {\n if (e.data) {\n thinkingBuffer = e.data;\n }\n });\n es.addEventListener('answer', e => {\n if (e.data) {\n answerBuffer += e.data.replace(/([a-zA-Z]+)([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z]+)/g, '$1 $2').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2');\n }\n });\n es.addEventListener('tool_result', e => {\n if (e.data && this.messages[msgIdx]) {\n if (!this.messages[msgIdx].toolCalls) this.messages[msgIdx].toolCalls = [];\n this.messages[msgIdx].toolCalls.push(e.data);\n this.scrollToBottom();\n }\n });\n es.addEventListener('agent_result', e => {\n finalContent = e.data || '';\n streamDone = true;\n es.close();\n this.eventSource = null;\n });\n es.onerror = err => {\n console.log('[SSE] onerror', err);\n if (thinkingBuffer.length > 0 && !streamDone) finalContent = thinkingBuffer;\n if (!streamDone) finalContent = '❌ 连接中断';\n streamDone = true;\n es.close();\n this.eventSource = null;\n };\n },\n stopAgent() {\n if (this.eventSource) {\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.activeSessionId) {\n (0, _apichat.stopStream)(this.activeSessionId).catch(() => {});\n }\n if (this.messages.length > 0) {\n const last = this.messages[this.messages.length - 1];\n if (last && last.loading) {\n last.content = last.content || last.thinking || '⏹ 已停止';\n last.loading = false;\n this.$delete(this.contentCache, last._key);\n }\n }\n this.sending = false;\n this.clearWaitTimer();\n },\n scrollToTop() {\n const el = this.$refs.messagesRef;\n if (el) el.scrollTop = 0;\n },\n scrollToBottom(force = false) {\n if (this.showDownBtn && !force) return;\n this.$nextTick(() => {\n const el = this.$refs.messagesRef;\n if (el) {\n el.scrollTop = el.scrollHeight;\n this.lastScrollTop = el.scrollTop;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n }\n });\n },\n scrollToBottomOnce() {\n clearTimeout(this._scrollTimer);\n this.$nextTick(() => {\n const tryScroll = () => {\n const el = this.$refs.messagesRef;\n if (!el) {\n this._scrollTimer = setTimeout(tryScroll, 50);\n return;\n }\n const originalScrollBehavior = el.style.scrollBehavior;\n el.style.scrollBehavior = 'auto';\n el.scrollTop = el.scrollHeight - el.clientHeight;\n el.style.scrollBehavior = originalScrollBehavior;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n };\n tryScroll();\n });\n },\n copyText(content) {\n if (!content) return;\n const text = content.replace(/<[^>]+>/g, '');\n\n // 优先使用 Clipboard API(HTTPS 环境)\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(() => {\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n this._fallbackCopyText(text);\n });\n } else {\n this._fallbackCopyText(text);\n }\n },\n _fallbackCopyText(text) {\n try {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';\n document.body.appendChild(ta);\n ta.focus();\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n } catch {\n this.$message({\n message: '复制失败',\n type: 'error',\n duration: 2000\n });\n }\n },\n copyAsImage(content) {\n if (!content) return;\n const div = document.createElement('div');\n div.className = 'copy-image-render';\n div.style.cssText = 'position:fixed;left:-9999px;top:0;width:600px;padding:20px;background:#fff;font-size:14px;line-height:1.6;color:#333;border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;';\n // 内联表格样式(因 Vue 作用域样式不覆盖复制用的脱离文档流 div)\n div.innerHTML = this.renderMarkdown(content) + '<style>table{border-collapse:collapse;width:100%;margin:6px 0;font-size:14px}' + 'th,td{border:1px solid #e8e8e8;padding:5px 8px;text-align:left}' + 'th{background:#fafafa;font-weight:600}</style>';\n document.body.appendChild(div);\n this.$message({\n message: '正在生成图片...',\n type: 'info',\n duration: 2000\n });\n (0, _html2canvas.default)(div, {\n scale: 2,\n useCORS: true,\n backgroundColor: '#ffffff'\n }).then(canvas => {\n // 检测是否支持 ClipboardItem(部分 WebView 不支持)\n const canCopyImage = typeof ClipboardItem !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.write === 'function';\n if (canCopyImage) {\n canvas.toBlob(blob => {\n if (blob) {\n navigator.clipboard.write([new ClipboardItem({\n 'image/png': blob\n })]).then(() => {\n this.$message({\n message: '图片已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n // ClipboardItem 存在但写入失败 → 降级下载\n this._downloadCanvas(canvas);\n });\n }\n }, 'image/png');\n } else {\n // 不支持剪贴板写图片 → 直接下载\n this._downloadCanvas(canvas);\n }\n }).catch(() => {\n this.$message({\n message: '生成图片失败',\n type: 'error',\n duration: 2000\n });\n }).finally(() => {\n document.body.removeChild(div);\n });\n },\n _downloadCanvas(canvas) {\n const link = document.createElement('a');\n link.download = '消息图片.png';\n link.href = canvas.toDataURL('image/png');\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n this.$message({\n message: '已保存图片到本地',\n type: 'success',\n duration: 2000\n });\n }\n }\n};\n\n//# sourceURL=webpack://ZjPluginIntelligent/./src/lib/components/chatMain/ChatDetail.vue?./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--13-0!./node_modules/_babel-loader@8.4.1@babel-loader/lib!./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--1-0!./node_modules/_vue-loader@15.11.1@vue-loader/lib??vue-loader-options");
|
|
954
|
+
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.detached.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.detached.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer-to-fixed-length.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer-to-fixed-length.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.find.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.find.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.for-each.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.for-each.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.at.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.at.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last-index.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last-index.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.set.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.set.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-reversed.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-reversed.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-sorted.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-sorted.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.with.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.with.js\");\nvar _markdownIt = _interopRequireDefault(__webpack_require__(/*! markdown-it */ \"./node_modules/_markdown-it@14.1.0@markdown-it/index.mjs\"));\nvar _apichat = __webpack_require__(/*! @lib/api/apichat */ \"./src/lib/api/apichat.js\");\nvar _index = __webpack_require__(/*! @lib/socket/fileManager/obs/index */ \"./src/lib/socket/fileManager/obs/index.js\");\nvar _index2 = _interopRequireDefault(__webpack_require__(/*! @lib/socket/utils/index */ \"./src/lib/socket/utils/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _apiObs = _interopRequireDefault(__webpack_require__(/*! @lib/api/apiObs */ \"./src/lib/api/apiObs.js\"));\nvar _html2canvas = _interopRequireDefault(__webpack_require__(/*! html2canvas */ \"./node_modules/_html2canvas@1.4.1@html2canvas/dist/html2canvas.js\"));\nconst md = new _markdownIt.default({\n breaks: true,\n html: false,\n linkify: true,\n typographer: true\n});\nmd.enable('table');\nvar _default = exports.default = {\n name: 'ChatDetail',\n props: {\n isMobile: {\n type: Boolean,\n default: false\n },\n activeSessionId: {\n type: String,\n default: ''\n },\n currentAgent: {\n type: String,\n default: ''\n },\n agentInfo: {\n type: Object,\n default: () => ({})\n },\n currentAgentDesc: {\n type: String,\n default: ''\n },\n showSidebar: {\n type: Boolean,\n default: false\n },\n showH5List: {\n type: Boolean,\n default: false\n },\n showAgentSelect: {\n type: Boolean,\n default: false\n },\n agentList: {\n type: Array,\n default: () => []\n }\n },\n data() {\n return {\n messages: [],\n inputText: '',\n sending: false,\n waitTimer: null,\n waitSeconds: 0,\n canceling: false,\n msgPage: 1,\n msgMaxPage: 1,\n msgTotal: 0,\n eventSource: null,\n showToolInfo: false,\n showDownBtn: false,\n lastScrollTop: 0,\n scrollTimer: null,\n contentCache: {},\n loadingSessionMsg: false,\n greeting: '',\n _scrollTimer: null,\n _userScrolledUp: false,\n voiceMode: false,\n isRecording: false,\n recordDuration: 0,\n recordingTimer: null,\n audioContext: null,\n audioStream: null,\n audioProcessor: null,\n audioChunks: [],\n audioSampleRate: 16000,\n isCancelSwipe: false,\n recordStartY: 0,\n _pendingSendText: null\n };\n },\n computed: {\n waitClass() {\n if (this.waitSeconds > 15) return 'wait-15';\n if (this.waitSeconds > 10) return 'wait-10';\n if (this.waitSeconds > 5) return 'wait-5';\n return '';\n },\n currentAgentName() {\n if (!this.currentAgent) return '';\n const a = this.agentList.find(x => x.agentCode === this.currentAgent);\n return a ? a.agentName || a.agentCode : this.currentAgent;\n }\n },\n watch: {\n contentCache: {\n handler(val) {\n // 保持响应性\n },\n immediate: true\n },\n activeSessionId(val) {\n if (val && this._pendingSendText) {\n const text = this._pendingSendText;\n this._pendingSendText = null;\n this.$nextTick(() => this._sendMessage(text));\n }\n }\n },\n mounted() {\n if (window.visualViewport) {\n window.visualViewport.addEventListener('resize', this.onViewportResize, {\n passive: true\n });\n }\n },\n beforeDestroy() {\n if (window.visualViewport) {\n window.visualViewport.removeEventListener('resize', this.onViewportResize);\n }\n this.releaseAudio();\n this.contentCache = {};\n clearTimeout(this._scrollTimer);\n if (this.eventSource) {\n this.eventSource.onerror = null;\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n if (this._typeTimer) {\n clearInterval(this._typeTimer);\n this._typeTimer = null;\n }\n },\n methods: {\n getInitial(name) {\n if (!name) return '?';\n return name.charAt(0);\n },\n formatTime(t) {\n if (!t) return '';\n const d = new Date(t.replace(' ', 'T'));\n const now = new Date();\n const diff = (now - d) / 1000;\n if (diff < 60) return '刚刚';\n if (diff < 3600) return Math.floor(diff / 60) + '分钟前';\n if (diff < 86400) return Math.floor(diff / 3600) + '小时前';\n return d.getMonth() + 1 + '/' + d.getDate();\n },\n escapeHtml(text) {\n if (!text) return '';\n return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\n },\n renderMarkdown(text) {\n if (!text) return '';\n try {\n return md.render(text);\n } catch (err) {\n return this.escapeHtml(text);\n }\n },\n renderStreaming(text, msgKey) {\n if (!text) return '';\n const cache = this.contentCache[msgKey];\n const lines = text.split('\\n');\n const lastLineIdx = lines.length - 1;\n if (!cache) {\n try {\n const html = md.render(text);\n this.$set(this.contentCache, msgKey, {\n renderedLines: lastLineIdx,\n html,\n fullText: text\n });\n return html;\n } catch {\n return this.escapeHtml(text);\n }\n }\n if (text === cache.fullText) return cache.html;\n cache.fullText = text;\n\n // 行数没变,仅末尾追加字符,直接拼接尾行\n if (lastLineIdx === cache.renderedLines) {\n return cache.html + this.escapeHtml(lines[lastLineIdx]);\n }\n\n // 场景1:行数变少/文本回退截断,全量重渲染\n if (lastLineIdx < cache.renderedLines) {\n try {\n const fullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (fullLines.length > 0) {\n newBaseHtml = md.render(fullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n return cache.html + (lastLine || '');\n } catch {\n return this.escapeHtml(text);\n }\n }\n\n // 场景2:新增完整行\n if (cache.renderedLines < lastLineIdx) {\n const allFullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (allFullLines.length > 0) {\n newBaseHtml = md.render(allFullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n }\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n cache.renderedLines = lastLineIdx;\n return cache.html + (lastLine || '');\n },\n getChatAvatarStyle(agent, avatar) {\n if (avatar) {\n return {\n backgroundImage: `url(${avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n const _agent = this.agentList.find(x => x.agentCode === agent);\n if (_agent && _agent.avatar) {\n return {\n backgroundImage: `url(${_agent.avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n // 没有网络图片时,根据智能体在 agentList 中的索引位置决定颜色\n const index = this.agentList.findIndex(item => item.agentCode === agent);\n if (index !== -1) {\n const colorIndex = index % 4 + 1;\n return {\n background: this.getGradientByColorIndex(colorIndex)\n };\n }\n return {\n background: 'linear-gradient(135deg, #1677ff, #4096ff)'\n };\n },\n getGradientByColorIndex(index) {\n const gradients = {\n 1: 'linear-gradient(135deg, #1677ff, #4096ff)',\n 2: 'linear-gradient(135deg, #52c41a, #73d13d)',\n 3: 'linear-gradient(135deg, #fa8c16, #ffa940)',\n 4: 'linear-gradient(135deg, #722ed1, #b37feb)'\n };\n return gradients[index] || gradients[1];\n },\n sendQuickMsg(message) {\n if (message) {\n this.inputText = message;\n this.sendMessage();\n }\n },\n formatMixText(str) {\n if (!str) return '';\n let res = str.replace(/([a-zA-Z0-9])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z0-9])/g, '$1 $2');\n res = res.replace(/([a-z])([A-Z])/g, '$1 $2');\n return res;\n },\n // 检测是否滚动到底部,控制向下箭头显示隐藏\n checkScrollBottom(forceShowWhenNotBottom = false) {\n const el = this.$refs.messagesRef;\n if (!el) {\n this.showDownBtn = false;\n return;\n }\n const {\n scrollHeight,\n scrollTop,\n clientHeight\n } = el;\n const remain = scrollHeight - scrollTop - clientHeight;\n const atBottom = remain <= 80;\n\n // 检测用户是否主动向上滚动(远离底部)\n if (remain > 80 && scrollTop < this.lastScrollTop) {\n this._userScrolledUp = true;\n }\n if (atBottom) {\n this._userScrolledUp = false;\n this.showDownBtn = false;\n } else if (this._userScrolledUp) {\n // 用户已主动上滚,保持显示\"到底部\"按钮,不自动回滚\n this.showDownBtn = true;\n } else if (this.sending && scrollTop >= this.lastScrollTop && !forceShowWhenNotBottom) {\n this.showDownBtn = false;\n } else {\n this.showDownBtn = true;\n }\n this.lastScrollTop = scrollTop;\n this.$emit('scroll-bottom-check', {\n showDownBtn: this.showDownBtn,\n lastScrollTop: this.lastScrollTop\n });\n },\n // H5 输入框获取焦点 - 滚动到底部让键盘不遮挡\n onInputFocus() {\n if (!this.isMobile) return;\n setTimeout(() => {\n window.scrollTo(0, document.body.scrollHeight);\n }, 300);\n },\n // visualViewport 变化时(如键盘弹出)调整滚动位置\n onViewportResize() {\n if (!this.isMobile) return;\n const activeEl = document.activeElement;\n if (activeEl && (activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'INPUT')) {\n window.scrollTo(0, document.body.scrollHeight);\n }\n },\n autoResizeTextarea() {\n const ta = this.$refs.taRef;\n if (!ta) return;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n const maxH = isMobile ? 100 : 180;\n ta.style.height = `${baseH}px`;\n const realH = ta.scrollHeight;\n if (realH > baseH) {\n ta.style.height = `${Math.min(realH, maxH)}px`;\n }\n this.$nextTick(() => {\n this.checkScrollBottom(true);\n });\n },\n // Ctrl+Enter换行\n newLine(e) {\n e.preventDefault();\n const ta = this.$refs.taRef;\n const start = ta.selectionStart;\n const end = ta.selectionEnd;\n this.inputText = this.inputText.slice(0, start) + '\\n' + this.inputText.slice(end);\n this.$nextTick(() => {\n ta.selectionStart = ta.selectionEnd = start + 1;\n this.autoResizeTextarea();\n });\n },\n toggleVoiceMode() {\n if (this.voiceMode) {\n this.voiceMode = false;\n this.releaseAudio();\n return;\n }\n // 进入语音模式前提前请求麦克风权限,避免触摸时异步弹窗导致 touchend 丢失\n this.ensureAudioReady().then(ready => {\n if (ready) {\n this.voiceMode = true;\n } else {\n this.voiceMode = false;\n }\n });\n },\n // 确保音频设备已就绪\n async ensureAudioReady() {\n if (this.mediaRecorder && this.audioStream) return true;\n if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {\n alert('当前环境不支持麦克风访问');\n return false;\n }\n if (!window.MediaRecorder) {\n alert('当前浏览器不支持录音功能');\n return false;\n }\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: true\n });\n // ========== 新增:校验音频轨道是否有效(iOS兼容) ==========\n const audioTracks = stream.getAudioTracks();\n if (audioTracks.length === 0 || audioTracks[0].readyState !== 'live') {\n alert('麦克风未正常激活,请重新授权');\n stream.getTracks().forEach(t => t.stop());\n return false;\n }\n this.audioStream = stream;\n\n // 检测平台支持的 MIME 类型\n const mimeType = this._getSupportedMimeType();\n const options = mimeType ? {\n mimeType\n } : {};\n const recorder = new MediaRecorder(stream, options);\n recorder.ondataavailable = e => {\n if (e.data && e.data.size > 0) {\n this.recordedChunks.push(e.data);\n }\n };\n recorder.onstop = () => {\n this._onMediaRecorderStop();\n };\n recorder.onerror = () => {\n console.error('MediaRecorder error');\n this.recordedChunks = [];\n };\n this.mediaRecorder = recorder;\n\n // ========== 改动:AudioContext全局单例,不重复new ==========\n if (!this.audioContext) {\n this.audioContext = new (window.AudioContext || window.webkitAudioContext)();\n }\n if (this.audioContext.state === 'suspended') {\n await this.audioContext.resume();\n }\n return true;\n } catch (err) {\n console.error('麦克风访问失败:', err);\n alert('麦克风权限被拒绝');\n return false;\n }\n },\n _onMediaRecorderStop() {\n if (this.recordedChunks.length === 0) {\n console.warn('录音数据为空');\n return;\n }\n const blob = new Blob(this.recordedChunks, {\n type: this.mediaRecorder.mimeType || 'audio/mp4'\n });\n this.recordedChunks = [];\n\n // 解码压缩音频为 PCM\n const reader = new FileReader();\n reader.onload = () => {\n const arrayBuffer = reader.result;\n this.audioContext.decodeAudioData(arrayBuffer, audioBuffer => {\n // 取第一个声道的数据\n const channelData = audioBuffer.getChannelData(0);\n const wavBlob = this._encodePCMToWavFromBuffer(channelData, audioBuffer.sampleRate);\n this.sendAudioBlob(wavBlob);\n }, err => {\n // ========== 新增:iOS解码失败兜底,直接上传原始mp4 blob ==========\n console.error('decodeAudioData 失败,降级上传原始音频:', err);\n this.sendAudioBlob(blob);\n });\n };\n reader.onerror = () => {\n console.error('FileReader 读取失败,降级上传原始音频');\n this.sendAudioBlob(blob);\n };\n reader.readAsArrayBuffer(blob);\n },\n _encodePCMToWavFromBuffer(channelData, sampleRate) {\n const numChannels = 1;\n const bitsPerSample = 16;\n const byteRate = sampleRate * numChannels * bitsPerSample / 8;\n const blockAlign = numChannels * bitsPerSample / 8;\n const dataSize = channelData.length * (bitsPerSample / 8);\n const headerSize = 44;\n const totalSize = headerSize + dataSize;\n const buffer = new ArrayBuffer(totalSize);\n const view = new DataView(buffer);\n this._wavWriteString(view, 0, 'RIFF');\n view.setUint32(4, totalSize - 8, true);\n this._wavWriteString(view, 8, 'WAVE');\n this._wavWriteString(view, 12, 'fmt ');\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true); // PCM\n view.setUint16(22, numChannels, true);\n view.setUint32(24, sampleRate, true);\n view.setUint32(28, byteRate, true);\n view.setUint16(32, blockAlign, true);\n view.setUint16(34, bitsPerSample, true);\n this._wavWriteString(view, 36, 'data');\n view.setUint32(40, dataSize, true);\n const pcmView = new Int16Array(buffer, headerSize, channelData.length);\n for (let i = 0; i < channelData.length; i++) {\n const s = Math.max(-1, Math.min(1, channelData[i]));\n pcmView[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;\n }\n return new Blob([buffer], {\n type: 'audio/wav'\n });\n },\n // 检测支持的 MIME 类型(改动:优先mp4,兜底mp4防止空)\n _getSupportedMimeType() {\n const types = ['audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'];\n for (const t of types) {\n if (MediaRecorder.isTypeSupported(t)) return t;\n }\n // iOS兜底强制mp4\n return 'audio/mp4;codecs=mp4a.40.2';\n },\n // H5语音录制 - 触摸开始\n async onRecordTouchStart(e) {\n if (this.sending) return;\n const ready = await this.ensureAudioReady();\n if (!ready) return;\n this.isCancelSwipe = false;\n this.recordStartY = e.touches[0].clientY;\n this.recordDuration = 0;\n this.recordedChunks = [];\n this.isRecording = true;\n // ========== 核心修复:增加1000ms分片,iOS必须带timeslice ==========\n this.mediaRecorder.start(1000);\n this.recordingTimer = setInterval(() => {\n this.recordDuration++;\n if (this.recordDuration >= 60) {\n this.onRecordTouchEnd();\n }\n }, 1000);\n },\n // H5语音录制 - 触摸移动\n onRecordTouchMove(e) {\n if (!this.isRecording) return;\n const touch = e.touches[0];\n const moveY = touch.clientY;\n const cancelThreshold = 60;\n this.isCancelSwipe = this.recordStartY - moveY > cancelThreshold;\n },\n // H5语音录制 - 触摸结束\n // H5语音录制 - 触摸结束\n onRecordTouchEnd() {\n if (!this.isRecording) return;\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n // 上滑取消:丢弃录音数据\n if (this.isCancelSwipe) {\n this.recordedChunks = [];\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n return;\n }\n // 正常发送:停止 MediaRecorder → 触发 onstop → _onMediaRecorderStop → 解码 → 发送\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n },\n // 停止音频采集并输出 WAV\n stopAudioCapture(shouldSend) {\n this.isRecording = false;\n if (shouldSend && this.audioChunks.length > 0) {\n const wavBlob = this.encodePCMToWav(this.audioChunks, this.audioSampleRate || 16000);\n this.sendAudioBlob(wavBlob);\n }\n this.audioChunks = [];\n },\n // 取消录音\n cancelRecording() {\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n this.recordedChunks = [];\n this.recordDuration = 0;\n this.isCancelSwipe = false;\n },\n // 释放音频资源\n releaseAudio() {\n this.cancelRecording();\n if (this.mediaRecorder) {\n if (this.mediaRecorder.state !== 'inactive') {\n try {\n this.mediaRecorder.stop();\n } catch (e) {}\n }\n this.mediaRecorder = null;\n }\n if (this.audioSource) {\n this.audioSource.disconnect();\n this.audioSource = null;\n }\n // 只关闭上下文,不清空实例,复用单例\n if (this.audioContext) {\n this.audioContext.close().catch(() => {});\n }\n if (this.audioStream) {\n this.audioStream.getTracks().forEach(t => t.stop());\n this.audioStream = null;\n }\n this.recordedChunks = [];\n this.voiceMode = false;\n },\n // 将 Float32 PCM 数据编码为 WAV Blob\n encodePCMToWav(chunks, sampleRate) {\n let totalLength = 0;\n for (const chunk of chunks) {\n totalLength += chunk.length;\n }\n const pcmData = new Int16Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n for (let i = 0; i < chunk.length; i++) {\n const s = Math.max(-1, Math.min(1, chunk[i]));\n pcmData[offset++] = s < 0 ? s * 0x8000 : s * 0x7FFF;\n }\n }\n const numChannels = 1;\n const bitsPerSample = 16;\n const byteRate = sampleRate * numChannels * bitsPerSample / 8;\n const blockAlign = numChannels * bitsPerSample / 8;\n const dataSize = pcmData.length * (bitsPerSample / 8);\n const headerSize = 44;\n const totalSize = headerSize + dataSize;\n const buffer = new ArrayBuffer(totalSize);\n const view = new DataView(buffer);\n this._wavWriteString(view, 0, 'RIFF');\n view.setUint32(4, totalSize - 8, true);\n this._wavWriteString(view, 8, 'WAVE');\n this._wavWriteString(view, 12, 'fmt ');\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true);\n view.setUint16(22, numChannels, true);\n view.setUint32(24, sampleRate, true);\n view.setUint32(28, byteRate, true);\n view.setUint16(32, blockAlign, true);\n view.setUint16(34, bitsPerSample, true);\n this._wavWriteString(view, 36, 'data');\n view.setUint32(40, dataSize, true);\n const pcmView = new Int16Array(buffer, headerSize, pcmData.length);\n pcmView.set(pcmData);\n return new Blob([buffer], {\n type: 'audio/wav'\n });\n },\n _wavWriteString(view, offset, str) {\n for (let i = 0; i < str.length; i++) {\n view.setUint8(offset + i, str.charCodeAt(i));\n }\n },\n // 发送音频\n // 发送音频\n sendAudioBlob(blob) {\n let appSeqId = _index2.default.getAppSeqId();\n let name = '';\n // 区分格式命名\n if (blob.type === 'audio/wav') {\n name = appSeqId + '.wav';\n } else {\n name = appSeqId + '.mp4';\n }\n const file = new File([blob], name, {\n type: blob.type\n });\n this.sendAudioMessage(file);\n },\n sendAudioMessage(file) {\n const sttKey = 'stt_' + Date.now();\n const sttMsg = {\n role: 'user',\n content: '转化中...',\n _sttStatus: 'pending',\n _sttKey: sttKey,\n createTime: new Date().toISOString(),\n _key: sttKey\n };\n this.messages.push(sttMsg);\n this.scrollToBottom(true);\n (0, _index.fileUploadToObs)({\n _file: file,\n appSeqId: file.name\n }).then(resp => {\n if (resp.CommonMsg.Status === 200) {\n return _apiObs.default.aliAudioToText({\n url: resp.obs_upload_data.obsUrl\n }).then(result => {\n if (result.code === '200' && result.data) {\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.messages.splice(idx, 1);\n }\n this.sendQuickMsg(result.data);\n } else {\n throw new Error(result.msg || '语音转文字失败');\n }\n });\n } else {\n throw new Error(resp.CommonMsg.Message || '文件上传失败');\n }\n }).catch(err => {\n console.error('语音转文字失败:', err);\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.$set(this.messages, idx, {\n ...this.messages[idx],\n _sttStatus: 'error',\n content: '语音转文字失败'\n });\n }\n this.$message.error('语音转文字失败');\n });\n this.$message.success(`录音完成 (${this.recordDuration}s)`);\n },\n clearWaitTimer() {\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n },\n loadMoreMessages() {\n const nextPage = this.msgPage + 1;\n const maxPage = Math.ceil(this.msgTotal / 10);\n if (nextPage > maxPage) {\n this.loadingMore = false;\n return;\n }\n (0, _apichat.getSessionMessages)(this.activeSessionId, nextPage, 10).then(res => {\n const list = Array.isArray(res) ? res : res.list || [];\n this.msgPage = nextPage;\n const asAsc = (list || []).slice().reverse();\n const prepend = asAsc.map(m => ({\n role: m.role,\n content: m.content || '',\n thinking: m.thinking || '',\n toolCalls: [],\n agentName: '',\n createTime: m.createTime || null,\n _key: m.createTime ? 'msg_' + m.createTime : 'msg_' + Date.now() + '_' + Math.random()\n }));\n const container = this.$refs.messagesRef;\n const oldScrollHeight = container ? container.scrollHeight : 0;\n const oldScrollTop = container ? container.scrollTop : 0;\n this.messages = [...prepend, ...this.messages];\n this.$nextTick(() => {\n if (container) {\n container.scrollTop = oldScrollTop + (container.scrollHeight - oldScrollHeight);\n }\n this.loadingMore = false;\n });\n }).catch(() => {\n this.loadingMore = false;\n });\n },\n onScrollMessages() {\n const container = this.$refs.messagesRef;\n if (!container || this.loadingMore) return;\n const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 100;\n this.checkScrollBottom();\n if (isAtBottom) return;\n if (container.scrollTop < 50 && this.msgPage < Math.ceil(this.msgTotal / 10)) {\n this.loadingMore = true;\n this.loadMoreMessages();\n }\n },\n async sendMessage() {\n const text = this.inputText.trim();\n if (!text || this.sending) return;\n if (text.length > 300) {\n this.$message({\n message: '消息内容不能超过300字',\n type: 'warning',\n duration: 2000\n });\n return;\n }\n if (!this.activeSessionId) {\n this.$emit('start-session');\n this._pendingSendText = text;\n return;\n }\n this._sendMessage(text);\n },\n async _sendMessage(text) {\n this.inputText = '';\n const ta = this.$refs.taRef;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n if (ta) {\n ta.style.height = baseH + 'px';\n }\n this.showDownBtn = false;\n this.messages.push({\n role: 'user',\n content: text,\n createTime: new Date().toISOString(),\n _key: 'user_' + Date.now() + '_' + Math.random()\n });\n const agentName = this.currentAgentName;\n const msgIdx = this.messages.push({\n role: \"assistant\",\n content: \"\",\n thinking: \"\",\n loading: true,\n toolCalls: [],\n agentName,\n createTime: new Date().toISOString(),\n _key: 'asst_' + Date.now() + '_' + Math.random()\n }) - 1;\n this.scrollToBottom(true);\n this.sending = true;\n this.waitSeconds = 0;\n if (this.waitTimer) clearInterval(this.waitTimer);\n this.waitTimer = setInterval(() => {\n this.waitSeconds++;\n }, 1000);\n let thinkingBuffer = '';\n let finalContent = '';\n let answerBuffer = '';\n let thinkDisplayed = 0;\n let typeTimer = null;\n let streamDone = false;\n typeTimer = setInterval(() => {\n if (!this.messages[msgIdx]) return;\n\n // Phase 1: 逐字展示思考内容(不 return,让 Phase 2 也能同时执行)\n if (thinkDisplayed < thinkingBuffer.length) {\n thinkDisplayed += 50;\n if (thinkDisplayed > thinkingBuffer.length) thinkDisplayed = thinkingBuffer.length;\n this.messages[msgIdx].thinking = thinkingBuffer.substring(0, thinkDisplayed);\n this.scrollToBottom();\n // 不 return,让 Phase 2 也能执行,避免 iOS 上思考内容过长阻塞回答内容展示\n }\n\n // Phase 2: 逐行展示(已完成的行整行出现,未完成的行也即时显示)\n const target = finalContent || answerBuffer;\n const msg = this.messages[msgIdx];\n const revealedLen = msg._revealedLen || 0;\n if (target.length > revealedLen) {\n const remaining = target.substring(revealedLen);\n const nextNewline = remaining.indexOf('\\n');\n if (nextNewline !== -1) {\n // 新的一行已完成 → 整行出现\n const endPos = revealedLen + nextNewline + 1;\n msg._revealedLen = endPos;\n msg.content = target.substring(0, endPos);\n this.scrollToBottom();\n return;\n }\n\n // 当前行未完成 → 逐步展示(每次 30 字符),确保 iOS 上也有渐进效果\n const revealStep = 30;\n const newRevealedLen = Math.min(revealedLen + revealStep, target.length);\n msg._revealedLen = newRevealedLen;\n msg.content = target.substring(0, newRevealedLen);\n this.scrollToBottom();\n return;\n }\n\n // Phase 3: 流已结束且内容已全部展示 → 收尾\n if (streamDone) {\n clearInterval(typeTimer);\n const key = this.messages[msgIdx]._key;\n if (key) {\n this.$delete(this.contentCache, key);\n this.$delete(this.contentCache, key + '_t');\n }\n typeTimer = null;\n this.messages[msgIdx].content = finalContent;\n this.messages[msgIdx].thinking = thinkingBuffer;\n this.messages[msgIdx].loading = false;\n this.sending = false;\n this.clearWaitTimer();\n this.scrollToBottom();\n this.$emit('session-refresh');\n this.$emit('message-sent');\n }\n }, 30);\n const url = (0, _apichat.createStreamUrl)(this.activeSessionId, text);\n if (this.eventSource) {\n this.eventSource.close();\n }\n this.eventSource = new EventSource(url);\n const es = this.eventSource;\n es.addEventListener('reasoning', e => {\n if (e.data) {\n const chunk = e.data;\n let cleanChunk = chunk.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2').replace(/\\s+([:,.!?])/g, '$1').replace(/([:,.!?])\\s+/g, '$1 ');\n cleanChunk = cleanChunk.replace(/\\s+/g, ' ');\n const lastChar = thinkingBuffer.slice(-1);\n const firstChar = cleanChunk.slice(0, 1);\n const isLetter = c => /[a-zA-Z]/.test(c);\n if (isLetter(lastChar) && isLetter(firstChar)) {\n thinkingBuffer += ' ' + cleanChunk;\n } else {\n thinkingBuffer += cleanChunk;\n }\n }\n });\n es.addEventListener('reasoning_result', e => {\n if (e.data) {\n thinkingBuffer = e.data;\n }\n });\n es.addEventListener('answer', e => {\n if (e.data) {\n answerBuffer += e.data.replace(/([a-zA-Z]+)([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z]+)/g, '$1 $2').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2');\n }\n });\n es.addEventListener('tool_result', e => {\n if (e.data && this.messages[msgIdx]) {\n if (!this.messages[msgIdx].toolCalls) this.messages[msgIdx].toolCalls = [];\n this.messages[msgIdx].toolCalls.push(e.data);\n this.scrollToBottom();\n }\n });\n es.addEventListener('agent_result', e => {\n finalContent = e.data || '';\n streamDone = true;\n es.close();\n this.eventSource = null;\n });\n es.onerror = err => {\n console.log('[SSE] onerror', err);\n if (thinkingBuffer.length > 0 && !streamDone) finalContent = thinkingBuffer;\n if (!streamDone) finalContent = '❌ 连接中断';\n streamDone = true;\n es.close();\n this.eventSource = null;\n };\n },\n stopAgent() {\n if (this.eventSource) {\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.activeSessionId) {\n (0, _apichat.stopStream)(this.activeSessionId).catch(() => {});\n }\n if (this.messages.length > 0) {\n const last = this.messages[this.messages.length - 1];\n if (last && last.loading) {\n last.content = last.content || last.thinking || '⏹ 已停止';\n last.loading = false;\n this.$delete(this.contentCache, last._key);\n }\n }\n this.sending = false;\n this.clearWaitTimer();\n },\n scrollToTop() {\n const el = this.$refs.messagesRef;\n if (el) el.scrollTop = 0;\n },\n scrollToBottom(force = false) {\n if (this.showDownBtn && !force) return;\n this.$nextTick(() => {\n const el = this.$refs.messagesRef;\n if (el) {\n el.scrollTop = el.scrollHeight;\n this.lastScrollTop = el.scrollTop;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n }\n });\n },\n scrollToBottomOnce() {\n clearTimeout(this._scrollTimer);\n this.$nextTick(() => {\n const tryScroll = () => {\n const el = this.$refs.messagesRef;\n if (!el) {\n this._scrollTimer = setTimeout(tryScroll, 50);\n return;\n }\n const originalScrollBehavior = el.style.scrollBehavior;\n el.style.scrollBehavior = 'auto';\n el.scrollTop = el.scrollHeight - el.clientHeight;\n el.style.scrollBehavior = originalScrollBehavior;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n };\n tryScroll();\n });\n },\n copyText(content) {\n if (!content) return;\n const text = content.replace(/<[^>]+>/g, '');\n\n // 优先使用 Clipboard API(HTTPS 环境)\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(() => {\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n this._fallbackCopyText(text);\n });\n } else {\n this._fallbackCopyText(text);\n }\n },\n _fallbackCopyText(text) {\n try {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';\n document.body.appendChild(ta);\n ta.focus();\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n } catch {\n this.$message({\n message: '复制失败',\n type: 'error',\n duration: 2000\n });\n }\n },\n copyAsImage(content) {\n if (!content) return;\n const div = document.createElement('div');\n div.className = 'copy-image-render';\n div.style.cssText = 'position:fixed;left:-9999px;top:0;width:600px;padding:20px;background:#fff;font-size:14px;line-height:1.6;color:#333;border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;';\n // 内联表格样式(因 Vue 作用域样式不覆盖复制用的脱离文档流 div)\n div.innerHTML = this.renderMarkdown(content) + '<style>table{border-collapse:collapse;width:100%;margin:6px 0;font-size:14px}' + 'th,td{border:1px solid #e8e8e8;padding:5px 8px;text-align:left}' + 'th{background:#fafafa;font-weight:600}</style>';\n document.body.appendChild(div);\n this.$message({\n message: '正在生成图片...',\n type: 'info',\n duration: 2000\n });\n (0, _html2canvas.default)(div, {\n scale: 2,\n useCORS: true,\n backgroundColor: '#ffffff'\n }).then(canvas => {\n // 检测是否支持 ClipboardItem(部分 WebView 不支持)\n const canCopyImage = typeof ClipboardItem !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.write === 'function';\n if (canCopyImage) {\n canvas.toBlob(blob => {\n if (blob) {\n navigator.clipboard.write([new ClipboardItem({\n 'image/png': blob\n })]).then(() => {\n this.$message({\n message: '图片已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n // ClipboardItem 存在但写入失败 → 降级下载\n this._downloadCanvas(canvas);\n });\n }\n }, 'image/png');\n } else {\n // 不支持剪贴板写图片 → 直接下载\n this._downloadCanvas(canvas);\n }\n }).catch(() => {\n this.$message({\n message: '生成图片失败',\n type: 'error',\n duration: 2000\n });\n }).finally(() => {\n document.body.removeChild(div);\n });\n },\n _downloadCanvas(canvas) {\n const link = document.createElement('a');\n link.download = '消息图片.png';\n link.href = canvas.toDataURL('image/png');\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n this.$message({\n message: '已保存图片到本地',\n type: 'success',\n duration: 2000\n });\n }\n }\n};\n\n//# sourceURL=webpack://ZjPluginIntelligent/./src/lib/components/chatMain/ChatDetail.vue?./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--13-0!./node_modules/_babel-loader@8.4.1@babel-loader/lib!./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--1-0!./node_modules/_vue-loader@15.11.1@vue-loader/lib??vue-loader-options");
|
|
955
955
|
|
|
956
956
|
/***/ }),
|
|
957
957
|
|
|
@@ -12608,7 +12608,7 @@ eval("module.exports = function(originalModule) {\n\tif (!originalModule.webpack
|
|
|
12608
12608
|
/*! exports provided: name, version, main, private, scripts, dependencies, devDependencies, browserslist, directories, keywords, license, default */
|
|
12609
12609
|
/***/ (function(module) {
|
|
12610
12610
|
|
|
12611
|
-
eval("module.exports = JSON.parse(\"{\\\"name\\\":\\\"zj-plugin-intelligent\\\",\\\"version\\\":\\\"1.2.5-beta.
|
|
12611
|
+
eval("module.exports = JSON.parse(\"{\\\"name\\\":\\\"zj-plugin-intelligent\\\",\\\"version\\\":\\\"1.2.5-beta.30\\\",\\\"main\\\":\\\"lib/ZjPluginIntelligent.umd.min.js\\\",\\\"private\\\":false,\\\"scripts\\\":{\\\"serve\\\":\\\"vue-cli-service serve\\\",\\\"build\\\":\\\"vue-cli-service build\\\",\\\"lib\\\":\\\"vue-cli-service build --target lib --name ZjPluginIntelligent --dest lib src/lib/index.js\\\",\\\"lib:test\\\":\\\"vue-cli-service build --mode test --target lib --name ZjPluginIntelligent --dest lib src/lib/index.js\\\"},\\\"dependencies\\\":{\\\"html2canvas\\\":\\\"^1.4.1\\\",\\\"markdown-it\\\":\\\"^14.1.0\\\"},\\\"devDependencies\\\":{\\\"@babel/preset-env\\\":\\\"^7.14.9\\\",\\\"@vue/cli-plugin-babel\\\":\\\"~4.5.0\\\",\\\"@vue/cli-plugin-eslint\\\":\\\"^4.5.13\\\",\\\"@vue/cli-plugin-vuex\\\":\\\"~4.5.0\\\",\\\"@vue/cli-service\\\":\\\"~4.5.0\\\",\\\"axios\\\":\\\"^0.27.2\\\",\\\"babel-eslint\\\":\\\"^10.1.0\\\",\\\"babel-plugin-component\\\":\\\"^1.1.1\\\",\\\"compression-webpack-plugin\\\":\\\"^6.0.3\\\",\\\"core-js\\\":\\\"^3.6.5\\\",\\\"element-ui\\\":\\\"^2.15.4\\\",\\\"eslint\\\":\\\"^7.30.0\\\",\\\"eslint-plugin-vue\\\":\\\"^7.13.0\\\",\\\"js-cookie\\\":\\\"^2.2.1\\\",\\\"node-sass\\\":\\\"^4.12.0\\\",\\\"obs-upload\\\":\\\"^1.0.4-alpha.0\\\",\\\"reconnecting-websocket\\\":\\\"^4.4.0\\\",\\\"sass-loader\\\":\\\"^8.0.2\\\",\\\"snowflake-id\\\":\\\"^1.1.0\\\",\\\"vue\\\":\\\"^2.6.11\\\",\\\"vue-template-compiler\\\":\\\"^2.6.11\\\",\\\"vuex\\\":\\\"^3.4.0\\\",\\\"pako\\\":\\\"^2.1.0\\\",\\\"webpack-bundle-analyzer\\\":\\\"^4.4.2\\\"},\\\"browserslist\\\":[\\\"> 1%\\\",\\\"last 2 versions\\\",\\\"not dead\\\"],\\\"directories\\\":{\\\"lib\\\":\\\"lib\\\"},\\\"keywords\\\":[],\\\"license\\\":\\\"ISC\\\"}\");\n\n//# sourceURL=webpack://ZjPluginIntelligent/./package.json?");
|
|
12612
12612
|
|
|
12613
12613
|
/***/ }),
|
|
12614
12614
|
|
|
@@ -13500,7 +13500,7 @@ eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n
|
|
|
13500
13500
|
/***/ (function(module, exports, __webpack_require__) {
|
|
13501
13501
|
|
|
13502
13502
|
"use strict";
|
|
13503
|
-
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\nexports.getAuth = getAuth;\nexports.setAuth = setAuth;\nexports.setBaseURL = setBaseURL;\nexports.setToken = setToken;\nexports.signQueryParams = signQueryParams;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.filter.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.filter.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\nvar _axios = _interopRequireDefault(__webpack_require__(/*! axios */ \"./node_modules/_axios@0.27.2@axios/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _message = __webpack_require__(/*! @lib/utils/message.js */ \"./src/lib/utils/message.js\");\n/*\r\n * @Author: 高瑞廷 2419056691@qq.com\r\n * @Date: 2026-06-04 11:27:13\r\n * @LastEditors: 高瑞廷 2419056691@qq.com\r\n * @LastEditTime: 2026-07-
|
|
13503
|
+
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\nexports.getAuth = getAuth;\nexports.setAuth = setAuth;\nexports.setBaseURL = setBaseURL;\nexports.setToken = setToken;\nexports.signQueryParams = signQueryParams;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.filter.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.filter.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\nvar _axios = _interopRequireDefault(__webpack_require__(/*! axios */ \"./node_modules/_axios@0.27.2@axios/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _message = __webpack_require__(/*! @lib/utils/message.js */ \"./src/lib/utils/message.js\");\n/*\r\n * @Author: 高瑞廷 2419056691@qq.com\r\n * @Date: 2026-06-04 11:27:13\r\n * @LastEditors: 高瑞廷 2419056691@qq.com\r\n * @LastEditTime: 2026-07-16 14:14:10\r\n * @FilePath: \\zjkj-nodejs-npm_customer-client\\src\\lib\\utils\\request.js\r\n * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE\r\n */\n\nlet runtimeAK = Object({\"NODE_ENV\":\"test\",\"VUE_APP_API\":\"https://api.qgbtech.cn\",\"VUE_APP_BASE_API\":\"https://ai.jybtech.cn\",\"VUE_APP_NAME\":\"development\",\"BASE_URL\":\"/\"}).VUE_APP_BASE_AK || '';\nlet runtimeSK = Object({\"NODE_ENV\":\"test\",\"VUE_APP_API\":\"https://api.qgbtech.cn\",\"VUE_APP_BASE_API\":\"https://ai.jybtech.cn\",\"VUE_APP_NAME\":\"development\",\"BASE_URL\":\"/\"}).VUE_APP_BASE_SK || '';\nlet runtimeToken = '';\n\n// let _user = '202007091611352'\nlet _user = '';\n// let _companyNo = '201912241159840'\nlet _companyNo = '';\nlet runtimeBaseURL = \"https://ai.jybtech.cn\" || false;\nconst CryptoJS = __webpack_require__(/*! crypto-js */ \"./node_modules/_crypto-js@4.2.0@crypto-js/index.js\");\nconst service = _axios.default.create({\n baseURL: runtimeBaseURL,\n timeout: 15000\n});\nfunction hmacSha256Base64(rawSignStr, sk) {\n const hmac = CryptoJS.HmacSHA256(rawSignStr, sk);\n // 转为 Base64\n return CryptoJS.enc.Base64.stringify(hmac);\n}\nfunction setAuth(ak, sk, user, companyNo, token) {\n runtimeAK = ak || '';\n runtimeSK = sk || '';\n _user = user;\n _companyNo = companyNo;\n runtimeToken = token || '';\n}\nfunction setToken(token) {\n runtimeToken = token || '';\n}\nfunction getAuth() {\n return {\n ak: runtimeAK,\n sk: runtimeSK,\n token: runtimeToken,\n user: _user,\n companyNo: _companyNo\n };\n}\nfunction setBaseURL(url) {\n runtimeBaseURL = url || '';\n service.defaults.baseURL = runtimeBaseURL;\n}\nfunction signQueryParams(params, secretKey) {\n const keys = Object.keys(params).filter(k => k !== 'ak' && k !== 'sign').sort();\n const raw = keys.map(k => `${k}=${params[k] || ''}`).join('&');\n return hmacSha256Base64(raw, secretKey);\n}\nservice.interceptors.request.use(config => {\n // 流式接口跳过 Header 签名\n if (config.url.includes('/chat/stream')) {\n return config;\n }\n const ts = Date.now().toString();\n const token = runtimeToken || (0, _cookie.getToken)();\n const externalUser = _user || '';\n const signParts = [];\n // 1. 第一位:token(有值才加入)\n if (_companyNo) {\n signParts.push(`companyNo=${_companyNo}`);\n }\n if (token) {\n signParts.push(`token=${token}`);\n }\n // 2. 第二位:user(有值才加入)\n if (externalUser) {\n signParts.push(`user=${externalUser}`);\n }\n // 3. 第三位:ts(必选,永远存在)\n signParts.push(`ts=${ts}`);\n\n // 拼接最终待签字符串\n const signStr = signParts.join('&');\n // 计算签名\n const sign = hmacSha256Base64(signStr, runtimeSK);\n if (_companyNo) {\n config.headers['X-Company-No'] = _companyNo;\n }\n\n // 设置请求头\n config.headers['X-AK'] = runtimeAK;\n config.headers['X-Ts'] = ts;\n config.headers['X-Sign'] = sign;\n if (token) {\n config.headers['X-Auth-Token'] = `${token}`;\n config.headers.Authorization = `Bearer ${token}`;\n }\n config.headers['X-External-User'] = externalUser;\n config.headers['Content-Type'] = 'application/json';\n\n // console.log('排序后key:', sortedKeys)\n\n return config;\n}, error => Promise.reject(error));\nservice.interceptors.response.use(response => {\n const res = response.data;\n if (res.code === 200) {\n return res;\n } else if (res.code === 40101) {\n (0, _message.showMessage)('登录已过期,请重新登录');\n return Promise.reject(new Error(res.message));\n } else {\n (0, _message.showMessage)(res.message || '请求失败');\n return Promise.reject(new Error(res.message));\n }\n}, error => {\n (0, _message.showMessage)(error.message || '网络异常');\n return Promise.reject(error);\n});\nvar _default = exports.default = service;\n\n//# sourceURL=webpack://ZjPluginIntelligent/./src/lib/utils/request.js?");
|
|
13504
13504
|
|
|
13505
13505
|
/***/ }),
|
|
13506
13506
|
|
|
@@ -960,7 +960,7 @@ eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n
|
|
|
960
960
|
/***/ (function(module, exports, __webpack_require__) {
|
|
961
961
|
|
|
962
962
|
"use strict";
|
|
963
|
-
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.detached.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.detached.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer-to-fixed-length.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer-to-fixed-length.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.find.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.find.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.for-each.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.for-each.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.at.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.at.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last-index.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last-index.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.set.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.set.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-reversed.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-reversed.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-sorted.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-sorted.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.with.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.with.js\");\nvar _markdownIt = _interopRequireDefault(__webpack_require__(/*! markdown-it */ \"./node_modules/_markdown-it@14.1.0@markdown-it/index.mjs\"));\nvar _apichat = __webpack_require__(/*! @lib/api/apichat */ \"./src/lib/api/apichat.js\");\nvar _index = __webpack_require__(/*! @lib/socket/fileManager/obs/index */ \"./src/lib/socket/fileManager/obs/index.js\");\nvar _index2 = _interopRequireDefault(__webpack_require__(/*! @lib/socket/utils/index */ \"./src/lib/socket/utils/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _apiObs = _interopRequireDefault(__webpack_require__(/*! @lib/api/apiObs */ \"./src/lib/api/apiObs.js\"));\nvar _html2canvas = _interopRequireDefault(__webpack_require__(/*! html2canvas */ \"./node_modules/_html2canvas@1.4.1@html2canvas/dist/html2canvas.js\"));\nconst md = new _markdownIt.default({\n breaks: true,\n html: false,\n linkify: true,\n typographer: true\n});\nmd.enable('table');\nvar _default = exports.default = {\n name: 'ChatDetail',\n props: {\n isMobile: {\n type: Boolean,\n default: false\n },\n activeSessionId: {\n type: String,\n default: ''\n },\n currentAgent: {\n type: String,\n default: ''\n },\n agentInfo: {\n type: Object,\n default: () => ({})\n },\n currentAgentDesc: {\n type: String,\n default: ''\n },\n showSidebar: {\n type: Boolean,\n default: false\n },\n showH5List: {\n type: Boolean,\n default: false\n },\n showAgentSelect: {\n type: Boolean,\n default: false\n },\n agentList: {\n type: Array,\n default: () => []\n }\n },\n data() {\n return {\n messages: [],\n inputText: '',\n sending: false,\n waitTimer: null,\n waitSeconds: 0,\n canceling: false,\n msgPage: 1,\n msgMaxPage: 1,\n msgTotal: 0,\n eventSource: null,\n showToolInfo: false,\n showDownBtn: false,\n lastScrollTop: 0,\n scrollTimer: null,\n contentCache: {},\n loadingSessionMsg: false,\n greeting: '',\n _scrollTimer: null,\n _userScrolledUp: false,\n voiceMode: false,\n isRecording: false,\n recordDuration: 0,\n recordingTimer: null,\n audioContext: null,\n audioStream: null,\n mediaRecorder: null,\n recordedChunks: [],\n audioSource: null,\n isCancelSwipe: false,\n recordStartY: 0,\n _pendingSendText: null\n };\n },\n computed: {\n waitClass() {\n if (this.waitSeconds > 15) return 'wait-15';\n if (this.waitSeconds > 10) return 'wait-10';\n if (this.waitSeconds > 5) return 'wait-5';\n return '';\n },\n currentAgentName() {\n if (!this.currentAgent) return '';\n const a = this.agentList.find(x => x.agentCode === this.currentAgent);\n return a ? a.agentName || a.agentCode : this.currentAgent;\n }\n },\n watch: {\n contentCache: {\n handler(val) {\n // 保持响应性\n },\n immediate: true\n },\n activeSessionId(val) {\n if (val && this._pendingSendText) {\n const text = this._pendingSendText;\n this._pendingSendText = null;\n this.$nextTick(() => this._sendMessage(text));\n }\n }\n },\n mounted() {\n if (window.visualViewport) {\n window.visualViewport.addEventListener('resize', this.onViewportResize, {\n passive: true\n });\n }\n },\n beforeDestroy() {\n if (window.visualViewport) {\n window.visualViewport.removeEventListener('resize', this.onViewportResize);\n }\n this.releaseAudio();\n this.contentCache = {};\n clearTimeout(this._scrollTimer);\n if (this.eventSource) {\n this.eventSource.onerror = null;\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n if (this._typeTimer) {\n clearInterval(this._typeTimer);\n this._typeTimer = null;\n }\n },\n methods: {\n getInitial(name) {\n if (!name) return '?';\n return name.charAt(0);\n },\n formatTime(t) {\n if (!t) return '';\n const d = new Date(t.replace(' ', 'T'));\n const now = new Date();\n const diff = (now - d) / 1000;\n if (diff < 60) return '刚刚';\n if (diff < 3600) return Math.floor(diff / 60) + '分钟前';\n if (diff < 86400) return Math.floor(diff / 3600) + '小时前';\n return d.getMonth() + 1 + '/' + d.getDate();\n },\n escapeHtml(text) {\n if (!text) return '';\n return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\n },\n renderMarkdown(text) {\n if (!text) return '';\n try {\n return md.render(text);\n } catch (err) {\n return this.escapeHtml(text);\n }\n },\n renderStreaming(text, msgKey) {\n if (!text) return '';\n const cache = this.contentCache[msgKey];\n const lines = text.split('\\n');\n const lastLineIdx = lines.length - 1;\n if (!cache) {\n try {\n const html = md.render(text);\n this.$set(this.contentCache, msgKey, {\n renderedLines: lastLineIdx,\n html,\n fullText: text\n });\n return html;\n } catch {\n return this.escapeHtml(text);\n }\n }\n if (text === cache.fullText) return cache.html;\n cache.fullText = text;\n\n // 行数没变,仅末尾追加字符,直接拼接尾行\n if (lastLineIdx === cache.renderedLines) {\n return cache.html + this.escapeHtml(lines[lastLineIdx]);\n }\n\n // 场景1:行数变少/文本回退截断,全量重渲染\n if (lastLineIdx < cache.renderedLines) {\n try {\n const fullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (fullLines.length > 0) {\n newBaseHtml = md.render(fullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n return cache.html + (lastLine || '');\n } catch {\n return this.escapeHtml(text);\n }\n }\n\n // 场景2:新增完整行\n if (cache.renderedLines < lastLineIdx) {\n const allFullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (allFullLines.length > 0) {\n newBaseHtml = md.render(allFullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n }\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n cache.renderedLines = lastLineIdx;\n return cache.html + (lastLine || '');\n },\n getChatAvatarStyle(agent, avatar) {\n if (avatar) {\n return {\n backgroundImage: `url(${avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n const _agent = this.agentList.find(x => x.agentCode === agent);\n if (_agent && _agent.avatar) {\n return {\n backgroundImage: `url(${_agent.avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n // 没有网络图片时,根据智能体在 agentList 中的索引位置决定颜色\n const index = this.agentList.findIndex(item => item.agentCode === agent);\n if (index !== -1) {\n const colorIndex = index % 4 + 1;\n return {\n background: this.getGradientByColorIndex(colorIndex)\n };\n }\n return {\n background: 'linear-gradient(135deg, #1677ff, #4096ff)'\n };\n },\n getGradientByColorIndex(index) {\n const gradients = {\n 1: 'linear-gradient(135deg, #1677ff, #4096ff)',\n 2: 'linear-gradient(135deg, #52c41a, #73d13d)',\n 3: 'linear-gradient(135deg, #fa8c16, #ffa940)',\n 4: 'linear-gradient(135deg, #722ed1, #b37feb)'\n };\n return gradients[index] || gradients[1];\n },\n sendQuickMsg(message) {\n if (message) {\n this.inputText = message;\n this.sendMessage();\n }\n },\n formatMixText(str) {\n if (!str) return '';\n let res = str.replace(/([a-zA-Z0-9])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z0-9])/g, '$1 $2');\n res = res.replace(/([a-z])([A-Z])/g, '$1 $2');\n return res;\n },\n // 检测是否滚动到底部,控制向下箭头显示隐藏\n checkScrollBottom(forceShowWhenNotBottom = false) {\n const el = this.$refs.messagesRef;\n if (!el) {\n this.showDownBtn = false;\n return;\n }\n const {\n scrollHeight,\n scrollTop,\n clientHeight\n } = el;\n const remain = scrollHeight - scrollTop - clientHeight;\n const atBottom = remain <= 80;\n\n // 检测用户是否主动向上滚动(远离底部)\n if (remain > 80 && scrollTop < this.lastScrollTop) {\n this._userScrolledUp = true;\n }\n if (atBottom) {\n this._userScrolledUp = false;\n this.showDownBtn = false;\n } else if (this._userScrolledUp) {\n // 用户已主动上滚,保持显示\"到底部\"按钮,不自动回滚\n this.showDownBtn = true;\n } else if (this.sending && scrollTop >= this.lastScrollTop && !forceShowWhenNotBottom) {\n this.showDownBtn = false;\n } else {\n this.showDownBtn = true;\n }\n this.lastScrollTop = scrollTop;\n this.$emit('scroll-bottom-check', {\n showDownBtn: this.showDownBtn,\n lastScrollTop: this.lastScrollTop\n });\n },\n // H5 输入框获取焦点 - 滚动到底部让键盘不遮挡\n onInputFocus() {\n if (!this.isMobile) return;\n setTimeout(() => {\n window.scrollTo(0, document.body.scrollHeight);\n }, 300);\n },\n // visualViewport 变化时(如键盘弹出)调整滚动位置\n onViewportResize() {\n if (!this.isMobile) return;\n const activeEl = document.activeElement;\n if (activeEl && (activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'INPUT')) {\n window.scrollTo(0, document.body.scrollHeight);\n }\n },\n autoResizeTextarea() {\n const ta = this.$refs.taRef;\n if (!ta) return;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n const maxH = isMobile ? 100 : 180;\n ta.style.height = `${baseH}px`;\n const realH = ta.scrollHeight;\n if (realH > baseH) {\n ta.style.height = `${Math.min(realH, maxH)}px`;\n }\n this.$nextTick(() => {\n this.checkScrollBottom(true);\n });\n },\n // Ctrl+Enter换行\n newLine(e) {\n e.preventDefault();\n const ta = this.$refs.taRef;\n const start = ta.selectionStart;\n const end = ta.selectionEnd;\n this.inputText = this.inputText.slice(0, start) + '\\n' + this.inputText.slice(end);\n this.$nextTick(() => {\n ta.selectionStart = ta.selectionEnd = start + 1;\n this.autoResizeTextarea();\n });\n },\n toggleVoiceMode() {\n if (this.voiceMode) {\n this.voiceMode = false;\n this.releaseAudio();\n return;\n }\n // 进入语音模式前提前请求麦克风权限,避免触摸时异步弹窗导致 touchend 丢失\n this.ensureAudioReady().then(ready => {\n if (ready) {\n this.voiceMode = true;\n } else {\n this.voiceMode = false;\n }\n });\n },\n // 确保音频设备已就绪\n async ensureAudioReady() {\n if (this.mediaRecorder && this.audioStream) return true;\n if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {\n alert('当前环境不支持麦克风访问');\n return false;\n }\n if (!window.MediaRecorder) {\n alert('当前浏览器不支持录音功能');\n return false;\n }\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: true\n });\n this.audioStream = stream;\n\n // 检测平台支持的 MIME 类型\n const mimeType = this._getSupportedMimeType();\n const options = mimeType ? {\n mimeType\n } : {};\n const recorder = new MediaRecorder(stream, options);\n recorder.ondataavailable = e => {\n if (e.data && e.data.size > 0) {\n this.recordedChunks.push(e.data);\n }\n };\n recorder.onstop = () => {\n this._onMediaRecorderStop();\n };\n recorder.onerror = () => {\n console.error('MediaRecorder error');\n this.recordedChunks = [];\n };\n this.mediaRecorder = recorder;\n this.audioContext = new (window.AudioContext || window.webkitAudioContext)();\n if (this.audioContext.state === 'suspended') {\n await this.audioContext.resume();\n }\n return true;\n } catch (err) {\n console.error('麦克风访问失败:', err);\n alert('麦克风权限被拒绝');\n return false;\n }\n },\n // 检测支持的 MIME 类型\n _getSupportedMimeType() {\n const types = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/ogg;codecs=opus'];\n for (const t of types) {\n if (MediaRecorder.isTypeSupported(t)) return t;\n }\n return '';\n },\n // MediaRecorder 停止后的回调:解码压缩音频 → 编码 WAV → 发送\n _onMediaRecorderStop() {\n if (this.recordedChunks.length === 0) {\n console.warn('录音数据为空');\n return;\n }\n const blob = new Blob(this.recordedChunks, {\n type: this.mediaRecorder.mimeType || 'audio/webm'\n });\n this.recordedChunks = [];\n\n // 解码压缩音频为 PCM\n const reader = new FileReader();\n reader.onload = () => {\n const arrayBuffer = reader.result;\n this.audioContext.decodeAudioData(arrayBuffer, audioBuffer => {\n // 取第一个声道的数据\n const channelData = audioBuffer.getChannelData(0);\n const wavBlob = this._encodePCMToWavFromBuffer(channelData, audioBuffer.sampleRate);\n this.sendAudioBlob(wavBlob);\n }, err => {\n console.error('decodeAudioData 失败:', err);\n });\n };\n reader.onerror = () => {\n console.error('FileReader 读取失败');\n };\n reader.readAsArrayBuffer(blob);\n },\n // 从 Float32Array 编码 WAV\n _encodePCMToWavFromBuffer(channelData, sampleRate) {\n const numChannels = 1;\n const bitsPerSample = 16;\n const byteRate = sampleRate * numChannels * bitsPerSample / 8;\n const blockAlign = numChannels * bitsPerSample / 8;\n const dataSize = channelData.length * (bitsPerSample / 8);\n const headerSize = 44;\n const totalSize = headerSize + dataSize;\n const buffer = new ArrayBuffer(totalSize);\n const view = new DataView(buffer);\n this._wavWriteString(view, 0, 'RIFF');\n view.setUint32(4, totalSize - 8, true);\n this._wavWriteString(view, 8, 'WAVE');\n this._wavWriteString(view, 12, 'fmt ');\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true); // PCM\n view.setUint16(22, numChannels, true);\n view.setUint32(24, sampleRate, true);\n view.setUint32(28, byteRate, true);\n view.setUint16(32, blockAlign, true);\n view.setUint16(34, bitsPerSample, true);\n this._wavWriteString(view, 36, 'data');\n view.setUint32(40, dataSize, true);\n const pcmView = new Int16Array(buffer, headerSize, channelData.length);\n for (let i = 0; i < channelData.length; i++) {\n const s = Math.max(-1, Math.min(1, channelData[i]));\n pcmView[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;\n }\n return new Blob([buffer], {\n type: 'audio/wav'\n });\n },\n // H5语音录制 - 触摸开始\n async onRecordTouchStart(e) {\n if (this.sending) return;\n const ready = await this.ensureAudioReady();\n if (!ready) return;\n this.isCancelSwipe = false;\n this.recordStartY = e.touches[0].clientY;\n this.recordDuration = 0;\n this.recordedChunks = [];\n this.isRecording = true;\n this.mediaRecorder.start();\n this.recordingTimer = setInterval(() => {\n this.recordDuration++;\n if (this.recordDuration >= 60) {\n this.onRecordTouchEnd();\n }\n }, 1000);\n },\n // H5语音录制 - 触摸移动\n onRecordTouchMove(e) {\n if (!this.isRecording) return;\n const touch = e.touches[0];\n const moveY = touch.clientY;\n const cancelThreshold = 60;\n this.isCancelSwipe = this.recordStartY - moveY > cancelThreshold;\n },\n // H5语音录制 - 触摸结束\n onRecordTouchEnd() {\n if (!this.isRecording) return;\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n // 上滑取消:丢弃录音数据\n if (this.isCancelSwipe) {\n this.recordedChunks = [];\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n return;\n }\n // 正常发送:停止 MediaRecorder → 触发 onstop → _onMediaRecorderStop → 解码 → 发送\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n },\n // 取消录音\n cancelRecording() {\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n this.recordedChunks = [];\n this.recordDuration = 0;\n this.isCancelSwipe = false;\n },\n // 释放音频资源\n releaseAudio() {\n this.cancelRecording();\n if (this.mediaRecorder) {\n if (this.mediaRecorder.state !== 'inactive') {\n try {\n this.mediaRecorder.stop();\n } catch (e) {}\n }\n this.mediaRecorder = null;\n }\n if (this.audioSource) {\n this.audioSource.disconnect();\n this.audioSource = null;\n }\n if (this.audioContext) {\n this.audioContext.close().catch(() => {});\n this.audioContext = null;\n }\n if (this.audioStream) {\n this.audioStream.getTracks().forEach(t => t.stop());\n this.audioStream = null;\n }\n this.recordedChunks = [];\n this.voiceMode = false;\n },\n _wavWriteString(view, offset, str) {\n for (let i = 0; i < str.length; i++) {\n view.setUint8(offset + i, str.charCodeAt(i));\n }\n },\n // 发送音频\n sendAudioBlob(blob) {\n let appSeqId = _index2.default.getAppSeqId();\n let name = appSeqId + '.wav';\n const file = new File([blob], name, {\n type: 'audio/wav'\n });\n this.sendAudioMessage(file);\n },\n sendAudioMessage(file) {\n const sttKey = 'stt_' + Date.now();\n const sttMsg = {\n role: 'user',\n content: '转化中...',\n _sttStatus: 'pending',\n _sttKey: sttKey,\n createTime: new Date().toISOString(),\n _key: sttKey\n };\n this.messages.push(sttMsg);\n this.scrollToBottom(true);\n (0, _index.fileUploadToObs)({\n _file: file,\n appSeqId: file.name\n }).then(resp => {\n if (resp.CommonMsg.Status === 200) {\n return _apiObs.default.aliAudioToText({\n url: resp.obs_upload_data.obsUrl\n }).then(result => {\n if (result.code === '200' && result.data) {\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.messages.splice(idx, 1);\n }\n this.sendQuickMsg(result.data);\n } else {\n throw new Error(result.msg || '语音转文字失败');\n }\n });\n } else {\n throw new Error(resp.CommonMsg.Message || '文件上传失败');\n }\n }).catch(err => {\n console.error('语音转文字失败:', err);\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.$set(this.messages, idx, {\n ...this.messages[idx],\n _sttStatus: 'error',\n content: '语音转文字失败'\n });\n }\n this.$message.error('语音转文字失败');\n });\n this.$message.success(`录音完成 (${this.recordDuration}s)`);\n },\n clearWaitTimer() {\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n },\n loadMoreMessages() {\n const nextPage = this.msgPage + 1;\n const maxPage = Math.ceil(this.msgTotal / 10);\n if (nextPage > maxPage) {\n this.loadingMore = false;\n return;\n }\n (0, _apichat.getSessionMessages)(this.activeSessionId, nextPage, 10).then(res => {\n const list = Array.isArray(res) ? res : res.list || [];\n this.msgPage = nextPage;\n const asAsc = (list || []).slice().reverse();\n const prepend = asAsc.map(m => ({\n role: m.role,\n content: m.content || '',\n thinking: m.thinking || '',\n toolCalls: [],\n agentName: '',\n createTime: m.createTime || null,\n _key: m.createTime ? 'msg_' + m.createTime : 'msg_' + Date.now() + '_' + Math.random()\n }));\n const container = this.$refs.messagesRef;\n const oldScrollHeight = container ? container.scrollHeight : 0;\n const oldScrollTop = container ? container.scrollTop : 0;\n this.messages = [...prepend, ...this.messages];\n this.$nextTick(() => {\n if (container) {\n container.scrollTop = oldScrollTop + (container.scrollHeight - oldScrollHeight);\n }\n this.loadingMore = false;\n });\n }).catch(() => {\n this.loadingMore = false;\n });\n },\n onScrollMessages() {\n const container = this.$refs.messagesRef;\n if (!container || this.loadingMore) return;\n const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 100;\n this.checkScrollBottom();\n if (isAtBottom) return;\n if (container.scrollTop < 50 && this.msgPage < Math.ceil(this.msgTotal / 10)) {\n this.loadingMore = true;\n this.loadMoreMessages();\n }\n },\n async sendMessage() {\n const text = this.inputText.trim();\n if (!text || this.sending) return;\n if (text.length > 300) {\n this.$message({\n message: '消息内容不能超过300字',\n type: 'warning',\n duration: 2000\n });\n return;\n }\n if (!this.activeSessionId) {\n this.$emit('start-session');\n this._pendingSendText = text;\n return;\n }\n this._sendMessage(text);\n },\n async _sendMessage(text) {\n this.inputText = '';\n const ta = this.$refs.taRef;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n if (ta) {\n ta.style.height = baseH + 'px';\n }\n this.showDownBtn = false;\n this.messages.push({\n role: 'user',\n content: text,\n createTime: new Date().toISOString(),\n _key: 'user_' + Date.now() + '_' + Math.random()\n });\n const agentName = this.currentAgentName;\n const msgIdx = this.messages.push({\n role: \"assistant\",\n content: \"\",\n thinking: \"\",\n loading: true,\n toolCalls: [],\n agentName,\n createTime: new Date().toISOString(),\n _key: 'asst_' + Date.now() + '_' + Math.random()\n }) - 1;\n this.scrollToBottom(true);\n this.sending = true;\n this.waitSeconds = 0;\n if (this.waitTimer) clearInterval(this.waitTimer);\n this.waitTimer = setInterval(() => {\n this.waitSeconds++;\n }, 1000);\n let thinkingBuffer = '';\n let finalContent = '';\n let answerBuffer = '';\n let thinkDisplayed = 0;\n let typeTimer = null;\n let streamDone = false;\n typeTimer = setInterval(() => {\n if (!this.messages[msgIdx]) return;\n\n // Phase 1: 逐字展示思考内容(不 return,让 Phase 2 也能同时执行)\n if (thinkDisplayed < thinkingBuffer.length) {\n thinkDisplayed += 50;\n if (thinkDisplayed > thinkingBuffer.length) thinkDisplayed = thinkingBuffer.length;\n this.messages[msgIdx].thinking = thinkingBuffer.substring(0, thinkDisplayed);\n this.scrollToBottom();\n // 不 return,让 Phase 2 也能执行,避免 iOS 上思考内容过长阻塞回答内容展示\n }\n\n // Phase 2: 逐行展示(已完成的行整行出现,未完成的行也即时显示)\n const target = finalContent || answerBuffer;\n const msg = this.messages[msgIdx];\n const revealedLen = msg._revealedLen || 0;\n if (target.length > revealedLen) {\n const remaining = target.substring(revealedLen);\n const nextNewline = remaining.indexOf('\\n');\n if (nextNewline !== -1) {\n // 新的一行已完成 → 整行出现\n const endPos = revealedLen + nextNewline + 1;\n msg._revealedLen = endPos;\n msg.content = target.substring(0, endPos);\n this.scrollToBottom();\n return;\n }\n\n // 当前行未完成 → 逐步展示(每次 30 字符),确保 iOS 上也有渐进效果\n const revealStep = 30;\n const newRevealedLen = Math.min(revealedLen + revealStep, target.length);\n msg._revealedLen = newRevealedLen;\n msg.content = target.substring(0, newRevealedLen);\n this.scrollToBottom();\n return;\n }\n\n // Phase 3: 流已结束且内容已全部展示 → 收尾\n if (streamDone) {\n clearInterval(typeTimer);\n const key = this.messages[msgIdx]._key;\n if (key) {\n this.$delete(this.contentCache, key);\n this.$delete(this.contentCache, key + '_t');\n }\n typeTimer = null;\n this.messages[msgIdx].content = finalContent;\n this.messages[msgIdx].thinking = thinkingBuffer;\n this.messages[msgIdx].loading = false;\n this.sending = false;\n this.clearWaitTimer();\n this.scrollToBottom();\n this.$emit('session-refresh');\n this.$emit('message-sent');\n }\n }, 30);\n const url = (0, _apichat.createStreamUrl)(this.activeSessionId, text);\n if (this.eventSource) {\n this.eventSource.close();\n }\n this.eventSource = new EventSource(url);\n const es = this.eventSource;\n es.addEventListener('reasoning', e => {\n if (e.data) {\n const chunk = e.data;\n let cleanChunk = chunk.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2').replace(/\\s+([:,.!?])/g, '$1').replace(/([:,.!?])\\s+/g, '$1 ');\n cleanChunk = cleanChunk.replace(/\\s+/g, ' ');\n const lastChar = thinkingBuffer.slice(-1);\n const firstChar = cleanChunk.slice(0, 1);\n const isLetter = c => /[a-zA-Z]/.test(c);\n if (isLetter(lastChar) && isLetter(firstChar)) {\n thinkingBuffer += ' ' + cleanChunk;\n } else {\n thinkingBuffer += cleanChunk;\n }\n }\n });\n es.addEventListener('reasoning_result', e => {\n if (e.data) {\n thinkingBuffer = e.data;\n }\n });\n es.addEventListener('answer', e => {\n if (e.data) {\n answerBuffer += e.data.replace(/([a-zA-Z]+)([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z]+)/g, '$1 $2').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2');\n }\n });\n es.addEventListener('tool_result', e => {\n if (e.data && this.messages[msgIdx]) {\n if (!this.messages[msgIdx].toolCalls) this.messages[msgIdx].toolCalls = [];\n this.messages[msgIdx].toolCalls.push(e.data);\n this.scrollToBottom();\n }\n });\n es.addEventListener('agent_result', e => {\n finalContent = e.data || '';\n streamDone = true;\n es.close();\n this.eventSource = null;\n });\n es.onerror = err => {\n console.log('[SSE] onerror', err);\n if (thinkingBuffer.length > 0 && !streamDone) finalContent = thinkingBuffer;\n if (!streamDone) finalContent = '❌ 连接中断';\n streamDone = true;\n es.close();\n this.eventSource = null;\n };\n },\n stopAgent() {\n if (this.eventSource) {\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.activeSessionId) {\n (0, _apichat.stopStream)(this.activeSessionId).catch(() => {});\n }\n if (this.messages.length > 0) {\n const last = this.messages[this.messages.length - 1];\n if (last && last.loading) {\n last.content = last.content || last.thinking || '⏹ 已停止';\n last.loading = false;\n this.$delete(this.contentCache, last._key);\n }\n }\n this.sending = false;\n this.clearWaitTimer();\n },\n scrollToTop() {\n const el = this.$refs.messagesRef;\n if (el) el.scrollTop = 0;\n },\n scrollToBottom(force = false) {\n if (this.showDownBtn && !force) return;\n this.$nextTick(() => {\n const el = this.$refs.messagesRef;\n if (el) {\n el.scrollTop = el.scrollHeight;\n this.lastScrollTop = el.scrollTop;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n }\n });\n },\n scrollToBottomOnce() {\n clearTimeout(this._scrollTimer);\n this.$nextTick(() => {\n const tryScroll = () => {\n const el = this.$refs.messagesRef;\n if (!el) {\n this._scrollTimer = setTimeout(tryScroll, 50);\n return;\n }\n const originalScrollBehavior = el.style.scrollBehavior;\n el.style.scrollBehavior = 'auto';\n el.scrollTop = el.scrollHeight - el.clientHeight;\n el.style.scrollBehavior = originalScrollBehavior;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n };\n tryScroll();\n });\n },\n copyText(content) {\n if (!content) return;\n const text = content.replace(/<[^>]+>/g, '');\n\n // 优先使用 Clipboard API(HTTPS 环境)\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(() => {\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n this._fallbackCopyText(text);\n });\n } else {\n this._fallbackCopyText(text);\n }\n },\n _fallbackCopyText(text) {\n try {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';\n document.body.appendChild(ta);\n ta.focus();\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n } catch {\n this.$message({\n message: '复制失败',\n type: 'error',\n duration: 2000\n });\n }\n },\n copyAsImage(content) {\n if (!content) return;\n const div = document.createElement('div');\n div.className = 'copy-image-render';\n div.style.cssText = 'position:fixed;left:-9999px;top:0;width:600px;padding:20px;background:#fff;font-size:14px;line-height:1.6;color:#333;border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;';\n // 内联表格样式(因 Vue 作用域样式不覆盖复制用的脱离文档流 div)\n div.innerHTML = this.renderMarkdown(content) + '<style>table{border-collapse:collapse;width:100%;margin:6px 0;font-size:14px}' + 'th,td{border:1px solid #e8e8e8;padding:5px 8px;text-align:left}' + 'th{background:#fafafa;font-weight:600}</style>';\n document.body.appendChild(div);\n this.$message({\n message: '正在生成图片...',\n type: 'info',\n duration: 2000\n });\n (0, _html2canvas.default)(div, {\n scale: 2,\n useCORS: true,\n backgroundColor: '#ffffff'\n }).then(canvas => {\n // 检测是否支持 ClipboardItem(部分 WebView 不支持)\n const canCopyImage = typeof ClipboardItem !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.write === 'function';\n if (canCopyImage) {\n canvas.toBlob(blob => {\n if (blob) {\n navigator.clipboard.write([new ClipboardItem({\n 'image/png': blob\n })]).then(() => {\n this.$message({\n message: '图片已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n // ClipboardItem 存在但写入失败 → 降级下载\n this._downloadCanvas(canvas);\n });\n }\n }, 'image/png');\n } else {\n // 不支持剪贴板写图片 → 直接下载\n this._downloadCanvas(canvas);\n }\n }).catch(() => {\n this.$message({\n message: '生成图片失败',\n type: 'error',\n duration: 2000\n });\n }).finally(() => {\n document.body.removeChild(div);\n });\n },\n _downloadCanvas(canvas) {\n const link = document.createElement('a');\n link.download = '消息图片.png';\n link.href = canvas.toDataURL('image/png');\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n this.$message({\n message: '已保存图片到本地',\n type: 'success',\n duration: 2000\n });\n }\n }\n};\n\n//# sourceURL=webpack://ZjPluginIntelligent/./src/lib/components/chatMain/ChatDetail.vue?./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--13-0!./node_modules/_babel-loader@8.4.1@babel-loader/lib!./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--1-0!./node_modules/_vue-loader@15.11.1@vue-loader/lib??vue-loader-options");
|
|
963
|
+
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.detached.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.detached.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer-to-fixed-length.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer-to-fixed-length.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.find.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.find.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.for-each.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.for-each.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.at.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.at.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last-index.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last-index.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.set.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.set.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-reversed.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-reversed.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-sorted.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-sorted.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.with.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.with.js\");\nvar _markdownIt = _interopRequireDefault(__webpack_require__(/*! markdown-it */ \"./node_modules/_markdown-it@14.1.0@markdown-it/index.mjs\"));\nvar _apichat = __webpack_require__(/*! @lib/api/apichat */ \"./src/lib/api/apichat.js\");\nvar _index = __webpack_require__(/*! @lib/socket/fileManager/obs/index */ \"./src/lib/socket/fileManager/obs/index.js\");\nvar _index2 = _interopRequireDefault(__webpack_require__(/*! @lib/socket/utils/index */ \"./src/lib/socket/utils/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _apiObs = _interopRequireDefault(__webpack_require__(/*! @lib/api/apiObs */ \"./src/lib/api/apiObs.js\"));\nvar _html2canvas = _interopRequireDefault(__webpack_require__(/*! html2canvas */ \"./node_modules/_html2canvas@1.4.1@html2canvas/dist/html2canvas.js\"));\nconst md = new _markdownIt.default({\n breaks: true,\n html: false,\n linkify: true,\n typographer: true\n});\nmd.enable('table');\nvar _default = exports.default = {\n name: 'ChatDetail',\n props: {\n isMobile: {\n type: Boolean,\n default: false\n },\n activeSessionId: {\n type: String,\n default: ''\n },\n currentAgent: {\n type: String,\n default: ''\n },\n agentInfo: {\n type: Object,\n default: () => ({})\n },\n currentAgentDesc: {\n type: String,\n default: ''\n },\n showSidebar: {\n type: Boolean,\n default: false\n },\n showH5List: {\n type: Boolean,\n default: false\n },\n showAgentSelect: {\n type: Boolean,\n default: false\n },\n agentList: {\n type: Array,\n default: () => []\n }\n },\n data() {\n return {\n messages: [],\n inputText: '',\n sending: false,\n waitTimer: null,\n waitSeconds: 0,\n canceling: false,\n msgPage: 1,\n msgMaxPage: 1,\n msgTotal: 0,\n eventSource: null,\n showToolInfo: false,\n showDownBtn: false,\n lastScrollTop: 0,\n scrollTimer: null,\n contentCache: {},\n loadingSessionMsg: false,\n greeting: '',\n _scrollTimer: null,\n _userScrolledUp: false,\n voiceMode: false,\n isRecording: false,\n recordDuration: 0,\n recordingTimer: null,\n audioContext: null,\n audioStream: null,\n audioProcessor: null,\n audioChunks: [],\n audioSampleRate: 16000,\n isCancelSwipe: false,\n recordStartY: 0,\n _pendingSendText: null\n };\n },\n computed: {\n waitClass() {\n if (this.waitSeconds > 15) return 'wait-15';\n if (this.waitSeconds > 10) return 'wait-10';\n if (this.waitSeconds > 5) return 'wait-5';\n return '';\n },\n currentAgentName() {\n if (!this.currentAgent) return '';\n const a = this.agentList.find(x => x.agentCode === this.currentAgent);\n return a ? a.agentName || a.agentCode : this.currentAgent;\n }\n },\n watch: {\n contentCache: {\n handler(val) {\n // 保持响应性\n },\n immediate: true\n },\n activeSessionId(val) {\n if (val && this._pendingSendText) {\n const text = this._pendingSendText;\n this._pendingSendText = null;\n this.$nextTick(() => this._sendMessage(text));\n }\n }\n },\n mounted() {\n if (window.visualViewport) {\n window.visualViewport.addEventListener('resize', this.onViewportResize, {\n passive: true\n });\n }\n },\n beforeDestroy() {\n if (window.visualViewport) {\n window.visualViewport.removeEventListener('resize', this.onViewportResize);\n }\n this.releaseAudio();\n this.contentCache = {};\n clearTimeout(this._scrollTimer);\n if (this.eventSource) {\n this.eventSource.onerror = null;\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n if (this._typeTimer) {\n clearInterval(this._typeTimer);\n this._typeTimer = null;\n }\n },\n methods: {\n getInitial(name) {\n if (!name) return '?';\n return name.charAt(0);\n },\n formatTime(t) {\n if (!t) return '';\n const d = new Date(t.replace(' ', 'T'));\n const now = new Date();\n const diff = (now - d) / 1000;\n if (diff < 60) return '刚刚';\n if (diff < 3600) return Math.floor(diff / 60) + '分钟前';\n if (diff < 86400) return Math.floor(diff / 3600) + '小时前';\n return d.getMonth() + 1 + '/' + d.getDate();\n },\n escapeHtml(text) {\n if (!text) return '';\n return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\n },\n renderMarkdown(text) {\n if (!text) return '';\n try {\n return md.render(text);\n } catch (err) {\n return this.escapeHtml(text);\n }\n },\n renderStreaming(text, msgKey) {\n if (!text) return '';\n const cache = this.contentCache[msgKey];\n const lines = text.split('\\n');\n const lastLineIdx = lines.length - 1;\n if (!cache) {\n try {\n const html = md.render(text);\n this.$set(this.contentCache, msgKey, {\n renderedLines: lastLineIdx,\n html,\n fullText: text\n });\n return html;\n } catch {\n return this.escapeHtml(text);\n }\n }\n if (text === cache.fullText) return cache.html;\n cache.fullText = text;\n\n // 行数没变,仅末尾追加字符,直接拼接尾行\n if (lastLineIdx === cache.renderedLines) {\n return cache.html + this.escapeHtml(lines[lastLineIdx]);\n }\n\n // 场景1:行数变少/文本回退截断,全量重渲染\n if (lastLineIdx < cache.renderedLines) {\n try {\n const fullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (fullLines.length > 0) {\n newBaseHtml = md.render(fullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n return cache.html + (lastLine || '');\n } catch {\n return this.escapeHtml(text);\n }\n }\n\n // 场景2:新增完整行\n if (cache.renderedLines < lastLineIdx) {\n const allFullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (allFullLines.length > 0) {\n newBaseHtml = md.render(allFullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n }\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n cache.renderedLines = lastLineIdx;\n return cache.html + (lastLine || '');\n },\n getChatAvatarStyle(agent, avatar) {\n if (avatar) {\n return {\n backgroundImage: `url(${avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n const _agent = this.agentList.find(x => x.agentCode === agent);\n if (_agent && _agent.avatar) {\n return {\n backgroundImage: `url(${_agent.avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n // 没有网络图片时,根据智能体在 agentList 中的索引位置决定颜色\n const index = this.agentList.findIndex(item => item.agentCode === agent);\n if (index !== -1) {\n const colorIndex = index % 4 + 1;\n return {\n background: this.getGradientByColorIndex(colorIndex)\n };\n }\n return {\n background: 'linear-gradient(135deg, #1677ff, #4096ff)'\n };\n },\n getGradientByColorIndex(index) {\n const gradients = {\n 1: 'linear-gradient(135deg, #1677ff, #4096ff)',\n 2: 'linear-gradient(135deg, #52c41a, #73d13d)',\n 3: 'linear-gradient(135deg, #fa8c16, #ffa940)',\n 4: 'linear-gradient(135deg, #722ed1, #b37feb)'\n };\n return gradients[index] || gradients[1];\n },\n sendQuickMsg(message) {\n if (message) {\n this.inputText = message;\n this.sendMessage();\n }\n },\n formatMixText(str) {\n if (!str) return '';\n let res = str.replace(/([a-zA-Z0-9])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z0-9])/g, '$1 $2');\n res = res.replace(/([a-z])([A-Z])/g, '$1 $2');\n return res;\n },\n // 检测是否滚动到底部,控制向下箭头显示隐藏\n checkScrollBottom(forceShowWhenNotBottom = false) {\n const el = this.$refs.messagesRef;\n if (!el) {\n this.showDownBtn = false;\n return;\n }\n const {\n scrollHeight,\n scrollTop,\n clientHeight\n } = el;\n const remain = scrollHeight - scrollTop - clientHeight;\n const atBottom = remain <= 80;\n\n // 检测用户是否主动向上滚动(远离底部)\n if (remain > 80 && scrollTop < this.lastScrollTop) {\n this._userScrolledUp = true;\n }\n if (atBottom) {\n this._userScrolledUp = false;\n this.showDownBtn = false;\n } else if (this._userScrolledUp) {\n // 用户已主动上滚,保持显示\"到底部\"按钮,不自动回滚\n this.showDownBtn = true;\n } else if (this.sending && scrollTop >= this.lastScrollTop && !forceShowWhenNotBottom) {\n this.showDownBtn = false;\n } else {\n this.showDownBtn = true;\n }\n this.lastScrollTop = scrollTop;\n this.$emit('scroll-bottom-check', {\n showDownBtn: this.showDownBtn,\n lastScrollTop: this.lastScrollTop\n });\n },\n // H5 输入框获取焦点 - 滚动到底部让键盘不遮挡\n onInputFocus() {\n if (!this.isMobile) return;\n setTimeout(() => {\n window.scrollTo(0, document.body.scrollHeight);\n }, 300);\n },\n // visualViewport 变化时(如键盘弹出)调整滚动位置\n onViewportResize() {\n if (!this.isMobile) return;\n const activeEl = document.activeElement;\n if (activeEl && (activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'INPUT')) {\n window.scrollTo(0, document.body.scrollHeight);\n }\n },\n autoResizeTextarea() {\n const ta = this.$refs.taRef;\n if (!ta) return;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n const maxH = isMobile ? 100 : 180;\n ta.style.height = `${baseH}px`;\n const realH = ta.scrollHeight;\n if (realH > baseH) {\n ta.style.height = `${Math.min(realH, maxH)}px`;\n }\n this.$nextTick(() => {\n this.checkScrollBottom(true);\n });\n },\n // Ctrl+Enter换行\n newLine(e) {\n e.preventDefault();\n const ta = this.$refs.taRef;\n const start = ta.selectionStart;\n const end = ta.selectionEnd;\n this.inputText = this.inputText.slice(0, start) + '\\n' + this.inputText.slice(end);\n this.$nextTick(() => {\n ta.selectionStart = ta.selectionEnd = start + 1;\n this.autoResizeTextarea();\n });\n },\n toggleVoiceMode() {\n if (this.voiceMode) {\n this.voiceMode = false;\n this.releaseAudio();\n return;\n }\n // 进入语音模式前提前请求麦克风权限,避免触摸时异步弹窗导致 touchend 丢失\n this.ensureAudioReady().then(ready => {\n if (ready) {\n this.voiceMode = true;\n } else {\n this.voiceMode = false;\n }\n });\n },\n // 确保音频设备已就绪\n async ensureAudioReady() {\n if (this.mediaRecorder && this.audioStream) return true;\n if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {\n alert('当前环境不支持麦克风访问');\n return false;\n }\n if (!window.MediaRecorder) {\n alert('当前浏览器不支持录音功能');\n return false;\n }\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: true\n });\n // ========== 新增:校验音频轨道是否有效(iOS兼容) ==========\n const audioTracks = stream.getAudioTracks();\n if (audioTracks.length === 0 || audioTracks[0].readyState !== 'live') {\n alert('麦克风未正常激活,请重新授权');\n stream.getTracks().forEach(t => t.stop());\n return false;\n }\n this.audioStream = stream;\n\n // 检测平台支持的 MIME 类型\n const mimeType = this._getSupportedMimeType();\n const options = mimeType ? {\n mimeType\n } : {};\n const recorder = new MediaRecorder(stream, options);\n recorder.ondataavailable = e => {\n if (e.data && e.data.size > 0) {\n this.recordedChunks.push(e.data);\n }\n };\n recorder.onstop = () => {\n this._onMediaRecorderStop();\n };\n recorder.onerror = () => {\n console.error('MediaRecorder error');\n this.recordedChunks = [];\n };\n this.mediaRecorder = recorder;\n\n // ========== 改动:AudioContext全局单例,不重复new ==========\n if (!this.audioContext) {\n this.audioContext = new (window.AudioContext || window.webkitAudioContext)();\n }\n if (this.audioContext.state === 'suspended') {\n await this.audioContext.resume();\n }\n return true;\n } catch (err) {\n console.error('麦克风访问失败:', err);\n alert('麦克风权限被拒绝');\n return false;\n }\n },\n _onMediaRecorderStop() {\n if (this.recordedChunks.length === 0) {\n console.warn('录音数据为空');\n return;\n }\n const blob = new Blob(this.recordedChunks, {\n type: this.mediaRecorder.mimeType || 'audio/mp4'\n });\n this.recordedChunks = [];\n\n // 解码压缩音频为 PCM\n const reader = new FileReader();\n reader.onload = () => {\n const arrayBuffer = reader.result;\n this.audioContext.decodeAudioData(arrayBuffer, audioBuffer => {\n // 取第一个声道的数据\n const channelData = audioBuffer.getChannelData(0);\n const wavBlob = this._encodePCMToWavFromBuffer(channelData, audioBuffer.sampleRate);\n this.sendAudioBlob(wavBlob);\n }, err => {\n // ========== 新增:iOS解码失败兜底,直接上传原始mp4 blob ==========\n console.error('decodeAudioData 失败,降级上传原始音频:', err);\n this.sendAudioBlob(blob);\n });\n };\n reader.onerror = () => {\n console.error('FileReader 读取失败,降级上传原始音频');\n this.sendAudioBlob(blob);\n };\n reader.readAsArrayBuffer(blob);\n },\n _encodePCMToWavFromBuffer(channelData, sampleRate) {\n const numChannels = 1;\n const bitsPerSample = 16;\n const byteRate = sampleRate * numChannels * bitsPerSample / 8;\n const blockAlign = numChannels * bitsPerSample / 8;\n const dataSize = channelData.length * (bitsPerSample / 8);\n const headerSize = 44;\n const totalSize = headerSize + dataSize;\n const buffer = new ArrayBuffer(totalSize);\n const view = new DataView(buffer);\n this._wavWriteString(view, 0, 'RIFF');\n view.setUint32(4, totalSize - 8, true);\n this._wavWriteString(view, 8, 'WAVE');\n this._wavWriteString(view, 12, 'fmt ');\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true); // PCM\n view.setUint16(22, numChannels, true);\n view.setUint32(24, sampleRate, true);\n view.setUint32(28, byteRate, true);\n view.setUint16(32, blockAlign, true);\n view.setUint16(34, bitsPerSample, true);\n this._wavWriteString(view, 36, 'data');\n view.setUint32(40, dataSize, true);\n const pcmView = new Int16Array(buffer, headerSize, channelData.length);\n for (let i = 0; i < channelData.length; i++) {\n const s = Math.max(-1, Math.min(1, channelData[i]));\n pcmView[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;\n }\n return new Blob([buffer], {\n type: 'audio/wav'\n });\n },\n // 检测支持的 MIME 类型(改动:优先mp4,兜底mp4防止空)\n _getSupportedMimeType() {\n const types = ['audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'];\n for (const t of types) {\n if (MediaRecorder.isTypeSupported(t)) return t;\n }\n // iOS兜底强制mp4\n return 'audio/mp4;codecs=mp4a.40.2';\n },\n // H5语音录制 - 触摸开始\n async onRecordTouchStart(e) {\n if (this.sending) return;\n const ready = await this.ensureAudioReady();\n if (!ready) return;\n this.isCancelSwipe = false;\n this.recordStartY = e.touches[0].clientY;\n this.recordDuration = 0;\n this.recordedChunks = [];\n this.isRecording = true;\n // ========== 核心修复:增加1000ms分片,iOS必须带timeslice ==========\n this.mediaRecorder.start(1000);\n this.recordingTimer = setInterval(() => {\n this.recordDuration++;\n if (this.recordDuration >= 60) {\n this.onRecordTouchEnd();\n }\n }, 1000);\n },\n // H5语音录制 - 触摸移动\n onRecordTouchMove(e) {\n if (!this.isRecording) return;\n const touch = e.touches[0];\n const moveY = touch.clientY;\n const cancelThreshold = 60;\n this.isCancelSwipe = this.recordStartY - moveY > cancelThreshold;\n },\n // H5语音录制 - 触摸结束\n // H5语音录制 - 触摸结束\n onRecordTouchEnd() {\n if (!this.isRecording) return;\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n // 上滑取消:丢弃录音数据\n if (this.isCancelSwipe) {\n this.recordedChunks = [];\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n return;\n }\n // 正常发送:停止 MediaRecorder → 触发 onstop → _onMediaRecorderStop → 解码 → 发送\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n },\n // 停止音频采集并输出 WAV\n stopAudioCapture(shouldSend) {\n this.isRecording = false;\n if (shouldSend && this.audioChunks.length > 0) {\n const wavBlob = this.encodePCMToWav(this.audioChunks, this.audioSampleRate || 16000);\n this.sendAudioBlob(wavBlob);\n }\n this.audioChunks = [];\n },\n // 取消录音\n cancelRecording() {\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n this.recordedChunks = [];\n this.recordDuration = 0;\n this.isCancelSwipe = false;\n },\n // 释放音频资源\n releaseAudio() {\n this.cancelRecording();\n if (this.mediaRecorder) {\n if (this.mediaRecorder.state !== 'inactive') {\n try {\n this.mediaRecorder.stop();\n } catch (e) {}\n }\n this.mediaRecorder = null;\n }\n if (this.audioSource) {\n this.audioSource.disconnect();\n this.audioSource = null;\n }\n // 只关闭上下文,不清空实例,复用单例\n if (this.audioContext) {\n this.audioContext.close().catch(() => {});\n }\n if (this.audioStream) {\n this.audioStream.getTracks().forEach(t => t.stop());\n this.audioStream = null;\n }\n this.recordedChunks = [];\n this.voiceMode = false;\n },\n // 将 Float32 PCM 数据编码为 WAV Blob\n encodePCMToWav(chunks, sampleRate) {\n let totalLength = 0;\n for (const chunk of chunks) {\n totalLength += chunk.length;\n }\n const pcmData = new Int16Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n for (let i = 0; i < chunk.length; i++) {\n const s = Math.max(-1, Math.min(1, chunk[i]));\n pcmData[offset++] = s < 0 ? s * 0x8000 : s * 0x7FFF;\n }\n }\n const numChannels = 1;\n const bitsPerSample = 16;\n const byteRate = sampleRate * numChannels * bitsPerSample / 8;\n const blockAlign = numChannels * bitsPerSample / 8;\n const dataSize = pcmData.length * (bitsPerSample / 8);\n const headerSize = 44;\n const totalSize = headerSize + dataSize;\n const buffer = new ArrayBuffer(totalSize);\n const view = new DataView(buffer);\n this._wavWriteString(view, 0, 'RIFF');\n view.setUint32(4, totalSize - 8, true);\n this._wavWriteString(view, 8, 'WAVE');\n this._wavWriteString(view, 12, 'fmt ');\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true);\n view.setUint16(22, numChannels, true);\n view.setUint32(24, sampleRate, true);\n view.setUint32(28, byteRate, true);\n view.setUint16(32, blockAlign, true);\n view.setUint16(34, bitsPerSample, true);\n this._wavWriteString(view, 36, 'data');\n view.setUint32(40, dataSize, true);\n const pcmView = new Int16Array(buffer, headerSize, pcmData.length);\n pcmView.set(pcmData);\n return new Blob([buffer], {\n type: 'audio/wav'\n });\n },\n _wavWriteString(view, offset, str) {\n for (let i = 0; i < str.length; i++) {\n view.setUint8(offset + i, str.charCodeAt(i));\n }\n },\n // 发送音频\n // 发送音频\n sendAudioBlob(blob) {\n let appSeqId = _index2.default.getAppSeqId();\n let name = '';\n // 区分格式命名\n if (blob.type === 'audio/wav') {\n name = appSeqId + '.wav';\n } else {\n name = appSeqId + '.mp4';\n }\n const file = new File([blob], name, {\n type: blob.type\n });\n this.sendAudioMessage(file);\n },\n sendAudioMessage(file) {\n const sttKey = 'stt_' + Date.now();\n const sttMsg = {\n role: 'user',\n content: '转化中...',\n _sttStatus: 'pending',\n _sttKey: sttKey,\n createTime: new Date().toISOString(),\n _key: sttKey\n };\n this.messages.push(sttMsg);\n this.scrollToBottom(true);\n (0, _index.fileUploadToObs)({\n _file: file,\n appSeqId: file.name\n }).then(resp => {\n if (resp.CommonMsg.Status === 200) {\n return _apiObs.default.aliAudioToText({\n url: resp.obs_upload_data.obsUrl\n }).then(result => {\n if (result.code === '200' && result.data) {\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.messages.splice(idx, 1);\n }\n this.sendQuickMsg(result.data);\n } else {\n throw new Error(result.msg || '语音转文字失败');\n }\n });\n } else {\n throw new Error(resp.CommonMsg.Message || '文件上传失败');\n }\n }).catch(err => {\n console.error('语音转文字失败:', err);\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.$set(this.messages, idx, {\n ...this.messages[idx],\n _sttStatus: 'error',\n content: '语音转文字失败'\n });\n }\n this.$message.error('语音转文字失败');\n });\n this.$message.success(`录音完成 (${this.recordDuration}s)`);\n },\n clearWaitTimer() {\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n },\n loadMoreMessages() {\n const nextPage = this.msgPage + 1;\n const maxPage = Math.ceil(this.msgTotal / 10);\n if (nextPage > maxPage) {\n this.loadingMore = false;\n return;\n }\n (0, _apichat.getSessionMessages)(this.activeSessionId, nextPage, 10).then(res => {\n const list = Array.isArray(res) ? res : res.list || [];\n this.msgPage = nextPage;\n const asAsc = (list || []).slice().reverse();\n const prepend = asAsc.map(m => ({\n role: m.role,\n content: m.content || '',\n thinking: m.thinking || '',\n toolCalls: [],\n agentName: '',\n createTime: m.createTime || null,\n _key: m.createTime ? 'msg_' + m.createTime : 'msg_' + Date.now() + '_' + Math.random()\n }));\n const container = this.$refs.messagesRef;\n const oldScrollHeight = container ? container.scrollHeight : 0;\n const oldScrollTop = container ? container.scrollTop : 0;\n this.messages = [...prepend, ...this.messages];\n this.$nextTick(() => {\n if (container) {\n container.scrollTop = oldScrollTop + (container.scrollHeight - oldScrollHeight);\n }\n this.loadingMore = false;\n });\n }).catch(() => {\n this.loadingMore = false;\n });\n },\n onScrollMessages() {\n const container = this.$refs.messagesRef;\n if (!container || this.loadingMore) return;\n const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 100;\n this.checkScrollBottom();\n if (isAtBottom) return;\n if (container.scrollTop < 50 && this.msgPage < Math.ceil(this.msgTotal / 10)) {\n this.loadingMore = true;\n this.loadMoreMessages();\n }\n },\n async sendMessage() {\n const text = this.inputText.trim();\n if (!text || this.sending) return;\n if (text.length > 300) {\n this.$message({\n message: '消息内容不能超过300字',\n type: 'warning',\n duration: 2000\n });\n return;\n }\n if (!this.activeSessionId) {\n this.$emit('start-session');\n this._pendingSendText = text;\n return;\n }\n this._sendMessage(text);\n },\n async _sendMessage(text) {\n this.inputText = '';\n const ta = this.$refs.taRef;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n if (ta) {\n ta.style.height = baseH + 'px';\n }\n this.showDownBtn = false;\n this.messages.push({\n role: 'user',\n content: text,\n createTime: new Date().toISOString(),\n _key: 'user_' + Date.now() + '_' + Math.random()\n });\n const agentName = this.currentAgentName;\n const msgIdx = this.messages.push({\n role: \"assistant\",\n content: \"\",\n thinking: \"\",\n loading: true,\n toolCalls: [],\n agentName,\n createTime: new Date().toISOString(),\n _key: 'asst_' + Date.now() + '_' + Math.random()\n }) - 1;\n this.scrollToBottom(true);\n this.sending = true;\n this.waitSeconds = 0;\n if (this.waitTimer) clearInterval(this.waitTimer);\n this.waitTimer = setInterval(() => {\n this.waitSeconds++;\n }, 1000);\n let thinkingBuffer = '';\n let finalContent = '';\n let answerBuffer = '';\n let thinkDisplayed = 0;\n let typeTimer = null;\n let streamDone = false;\n typeTimer = setInterval(() => {\n if (!this.messages[msgIdx]) return;\n\n // Phase 1: 逐字展示思考内容(不 return,让 Phase 2 也能同时执行)\n if (thinkDisplayed < thinkingBuffer.length) {\n thinkDisplayed += 50;\n if (thinkDisplayed > thinkingBuffer.length) thinkDisplayed = thinkingBuffer.length;\n this.messages[msgIdx].thinking = thinkingBuffer.substring(0, thinkDisplayed);\n this.scrollToBottom();\n // 不 return,让 Phase 2 也能执行,避免 iOS 上思考内容过长阻塞回答内容展示\n }\n\n // Phase 2: 逐行展示(已完成的行整行出现,未完成的行也即时显示)\n const target = finalContent || answerBuffer;\n const msg = this.messages[msgIdx];\n const revealedLen = msg._revealedLen || 0;\n if (target.length > revealedLen) {\n const remaining = target.substring(revealedLen);\n const nextNewline = remaining.indexOf('\\n');\n if (nextNewline !== -1) {\n // 新的一行已完成 → 整行出现\n const endPos = revealedLen + nextNewline + 1;\n msg._revealedLen = endPos;\n msg.content = target.substring(0, endPos);\n this.scrollToBottom();\n return;\n }\n\n // 当前行未完成 → 逐步展示(每次 30 字符),确保 iOS 上也有渐进效果\n const revealStep = 30;\n const newRevealedLen = Math.min(revealedLen + revealStep, target.length);\n msg._revealedLen = newRevealedLen;\n msg.content = target.substring(0, newRevealedLen);\n this.scrollToBottom();\n return;\n }\n\n // Phase 3: 流已结束且内容已全部展示 → 收尾\n if (streamDone) {\n clearInterval(typeTimer);\n const key = this.messages[msgIdx]._key;\n if (key) {\n this.$delete(this.contentCache, key);\n this.$delete(this.contentCache, key + '_t');\n }\n typeTimer = null;\n this.messages[msgIdx].content = finalContent;\n this.messages[msgIdx].thinking = thinkingBuffer;\n this.messages[msgIdx].loading = false;\n this.sending = false;\n this.clearWaitTimer();\n this.scrollToBottom();\n this.$emit('session-refresh');\n this.$emit('message-sent');\n }\n }, 30);\n const url = (0, _apichat.createStreamUrl)(this.activeSessionId, text);\n if (this.eventSource) {\n this.eventSource.close();\n }\n this.eventSource = new EventSource(url);\n const es = this.eventSource;\n es.addEventListener('reasoning', e => {\n if (e.data) {\n const chunk = e.data;\n let cleanChunk = chunk.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2').replace(/\\s+([:,.!?])/g, '$1').replace(/([:,.!?])\\s+/g, '$1 ');\n cleanChunk = cleanChunk.replace(/\\s+/g, ' ');\n const lastChar = thinkingBuffer.slice(-1);\n const firstChar = cleanChunk.slice(0, 1);\n const isLetter = c => /[a-zA-Z]/.test(c);\n if (isLetter(lastChar) && isLetter(firstChar)) {\n thinkingBuffer += ' ' + cleanChunk;\n } else {\n thinkingBuffer += cleanChunk;\n }\n }\n });\n es.addEventListener('reasoning_result', e => {\n if (e.data) {\n thinkingBuffer = e.data;\n }\n });\n es.addEventListener('answer', e => {\n if (e.data) {\n answerBuffer += e.data.replace(/([a-zA-Z]+)([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z]+)/g, '$1 $2').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2');\n }\n });\n es.addEventListener('tool_result', e => {\n if (e.data && this.messages[msgIdx]) {\n if (!this.messages[msgIdx].toolCalls) this.messages[msgIdx].toolCalls = [];\n this.messages[msgIdx].toolCalls.push(e.data);\n this.scrollToBottom();\n }\n });\n es.addEventListener('agent_result', e => {\n finalContent = e.data || '';\n streamDone = true;\n es.close();\n this.eventSource = null;\n });\n es.onerror = err => {\n console.log('[SSE] onerror', err);\n if (thinkingBuffer.length > 0 && !streamDone) finalContent = thinkingBuffer;\n if (!streamDone) finalContent = '❌ 连接中断';\n streamDone = true;\n es.close();\n this.eventSource = null;\n };\n },\n stopAgent() {\n if (this.eventSource) {\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.activeSessionId) {\n (0, _apichat.stopStream)(this.activeSessionId).catch(() => {});\n }\n if (this.messages.length > 0) {\n const last = this.messages[this.messages.length - 1];\n if (last && last.loading) {\n last.content = last.content || last.thinking || '⏹ 已停止';\n last.loading = false;\n this.$delete(this.contentCache, last._key);\n }\n }\n this.sending = false;\n this.clearWaitTimer();\n },\n scrollToTop() {\n const el = this.$refs.messagesRef;\n if (el) el.scrollTop = 0;\n },\n scrollToBottom(force = false) {\n if (this.showDownBtn && !force) return;\n this.$nextTick(() => {\n const el = this.$refs.messagesRef;\n if (el) {\n el.scrollTop = el.scrollHeight;\n this.lastScrollTop = el.scrollTop;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n }\n });\n },\n scrollToBottomOnce() {\n clearTimeout(this._scrollTimer);\n this.$nextTick(() => {\n const tryScroll = () => {\n const el = this.$refs.messagesRef;\n if (!el) {\n this._scrollTimer = setTimeout(tryScroll, 50);\n return;\n }\n const originalScrollBehavior = el.style.scrollBehavior;\n el.style.scrollBehavior = 'auto';\n el.scrollTop = el.scrollHeight - el.clientHeight;\n el.style.scrollBehavior = originalScrollBehavior;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n };\n tryScroll();\n });\n },\n copyText(content) {\n if (!content) return;\n const text = content.replace(/<[^>]+>/g, '');\n\n // 优先使用 Clipboard API(HTTPS 环境)\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(() => {\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n this._fallbackCopyText(text);\n });\n } else {\n this._fallbackCopyText(text);\n }\n },\n _fallbackCopyText(text) {\n try {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';\n document.body.appendChild(ta);\n ta.focus();\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n } catch {\n this.$message({\n message: '复制失败',\n type: 'error',\n duration: 2000\n });\n }\n },\n copyAsImage(content) {\n if (!content) return;\n const div = document.createElement('div');\n div.className = 'copy-image-render';\n div.style.cssText = 'position:fixed;left:-9999px;top:0;width:600px;padding:20px;background:#fff;font-size:14px;line-height:1.6;color:#333;border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;';\n // 内联表格样式(因 Vue 作用域样式不覆盖复制用的脱离文档流 div)\n div.innerHTML = this.renderMarkdown(content) + '<style>table{border-collapse:collapse;width:100%;margin:6px 0;font-size:14px}' + 'th,td{border:1px solid #e8e8e8;padding:5px 8px;text-align:left}' + 'th{background:#fafafa;font-weight:600}</style>';\n document.body.appendChild(div);\n this.$message({\n message: '正在生成图片...',\n type: 'info',\n duration: 2000\n });\n (0, _html2canvas.default)(div, {\n scale: 2,\n useCORS: true,\n backgroundColor: '#ffffff'\n }).then(canvas => {\n // 检测是否支持 ClipboardItem(部分 WebView 不支持)\n const canCopyImage = typeof ClipboardItem !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.write === 'function';\n if (canCopyImage) {\n canvas.toBlob(blob => {\n if (blob) {\n navigator.clipboard.write([new ClipboardItem({\n 'image/png': blob\n })]).then(() => {\n this.$message({\n message: '图片已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n // ClipboardItem 存在但写入失败 → 降级下载\n this._downloadCanvas(canvas);\n });\n }\n }, 'image/png');\n } else {\n // 不支持剪贴板写图片 → 直接下载\n this._downloadCanvas(canvas);\n }\n }).catch(() => {\n this.$message({\n message: '生成图片失败',\n type: 'error',\n duration: 2000\n });\n }).finally(() => {\n document.body.removeChild(div);\n });\n },\n _downloadCanvas(canvas) {\n const link = document.createElement('a');\n link.download = '消息图片.png';\n link.href = canvas.toDataURL('image/png');\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n this.$message({\n message: '已保存图片到本地',\n type: 'success',\n duration: 2000\n });\n }\n }\n};\n\n//# sourceURL=webpack://ZjPluginIntelligent/./src/lib/components/chatMain/ChatDetail.vue?./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--13-0!./node_modules/_babel-loader@8.4.1@babel-loader/lib!./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--1-0!./node_modules/_vue-loader@15.11.1@vue-loader/lib??vue-loader-options");
|
|
964
964
|
|
|
965
965
|
/***/ }),
|
|
966
966
|
|
|
@@ -12617,7 +12617,7 @@ eval("module.exports = function(originalModule) {\n\tif (!originalModule.webpack
|
|
|
12617
12617
|
/*! exports provided: name, version, main, private, scripts, dependencies, devDependencies, browserslist, directories, keywords, license, default */
|
|
12618
12618
|
/***/ (function(module) {
|
|
12619
12619
|
|
|
12620
|
-
eval("module.exports = JSON.parse(\"{\\\"name\\\":\\\"zj-plugin-intelligent\\\",\\\"version\\\":\\\"1.2.5-beta.
|
|
12620
|
+
eval("module.exports = JSON.parse(\"{\\\"name\\\":\\\"zj-plugin-intelligent\\\",\\\"version\\\":\\\"1.2.5-beta.30\\\",\\\"main\\\":\\\"lib/ZjPluginIntelligent.umd.min.js\\\",\\\"private\\\":false,\\\"scripts\\\":{\\\"serve\\\":\\\"vue-cli-service serve\\\",\\\"build\\\":\\\"vue-cli-service build\\\",\\\"lib\\\":\\\"vue-cli-service build --target lib --name ZjPluginIntelligent --dest lib src/lib/index.js\\\",\\\"lib:test\\\":\\\"vue-cli-service build --mode test --target lib --name ZjPluginIntelligent --dest lib src/lib/index.js\\\"},\\\"dependencies\\\":{\\\"html2canvas\\\":\\\"^1.4.1\\\",\\\"markdown-it\\\":\\\"^14.1.0\\\"},\\\"devDependencies\\\":{\\\"@babel/preset-env\\\":\\\"^7.14.9\\\",\\\"@vue/cli-plugin-babel\\\":\\\"~4.5.0\\\",\\\"@vue/cli-plugin-eslint\\\":\\\"^4.5.13\\\",\\\"@vue/cli-plugin-vuex\\\":\\\"~4.5.0\\\",\\\"@vue/cli-service\\\":\\\"~4.5.0\\\",\\\"axios\\\":\\\"^0.27.2\\\",\\\"babel-eslint\\\":\\\"^10.1.0\\\",\\\"babel-plugin-component\\\":\\\"^1.1.1\\\",\\\"compression-webpack-plugin\\\":\\\"^6.0.3\\\",\\\"core-js\\\":\\\"^3.6.5\\\",\\\"element-ui\\\":\\\"^2.15.4\\\",\\\"eslint\\\":\\\"^7.30.0\\\",\\\"eslint-plugin-vue\\\":\\\"^7.13.0\\\",\\\"js-cookie\\\":\\\"^2.2.1\\\",\\\"node-sass\\\":\\\"^4.12.0\\\",\\\"obs-upload\\\":\\\"^1.0.4-alpha.0\\\",\\\"reconnecting-websocket\\\":\\\"^4.4.0\\\",\\\"sass-loader\\\":\\\"^8.0.2\\\",\\\"snowflake-id\\\":\\\"^1.1.0\\\",\\\"vue\\\":\\\"^2.6.11\\\",\\\"vue-template-compiler\\\":\\\"^2.6.11\\\",\\\"vuex\\\":\\\"^3.4.0\\\",\\\"pako\\\":\\\"^2.1.0\\\",\\\"webpack-bundle-analyzer\\\":\\\"^4.4.2\\\"},\\\"browserslist\\\":[\\\"> 1%\\\",\\\"last 2 versions\\\",\\\"not dead\\\"],\\\"directories\\\":{\\\"lib\\\":\\\"lib\\\"},\\\"keywords\\\":[],\\\"license\\\":\\\"ISC\\\"}\");\n\n//# sourceURL=webpack://ZjPluginIntelligent/./package.json?");
|
|
12621
12621
|
|
|
12622
12622
|
/***/ }),
|
|
12623
12623
|
|
|
@@ -13509,7 +13509,7 @@ eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n
|
|
|
13509
13509
|
/***/ (function(module, exports, __webpack_require__) {
|
|
13510
13510
|
|
|
13511
13511
|
"use strict";
|
|
13512
|
-
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\nexports.getAuth = getAuth;\nexports.setAuth = setAuth;\nexports.setBaseURL = setBaseURL;\nexports.setToken = setToken;\nexports.signQueryParams = signQueryParams;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.filter.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.filter.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\nvar _axios = _interopRequireDefault(__webpack_require__(/*! axios */ \"./node_modules/_axios@0.27.2@axios/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _message = __webpack_require__(/*! @lib/utils/message.js */ \"./src/lib/utils/message.js\");\n/*\r\n * @Author: 高瑞廷 2419056691@qq.com\r\n * @Date: 2026-06-04 11:27:13\r\n * @LastEditors: 高瑞廷 2419056691@qq.com\r\n * @LastEditTime: 2026-07-
|
|
13512
|
+
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\nexports.getAuth = getAuth;\nexports.setAuth = setAuth;\nexports.setBaseURL = setBaseURL;\nexports.setToken = setToken;\nexports.signQueryParams = signQueryParams;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.filter.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.filter.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\nvar _axios = _interopRequireDefault(__webpack_require__(/*! axios */ \"./node_modules/_axios@0.27.2@axios/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _message = __webpack_require__(/*! @lib/utils/message.js */ \"./src/lib/utils/message.js\");\n/*\r\n * @Author: 高瑞廷 2419056691@qq.com\r\n * @Date: 2026-06-04 11:27:13\r\n * @LastEditors: 高瑞廷 2419056691@qq.com\r\n * @LastEditTime: 2026-07-16 14:14:10\r\n * @FilePath: \\zjkj-nodejs-npm_customer-client\\src\\lib\\utils\\request.js\r\n * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE\r\n */\n\nlet runtimeAK = Object({\"NODE_ENV\":\"test\",\"VUE_APP_API\":\"https://api.qgbtech.cn\",\"VUE_APP_BASE_API\":\"https://ai.jybtech.cn\",\"VUE_APP_NAME\":\"development\",\"BASE_URL\":\"/\"}).VUE_APP_BASE_AK || '';\nlet runtimeSK = Object({\"NODE_ENV\":\"test\",\"VUE_APP_API\":\"https://api.qgbtech.cn\",\"VUE_APP_BASE_API\":\"https://ai.jybtech.cn\",\"VUE_APP_NAME\":\"development\",\"BASE_URL\":\"/\"}).VUE_APP_BASE_SK || '';\nlet runtimeToken = '';\n\n// let _user = '202007091611352'\nlet _user = '';\n// let _companyNo = '201912241159840'\nlet _companyNo = '';\nlet runtimeBaseURL = \"https://ai.jybtech.cn\" || false;\nconst CryptoJS = __webpack_require__(/*! crypto-js */ \"./node_modules/_crypto-js@4.2.0@crypto-js/index.js\");\nconst service = _axios.default.create({\n baseURL: runtimeBaseURL,\n timeout: 15000\n});\nfunction hmacSha256Base64(rawSignStr, sk) {\n const hmac = CryptoJS.HmacSHA256(rawSignStr, sk);\n // 转为 Base64\n return CryptoJS.enc.Base64.stringify(hmac);\n}\nfunction setAuth(ak, sk, user, companyNo, token) {\n runtimeAK = ak || '';\n runtimeSK = sk || '';\n _user = user;\n _companyNo = companyNo;\n runtimeToken = token || '';\n}\nfunction setToken(token) {\n runtimeToken = token || '';\n}\nfunction getAuth() {\n return {\n ak: runtimeAK,\n sk: runtimeSK,\n token: runtimeToken,\n user: _user,\n companyNo: _companyNo\n };\n}\nfunction setBaseURL(url) {\n runtimeBaseURL = url || '';\n service.defaults.baseURL = runtimeBaseURL;\n}\nfunction signQueryParams(params, secretKey) {\n const keys = Object.keys(params).filter(k => k !== 'ak' && k !== 'sign').sort();\n const raw = keys.map(k => `${k}=${params[k] || ''}`).join('&');\n return hmacSha256Base64(raw, secretKey);\n}\nservice.interceptors.request.use(config => {\n // 流式接口跳过 Header 签名\n if (config.url.includes('/chat/stream')) {\n return config;\n }\n const ts = Date.now().toString();\n const token = runtimeToken || (0, _cookie.getToken)();\n const externalUser = _user || '';\n const signParts = [];\n // 1. 第一位:token(有值才加入)\n if (_companyNo) {\n signParts.push(`companyNo=${_companyNo}`);\n }\n if (token) {\n signParts.push(`token=${token}`);\n }\n // 2. 第二位:user(有值才加入)\n if (externalUser) {\n signParts.push(`user=${externalUser}`);\n }\n // 3. 第三位:ts(必选,永远存在)\n signParts.push(`ts=${ts}`);\n\n // 拼接最终待签字符串\n const signStr = signParts.join('&');\n // 计算签名\n const sign = hmacSha256Base64(signStr, runtimeSK);\n if (_companyNo) {\n config.headers['X-Company-No'] = _companyNo;\n }\n\n // 设置请求头\n config.headers['X-AK'] = runtimeAK;\n config.headers['X-Ts'] = ts;\n config.headers['X-Sign'] = sign;\n if (token) {\n config.headers['X-Auth-Token'] = `${token}`;\n config.headers.Authorization = `Bearer ${token}`;\n }\n config.headers['X-External-User'] = externalUser;\n config.headers['Content-Type'] = 'application/json';\n\n // console.log('排序后key:', sortedKeys)\n\n return config;\n}, error => Promise.reject(error));\nservice.interceptors.response.use(response => {\n const res = response.data;\n if (res.code === 200) {\n return res;\n } else if (res.code === 40101) {\n (0, _message.showMessage)('登录已过期,请重新登录');\n return Promise.reject(new Error(res.message));\n } else {\n (0, _message.showMessage)(res.message || '请求失败');\n return Promise.reject(new Error(res.message));\n }\n}, error => {\n (0, _message.showMessage)(error.message || '网络异常');\n return Promise.reject(error);\n});\nvar _default = exports.default = service;\n\n//# sourceURL=webpack://ZjPluginIntelligent/./src/lib/utils/request.js?");
|
|
13513
13513
|
|
|
13514
13514
|
/***/ }),
|
|
13515
13515
|
|
|
@@ -960,7 +960,7 @@ eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n
|
|
|
960
960
|
/***/ (function(module, exports, __webpack_require__) {
|
|
961
961
|
|
|
962
962
|
"use strict";
|
|
963
|
-
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.detached.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.detached.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer-to-fixed-length.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer-to-fixed-length.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.find.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.find.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.for-each.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.for-each.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.at.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.at.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last-index.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last-index.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.set.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.set.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-reversed.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-reversed.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-sorted.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-sorted.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.with.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.with.js\");\nvar _markdownIt = _interopRequireDefault(__webpack_require__(/*! markdown-it */ \"./node_modules/_markdown-it@14.1.0@markdown-it/index.mjs\"));\nvar _apichat = __webpack_require__(/*! @lib/api/apichat */ \"./src/lib/api/apichat.js\");\nvar _index = __webpack_require__(/*! @lib/socket/fileManager/obs/index */ \"./src/lib/socket/fileManager/obs/index.js\");\nvar _index2 = _interopRequireDefault(__webpack_require__(/*! @lib/socket/utils/index */ \"./src/lib/socket/utils/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _apiObs = _interopRequireDefault(__webpack_require__(/*! @lib/api/apiObs */ \"./src/lib/api/apiObs.js\"));\nvar _html2canvas = _interopRequireDefault(__webpack_require__(/*! html2canvas */ \"./node_modules/_html2canvas@1.4.1@html2canvas/dist/html2canvas.js\"));\nconst md = new _markdownIt.default({\n breaks: true,\n html: false,\n linkify: true,\n typographer: true\n});\nmd.enable('table');\nvar _default = exports.default = {\n name: 'ChatDetail',\n props: {\n isMobile: {\n type: Boolean,\n default: false\n },\n activeSessionId: {\n type: String,\n default: ''\n },\n currentAgent: {\n type: String,\n default: ''\n },\n agentInfo: {\n type: Object,\n default: () => ({})\n },\n currentAgentDesc: {\n type: String,\n default: ''\n },\n showSidebar: {\n type: Boolean,\n default: false\n },\n showH5List: {\n type: Boolean,\n default: false\n },\n showAgentSelect: {\n type: Boolean,\n default: false\n },\n agentList: {\n type: Array,\n default: () => []\n }\n },\n data() {\n return {\n messages: [],\n inputText: '',\n sending: false,\n waitTimer: null,\n waitSeconds: 0,\n canceling: false,\n msgPage: 1,\n msgMaxPage: 1,\n msgTotal: 0,\n eventSource: null,\n showToolInfo: false,\n showDownBtn: false,\n lastScrollTop: 0,\n scrollTimer: null,\n contentCache: {},\n loadingSessionMsg: false,\n greeting: '',\n _scrollTimer: null,\n _userScrolledUp: false,\n voiceMode: false,\n isRecording: false,\n recordDuration: 0,\n recordingTimer: null,\n audioContext: null,\n audioStream: null,\n mediaRecorder: null,\n recordedChunks: [],\n audioSource: null,\n isCancelSwipe: false,\n recordStartY: 0,\n _pendingSendText: null\n };\n },\n computed: {\n waitClass() {\n if (this.waitSeconds > 15) return 'wait-15';\n if (this.waitSeconds > 10) return 'wait-10';\n if (this.waitSeconds > 5) return 'wait-5';\n return '';\n },\n currentAgentName() {\n if (!this.currentAgent) return '';\n const a = this.agentList.find(x => x.agentCode === this.currentAgent);\n return a ? a.agentName || a.agentCode : this.currentAgent;\n }\n },\n watch: {\n contentCache: {\n handler(val) {\n // 保持响应性\n },\n immediate: true\n },\n activeSessionId(val) {\n if (val && this._pendingSendText) {\n const text = this._pendingSendText;\n this._pendingSendText = null;\n this.$nextTick(() => this._sendMessage(text));\n }\n }\n },\n mounted() {\n if (window.visualViewport) {\n window.visualViewport.addEventListener('resize', this.onViewportResize, {\n passive: true\n });\n }\n },\n beforeDestroy() {\n if (window.visualViewport) {\n window.visualViewport.removeEventListener('resize', this.onViewportResize);\n }\n this.releaseAudio();\n this.contentCache = {};\n clearTimeout(this._scrollTimer);\n if (this.eventSource) {\n this.eventSource.onerror = null;\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n if (this._typeTimer) {\n clearInterval(this._typeTimer);\n this._typeTimer = null;\n }\n },\n methods: {\n getInitial(name) {\n if (!name) return '?';\n return name.charAt(0);\n },\n formatTime(t) {\n if (!t) return '';\n const d = new Date(t.replace(' ', 'T'));\n const now = new Date();\n const diff = (now - d) / 1000;\n if (diff < 60) return '刚刚';\n if (diff < 3600) return Math.floor(diff / 60) + '分钟前';\n if (diff < 86400) return Math.floor(diff / 3600) + '小时前';\n return d.getMonth() + 1 + '/' + d.getDate();\n },\n escapeHtml(text) {\n if (!text) return '';\n return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\n },\n renderMarkdown(text) {\n if (!text) return '';\n try {\n return md.render(text);\n } catch (err) {\n return this.escapeHtml(text);\n }\n },\n renderStreaming(text, msgKey) {\n if (!text) return '';\n const cache = this.contentCache[msgKey];\n const lines = text.split('\\n');\n const lastLineIdx = lines.length - 1;\n if (!cache) {\n try {\n const html = md.render(text);\n this.$set(this.contentCache, msgKey, {\n renderedLines: lastLineIdx,\n html,\n fullText: text\n });\n return html;\n } catch {\n return this.escapeHtml(text);\n }\n }\n if (text === cache.fullText) return cache.html;\n cache.fullText = text;\n\n // 行数没变,仅末尾追加字符,直接拼接尾行\n if (lastLineIdx === cache.renderedLines) {\n return cache.html + this.escapeHtml(lines[lastLineIdx]);\n }\n\n // 场景1:行数变少/文本回退截断,全量重渲染\n if (lastLineIdx < cache.renderedLines) {\n try {\n const fullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (fullLines.length > 0) {\n newBaseHtml = md.render(fullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n return cache.html + (lastLine || '');\n } catch {\n return this.escapeHtml(text);\n }\n }\n\n // 场景2:新增完整行\n if (cache.renderedLines < lastLineIdx) {\n const allFullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (allFullLines.length > 0) {\n newBaseHtml = md.render(allFullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n }\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n cache.renderedLines = lastLineIdx;\n return cache.html + (lastLine || '');\n },\n getChatAvatarStyle(agent, avatar) {\n if (avatar) {\n return {\n backgroundImage: `url(${avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n const _agent = this.agentList.find(x => x.agentCode === agent);\n if (_agent && _agent.avatar) {\n return {\n backgroundImage: `url(${_agent.avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n // 没有网络图片时,根据智能体在 agentList 中的索引位置决定颜色\n const index = this.agentList.findIndex(item => item.agentCode === agent);\n if (index !== -1) {\n const colorIndex = index % 4 + 1;\n return {\n background: this.getGradientByColorIndex(colorIndex)\n };\n }\n return {\n background: 'linear-gradient(135deg, #1677ff, #4096ff)'\n };\n },\n getGradientByColorIndex(index) {\n const gradients = {\n 1: 'linear-gradient(135deg, #1677ff, #4096ff)',\n 2: 'linear-gradient(135deg, #52c41a, #73d13d)',\n 3: 'linear-gradient(135deg, #fa8c16, #ffa940)',\n 4: 'linear-gradient(135deg, #722ed1, #b37feb)'\n };\n return gradients[index] || gradients[1];\n },\n sendQuickMsg(message) {\n if (message) {\n this.inputText = message;\n this.sendMessage();\n }\n },\n formatMixText(str) {\n if (!str) return '';\n let res = str.replace(/([a-zA-Z0-9])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z0-9])/g, '$1 $2');\n res = res.replace(/([a-z])([A-Z])/g, '$1 $2');\n return res;\n },\n // 检测是否滚动到底部,控制向下箭头显示隐藏\n checkScrollBottom(forceShowWhenNotBottom = false) {\n const el = this.$refs.messagesRef;\n if (!el) {\n this.showDownBtn = false;\n return;\n }\n const {\n scrollHeight,\n scrollTop,\n clientHeight\n } = el;\n const remain = scrollHeight - scrollTop - clientHeight;\n const atBottom = remain <= 80;\n\n // 检测用户是否主动向上滚动(远离底部)\n if (remain > 80 && scrollTop < this.lastScrollTop) {\n this._userScrolledUp = true;\n }\n if (atBottom) {\n this._userScrolledUp = false;\n this.showDownBtn = false;\n } else if (this._userScrolledUp) {\n // 用户已主动上滚,保持显示\"到底部\"按钮,不自动回滚\n this.showDownBtn = true;\n } else if (this.sending && scrollTop >= this.lastScrollTop && !forceShowWhenNotBottom) {\n this.showDownBtn = false;\n } else {\n this.showDownBtn = true;\n }\n this.lastScrollTop = scrollTop;\n this.$emit('scroll-bottom-check', {\n showDownBtn: this.showDownBtn,\n lastScrollTop: this.lastScrollTop\n });\n },\n // H5 输入框获取焦点 - 滚动到底部让键盘不遮挡\n onInputFocus() {\n if (!this.isMobile) return;\n setTimeout(() => {\n window.scrollTo(0, document.body.scrollHeight);\n }, 300);\n },\n // visualViewport 变化时(如键盘弹出)调整滚动位置\n onViewportResize() {\n if (!this.isMobile) return;\n const activeEl = document.activeElement;\n if (activeEl && (activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'INPUT')) {\n window.scrollTo(0, document.body.scrollHeight);\n }\n },\n autoResizeTextarea() {\n const ta = this.$refs.taRef;\n if (!ta) return;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n const maxH = isMobile ? 100 : 180;\n ta.style.height = `${baseH}px`;\n const realH = ta.scrollHeight;\n if (realH > baseH) {\n ta.style.height = `${Math.min(realH, maxH)}px`;\n }\n this.$nextTick(() => {\n this.checkScrollBottom(true);\n });\n },\n // Ctrl+Enter换行\n newLine(e) {\n e.preventDefault();\n const ta = this.$refs.taRef;\n const start = ta.selectionStart;\n const end = ta.selectionEnd;\n this.inputText = this.inputText.slice(0, start) + '\\n' + this.inputText.slice(end);\n this.$nextTick(() => {\n ta.selectionStart = ta.selectionEnd = start + 1;\n this.autoResizeTextarea();\n });\n },\n toggleVoiceMode() {\n if (this.voiceMode) {\n this.voiceMode = false;\n this.releaseAudio();\n return;\n }\n // 进入语音模式前提前请求麦克风权限,避免触摸时异步弹窗导致 touchend 丢失\n this.ensureAudioReady().then(ready => {\n if (ready) {\n this.voiceMode = true;\n } else {\n this.voiceMode = false;\n }\n });\n },\n // 确保音频设备已就绪\n async ensureAudioReady() {\n if (this.mediaRecorder && this.audioStream) return true;\n if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {\n alert('当前环境不支持麦克风访问');\n return false;\n }\n if (!window.MediaRecorder) {\n alert('当前浏览器不支持录音功能');\n return false;\n }\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: true\n });\n this.audioStream = stream;\n\n // 检测平台支持的 MIME 类型\n const mimeType = this._getSupportedMimeType();\n const options = mimeType ? {\n mimeType\n } : {};\n const recorder = new MediaRecorder(stream, options);\n recorder.ondataavailable = e => {\n if (e.data && e.data.size > 0) {\n this.recordedChunks.push(e.data);\n }\n };\n recorder.onstop = () => {\n this._onMediaRecorderStop();\n };\n recorder.onerror = () => {\n console.error('MediaRecorder error');\n this.recordedChunks = [];\n };\n this.mediaRecorder = recorder;\n this.audioContext = new (window.AudioContext || window.webkitAudioContext)();\n if (this.audioContext.state === 'suspended') {\n await this.audioContext.resume();\n }\n return true;\n } catch (err) {\n console.error('麦克风访问失败:', err);\n alert('麦克风权限被拒绝');\n return false;\n }\n },\n // 检测支持的 MIME 类型\n _getSupportedMimeType() {\n const types = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/ogg;codecs=opus'];\n for (const t of types) {\n if (MediaRecorder.isTypeSupported(t)) return t;\n }\n return '';\n },\n // MediaRecorder 停止后的回调:解码压缩音频 → 编码 WAV → 发送\n _onMediaRecorderStop() {\n if (this.recordedChunks.length === 0) {\n console.warn('录音数据为空');\n return;\n }\n const blob = new Blob(this.recordedChunks, {\n type: this.mediaRecorder.mimeType || 'audio/webm'\n });\n this.recordedChunks = [];\n\n // 解码压缩音频为 PCM\n const reader = new FileReader();\n reader.onload = () => {\n const arrayBuffer = reader.result;\n this.audioContext.decodeAudioData(arrayBuffer, audioBuffer => {\n // 取第一个声道的数据\n const channelData = audioBuffer.getChannelData(0);\n const wavBlob = this._encodePCMToWavFromBuffer(channelData, audioBuffer.sampleRate);\n this.sendAudioBlob(wavBlob);\n }, err => {\n console.error('decodeAudioData 失败:', err);\n });\n };\n reader.onerror = () => {\n console.error('FileReader 读取失败');\n };\n reader.readAsArrayBuffer(blob);\n },\n // 从 Float32Array 编码 WAV\n _encodePCMToWavFromBuffer(channelData, sampleRate) {\n const numChannels = 1;\n const bitsPerSample = 16;\n const byteRate = sampleRate * numChannels * bitsPerSample / 8;\n const blockAlign = numChannels * bitsPerSample / 8;\n const dataSize = channelData.length * (bitsPerSample / 8);\n const headerSize = 44;\n const totalSize = headerSize + dataSize;\n const buffer = new ArrayBuffer(totalSize);\n const view = new DataView(buffer);\n this._wavWriteString(view, 0, 'RIFF');\n view.setUint32(4, totalSize - 8, true);\n this._wavWriteString(view, 8, 'WAVE');\n this._wavWriteString(view, 12, 'fmt ');\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true); // PCM\n view.setUint16(22, numChannels, true);\n view.setUint32(24, sampleRate, true);\n view.setUint32(28, byteRate, true);\n view.setUint16(32, blockAlign, true);\n view.setUint16(34, bitsPerSample, true);\n this._wavWriteString(view, 36, 'data');\n view.setUint32(40, dataSize, true);\n const pcmView = new Int16Array(buffer, headerSize, channelData.length);\n for (let i = 0; i < channelData.length; i++) {\n const s = Math.max(-1, Math.min(1, channelData[i]));\n pcmView[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;\n }\n return new Blob([buffer], {\n type: 'audio/wav'\n });\n },\n // H5语音录制 - 触摸开始\n async onRecordTouchStart(e) {\n if (this.sending) return;\n const ready = await this.ensureAudioReady();\n if (!ready) return;\n this.isCancelSwipe = false;\n this.recordStartY = e.touches[0].clientY;\n this.recordDuration = 0;\n this.recordedChunks = [];\n this.isRecording = true;\n this.mediaRecorder.start();\n this.recordingTimer = setInterval(() => {\n this.recordDuration++;\n if (this.recordDuration >= 60) {\n this.onRecordTouchEnd();\n }\n }, 1000);\n },\n // H5语音录制 - 触摸移动\n onRecordTouchMove(e) {\n if (!this.isRecording) return;\n const touch = e.touches[0];\n const moveY = touch.clientY;\n const cancelThreshold = 60;\n this.isCancelSwipe = this.recordStartY - moveY > cancelThreshold;\n },\n // H5语音录制 - 触摸结束\n onRecordTouchEnd() {\n if (!this.isRecording) return;\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n // 上滑取消:丢弃录音数据\n if (this.isCancelSwipe) {\n this.recordedChunks = [];\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n return;\n }\n // 正常发送:停止 MediaRecorder → 触发 onstop → _onMediaRecorderStop → 解码 → 发送\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n },\n // 取消录音\n cancelRecording() {\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n this.recordedChunks = [];\n this.recordDuration = 0;\n this.isCancelSwipe = false;\n },\n // 释放音频资源\n releaseAudio() {\n this.cancelRecording();\n if (this.mediaRecorder) {\n if (this.mediaRecorder.state !== 'inactive') {\n try {\n this.mediaRecorder.stop();\n } catch (e) {}\n }\n this.mediaRecorder = null;\n }\n if (this.audioSource) {\n this.audioSource.disconnect();\n this.audioSource = null;\n }\n if (this.audioContext) {\n this.audioContext.close().catch(() => {});\n this.audioContext = null;\n }\n if (this.audioStream) {\n this.audioStream.getTracks().forEach(t => t.stop());\n this.audioStream = null;\n }\n this.recordedChunks = [];\n this.voiceMode = false;\n },\n _wavWriteString(view, offset, str) {\n for (let i = 0; i < str.length; i++) {\n view.setUint8(offset + i, str.charCodeAt(i));\n }\n },\n // 发送音频\n sendAudioBlob(blob) {\n let appSeqId = _index2.default.getAppSeqId();\n let name = appSeqId + '.wav';\n const file = new File([blob], name, {\n type: 'audio/wav'\n });\n this.sendAudioMessage(file);\n },\n sendAudioMessage(file) {\n const sttKey = 'stt_' + Date.now();\n const sttMsg = {\n role: 'user',\n content: '转化中...',\n _sttStatus: 'pending',\n _sttKey: sttKey,\n createTime: new Date().toISOString(),\n _key: sttKey\n };\n this.messages.push(sttMsg);\n this.scrollToBottom(true);\n (0, _index.fileUploadToObs)({\n _file: file,\n appSeqId: file.name\n }).then(resp => {\n if (resp.CommonMsg.Status === 200) {\n return _apiObs.default.aliAudioToText({\n url: resp.obs_upload_data.obsUrl\n }).then(result => {\n if (result.code === '200' && result.data) {\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.messages.splice(idx, 1);\n }\n this.sendQuickMsg(result.data);\n } else {\n throw new Error(result.msg || '语音转文字失败');\n }\n });\n } else {\n throw new Error(resp.CommonMsg.Message || '文件上传失败');\n }\n }).catch(err => {\n console.error('语音转文字失败:', err);\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.$set(this.messages, idx, {\n ...this.messages[idx],\n _sttStatus: 'error',\n content: '语音转文字失败'\n });\n }\n this.$message.error('语音转文字失败');\n });\n this.$message.success(`录音完成 (${this.recordDuration}s)`);\n },\n clearWaitTimer() {\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n },\n loadMoreMessages() {\n const nextPage = this.msgPage + 1;\n const maxPage = Math.ceil(this.msgTotal / 10);\n if (nextPage > maxPage) {\n this.loadingMore = false;\n return;\n }\n (0, _apichat.getSessionMessages)(this.activeSessionId, nextPage, 10).then(res => {\n const list = Array.isArray(res) ? res : res.list || [];\n this.msgPage = nextPage;\n const asAsc = (list || []).slice().reverse();\n const prepend = asAsc.map(m => ({\n role: m.role,\n content: m.content || '',\n thinking: m.thinking || '',\n toolCalls: [],\n agentName: '',\n createTime: m.createTime || null,\n _key: m.createTime ? 'msg_' + m.createTime : 'msg_' + Date.now() + '_' + Math.random()\n }));\n const container = this.$refs.messagesRef;\n const oldScrollHeight = container ? container.scrollHeight : 0;\n const oldScrollTop = container ? container.scrollTop : 0;\n this.messages = [...prepend, ...this.messages];\n this.$nextTick(() => {\n if (container) {\n container.scrollTop = oldScrollTop + (container.scrollHeight - oldScrollHeight);\n }\n this.loadingMore = false;\n });\n }).catch(() => {\n this.loadingMore = false;\n });\n },\n onScrollMessages() {\n const container = this.$refs.messagesRef;\n if (!container || this.loadingMore) return;\n const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 100;\n this.checkScrollBottom();\n if (isAtBottom) return;\n if (container.scrollTop < 50 && this.msgPage < Math.ceil(this.msgTotal / 10)) {\n this.loadingMore = true;\n this.loadMoreMessages();\n }\n },\n async sendMessage() {\n const text = this.inputText.trim();\n if (!text || this.sending) return;\n if (text.length > 300) {\n this.$message({\n message: '消息内容不能超过300字',\n type: 'warning',\n duration: 2000\n });\n return;\n }\n if (!this.activeSessionId) {\n this.$emit('start-session');\n this._pendingSendText = text;\n return;\n }\n this._sendMessage(text);\n },\n async _sendMessage(text) {\n this.inputText = '';\n const ta = this.$refs.taRef;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n if (ta) {\n ta.style.height = baseH + 'px';\n }\n this.showDownBtn = false;\n this.messages.push({\n role: 'user',\n content: text,\n createTime: new Date().toISOString(),\n _key: 'user_' + Date.now() + '_' + Math.random()\n });\n const agentName = this.currentAgentName;\n const msgIdx = this.messages.push({\n role: \"assistant\",\n content: \"\",\n thinking: \"\",\n loading: true,\n toolCalls: [],\n agentName,\n createTime: new Date().toISOString(),\n _key: 'asst_' + Date.now() + '_' + Math.random()\n }) - 1;\n this.scrollToBottom(true);\n this.sending = true;\n this.waitSeconds = 0;\n if (this.waitTimer) clearInterval(this.waitTimer);\n this.waitTimer = setInterval(() => {\n this.waitSeconds++;\n }, 1000);\n let thinkingBuffer = '';\n let finalContent = '';\n let answerBuffer = '';\n let thinkDisplayed = 0;\n let typeTimer = null;\n let streamDone = false;\n typeTimer = setInterval(() => {\n if (!this.messages[msgIdx]) return;\n\n // Phase 1: 逐字展示思考内容(不 return,让 Phase 2 也能同时执行)\n if (thinkDisplayed < thinkingBuffer.length) {\n thinkDisplayed += 50;\n if (thinkDisplayed > thinkingBuffer.length) thinkDisplayed = thinkingBuffer.length;\n this.messages[msgIdx].thinking = thinkingBuffer.substring(0, thinkDisplayed);\n this.scrollToBottom();\n // 不 return,让 Phase 2 也能执行,避免 iOS 上思考内容过长阻塞回答内容展示\n }\n\n // Phase 2: 逐行展示(已完成的行整行出现,未完成的行也即时显示)\n const target = finalContent || answerBuffer;\n const msg = this.messages[msgIdx];\n const revealedLen = msg._revealedLen || 0;\n if (target.length > revealedLen) {\n const remaining = target.substring(revealedLen);\n const nextNewline = remaining.indexOf('\\n');\n if (nextNewline !== -1) {\n // 新的一行已完成 → 整行出现\n const endPos = revealedLen + nextNewline + 1;\n msg._revealedLen = endPos;\n msg.content = target.substring(0, endPos);\n this.scrollToBottom();\n return;\n }\n\n // 当前行未完成 → 逐步展示(每次 30 字符),确保 iOS 上也有渐进效果\n const revealStep = 30;\n const newRevealedLen = Math.min(revealedLen + revealStep, target.length);\n msg._revealedLen = newRevealedLen;\n msg.content = target.substring(0, newRevealedLen);\n this.scrollToBottom();\n return;\n }\n\n // Phase 3: 流已结束且内容已全部展示 → 收尾\n if (streamDone) {\n clearInterval(typeTimer);\n const key = this.messages[msgIdx]._key;\n if (key) {\n this.$delete(this.contentCache, key);\n this.$delete(this.contentCache, key + '_t');\n }\n typeTimer = null;\n this.messages[msgIdx].content = finalContent;\n this.messages[msgIdx].thinking = thinkingBuffer;\n this.messages[msgIdx].loading = false;\n this.sending = false;\n this.clearWaitTimer();\n this.scrollToBottom();\n this.$emit('session-refresh');\n this.$emit('message-sent');\n }\n }, 30);\n const url = (0, _apichat.createStreamUrl)(this.activeSessionId, text);\n if (this.eventSource) {\n this.eventSource.close();\n }\n this.eventSource = new EventSource(url);\n const es = this.eventSource;\n es.addEventListener('reasoning', e => {\n if (e.data) {\n const chunk = e.data;\n let cleanChunk = chunk.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2').replace(/\\s+([:,.!?])/g, '$1').replace(/([:,.!?])\\s+/g, '$1 ');\n cleanChunk = cleanChunk.replace(/\\s+/g, ' ');\n const lastChar = thinkingBuffer.slice(-1);\n const firstChar = cleanChunk.slice(0, 1);\n const isLetter = c => /[a-zA-Z]/.test(c);\n if (isLetter(lastChar) && isLetter(firstChar)) {\n thinkingBuffer += ' ' + cleanChunk;\n } else {\n thinkingBuffer += cleanChunk;\n }\n }\n });\n es.addEventListener('reasoning_result', e => {\n if (e.data) {\n thinkingBuffer = e.data;\n }\n });\n es.addEventListener('answer', e => {\n if (e.data) {\n answerBuffer += e.data.replace(/([a-zA-Z]+)([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z]+)/g, '$1 $2').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2');\n }\n });\n es.addEventListener('tool_result', e => {\n if (e.data && this.messages[msgIdx]) {\n if (!this.messages[msgIdx].toolCalls) this.messages[msgIdx].toolCalls = [];\n this.messages[msgIdx].toolCalls.push(e.data);\n this.scrollToBottom();\n }\n });\n es.addEventListener('agent_result', e => {\n finalContent = e.data || '';\n streamDone = true;\n es.close();\n this.eventSource = null;\n });\n es.onerror = err => {\n console.log('[SSE] onerror', err);\n if (thinkingBuffer.length > 0 && !streamDone) finalContent = thinkingBuffer;\n if (!streamDone) finalContent = '❌ 连接中断';\n streamDone = true;\n es.close();\n this.eventSource = null;\n };\n },\n stopAgent() {\n if (this.eventSource) {\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.activeSessionId) {\n (0, _apichat.stopStream)(this.activeSessionId).catch(() => {});\n }\n if (this.messages.length > 0) {\n const last = this.messages[this.messages.length - 1];\n if (last && last.loading) {\n last.content = last.content || last.thinking || '⏹ 已停止';\n last.loading = false;\n this.$delete(this.contentCache, last._key);\n }\n }\n this.sending = false;\n this.clearWaitTimer();\n },\n scrollToTop() {\n const el = this.$refs.messagesRef;\n if (el) el.scrollTop = 0;\n },\n scrollToBottom(force = false) {\n if (this.showDownBtn && !force) return;\n this.$nextTick(() => {\n const el = this.$refs.messagesRef;\n if (el) {\n el.scrollTop = el.scrollHeight;\n this.lastScrollTop = el.scrollTop;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n }\n });\n },\n scrollToBottomOnce() {\n clearTimeout(this._scrollTimer);\n this.$nextTick(() => {\n const tryScroll = () => {\n const el = this.$refs.messagesRef;\n if (!el) {\n this._scrollTimer = setTimeout(tryScroll, 50);\n return;\n }\n const originalScrollBehavior = el.style.scrollBehavior;\n el.style.scrollBehavior = 'auto';\n el.scrollTop = el.scrollHeight - el.clientHeight;\n el.style.scrollBehavior = originalScrollBehavior;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n };\n tryScroll();\n });\n },\n copyText(content) {\n if (!content) return;\n const text = content.replace(/<[^>]+>/g, '');\n\n // 优先使用 Clipboard API(HTTPS 环境)\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(() => {\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n this._fallbackCopyText(text);\n });\n } else {\n this._fallbackCopyText(text);\n }\n },\n _fallbackCopyText(text) {\n try {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';\n document.body.appendChild(ta);\n ta.focus();\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n } catch {\n this.$message({\n message: '复制失败',\n type: 'error',\n duration: 2000\n });\n }\n },\n copyAsImage(content) {\n if (!content) return;\n const div = document.createElement('div');\n div.className = 'copy-image-render';\n div.style.cssText = 'position:fixed;left:-9999px;top:0;width:600px;padding:20px;background:#fff;font-size:14px;line-height:1.6;color:#333;border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;';\n // 内联表格样式(因 Vue 作用域样式不覆盖复制用的脱离文档流 div)\n div.innerHTML = this.renderMarkdown(content) + '<style>table{border-collapse:collapse;width:100%;margin:6px 0;font-size:14px}' + 'th,td{border:1px solid #e8e8e8;padding:5px 8px;text-align:left}' + 'th{background:#fafafa;font-weight:600}</style>';\n document.body.appendChild(div);\n this.$message({\n message: '正在生成图片...',\n type: 'info',\n duration: 2000\n });\n (0, _html2canvas.default)(div, {\n scale: 2,\n useCORS: true,\n backgroundColor: '#ffffff'\n }).then(canvas => {\n // 检测是否支持 ClipboardItem(部分 WebView 不支持)\n const canCopyImage = typeof ClipboardItem !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.write === 'function';\n if (canCopyImage) {\n canvas.toBlob(blob => {\n if (blob) {\n navigator.clipboard.write([new ClipboardItem({\n 'image/png': blob\n })]).then(() => {\n this.$message({\n message: '图片已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n // ClipboardItem 存在但写入失败 → 降级下载\n this._downloadCanvas(canvas);\n });\n }\n }, 'image/png');\n } else {\n // 不支持剪贴板写图片 → 直接下载\n this._downloadCanvas(canvas);\n }\n }).catch(() => {\n this.$message({\n message: '生成图片失败',\n type: 'error',\n duration: 2000\n });\n }).finally(() => {\n document.body.removeChild(div);\n });\n },\n _downloadCanvas(canvas) {\n const link = document.createElement('a');\n link.download = '消息图片.png';\n link.href = canvas.toDataURL('image/png');\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n this.$message({\n message: '已保存图片到本地',\n type: 'success',\n duration: 2000\n });\n }\n }\n};\n\n//# sourceURL=webpack://ZjPluginIntelligent/./src/lib/components/chatMain/ChatDetail.vue?./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--13-0!./node_modules/_babel-loader@8.4.1@babel-loader/lib!./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--1-0!./node_modules/_vue-loader@15.11.1@vue-loader/lib??vue-loader-options");
|
|
963
|
+
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.detached.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.detached.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer.js\");\n__webpack_require__(/*! core-js/modules/es.array-buffer.transfer-to-fixed-length.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array-buffer.transfer-to-fixed-length.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.find.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.find.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.for-each.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.for-each.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.at.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.at.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.find-last-index.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.find-last-index.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.set.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.set.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-reversed.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-reversed.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.to-sorted.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.to-sorted.js\");\n__webpack_require__(/*! core-js/modules/es.typed-array.with.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.typed-array.with.js\");\nvar _markdownIt = _interopRequireDefault(__webpack_require__(/*! markdown-it */ \"./node_modules/_markdown-it@14.1.0@markdown-it/index.mjs\"));\nvar _apichat = __webpack_require__(/*! @lib/api/apichat */ \"./src/lib/api/apichat.js\");\nvar _index = __webpack_require__(/*! @lib/socket/fileManager/obs/index */ \"./src/lib/socket/fileManager/obs/index.js\");\nvar _index2 = _interopRequireDefault(__webpack_require__(/*! @lib/socket/utils/index */ \"./src/lib/socket/utils/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _apiObs = _interopRequireDefault(__webpack_require__(/*! @lib/api/apiObs */ \"./src/lib/api/apiObs.js\"));\nvar _html2canvas = _interopRequireDefault(__webpack_require__(/*! html2canvas */ \"./node_modules/_html2canvas@1.4.1@html2canvas/dist/html2canvas.js\"));\nconst md = new _markdownIt.default({\n breaks: true,\n html: false,\n linkify: true,\n typographer: true\n});\nmd.enable('table');\nvar _default = exports.default = {\n name: 'ChatDetail',\n props: {\n isMobile: {\n type: Boolean,\n default: false\n },\n activeSessionId: {\n type: String,\n default: ''\n },\n currentAgent: {\n type: String,\n default: ''\n },\n agentInfo: {\n type: Object,\n default: () => ({})\n },\n currentAgentDesc: {\n type: String,\n default: ''\n },\n showSidebar: {\n type: Boolean,\n default: false\n },\n showH5List: {\n type: Boolean,\n default: false\n },\n showAgentSelect: {\n type: Boolean,\n default: false\n },\n agentList: {\n type: Array,\n default: () => []\n }\n },\n data() {\n return {\n messages: [],\n inputText: '',\n sending: false,\n waitTimer: null,\n waitSeconds: 0,\n canceling: false,\n msgPage: 1,\n msgMaxPage: 1,\n msgTotal: 0,\n eventSource: null,\n showToolInfo: false,\n showDownBtn: false,\n lastScrollTop: 0,\n scrollTimer: null,\n contentCache: {},\n loadingSessionMsg: false,\n greeting: '',\n _scrollTimer: null,\n _userScrolledUp: false,\n voiceMode: false,\n isRecording: false,\n recordDuration: 0,\n recordingTimer: null,\n audioContext: null,\n audioStream: null,\n audioProcessor: null,\n audioChunks: [],\n audioSampleRate: 16000,\n isCancelSwipe: false,\n recordStartY: 0,\n _pendingSendText: null\n };\n },\n computed: {\n waitClass() {\n if (this.waitSeconds > 15) return 'wait-15';\n if (this.waitSeconds > 10) return 'wait-10';\n if (this.waitSeconds > 5) return 'wait-5';\n return '';\n },\n currentAgentName() {\n if (!this.currentAgent) return '';\n const a = this.agentList.find(x => x.agentCode === this.currentAgent);\n return a ? a.agentName || a.agentCode : this.currentAgent;\n }\n },\n watch: {\n contentCache: {\n handler(val) {\n // 保持响应性\n },\n immediate: true\n },\n activeSessionId(val) {\n if (val && this._pendingSendText) {\n const text = this._pendingSendText;\n this._pendingSendText = null;\n this.$nextTick(() => this._sendMessage(text));\n }\n }\n },\n mounted() {\n if (window.visualViewport) {\n window.visualViewport.addEventListener('resize', this.onViewportResize, {\n passive: true\n });\n }\n },\n beforeDestroy() {\n if (window.visualViewport) {\n window.visualViewport.removeEventListener('resize', this.onViewportResize);\n }\n this.releaseAudio();\n this.contentCache = {};\n clearTimeout(this._scrollTimer);\n if (this.eventSource) {\n this.eventSource.onerror = null;\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n if (this._typeTimer) {\n clearInterval(this._typeTimer);\n this._typeTimer = null;\n }\n },\n methods: {\n getInitial(name) {\n if (!name) return '?';\n return name.charAt(0);\n },\n formatTime(t) {\n if (!t) return '';\n const d = new Date(t.replace(' ', 'T'));\n const now = new Date();\n const diff = (now - d) / 1000;\n if (diff < 60) return '刚刚';\n if (diff < 3600) return Math.floor(diff / 60) + '分钟前';\n if (diff < 86400) return Math.floor(diff / 3600) + '小时前';\n return d.getMonth() + 1 + '/' + d.getDate();\n },\n escapeHtml(text) {\n if (!text) return '';\n return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\n },\n renderMarkdown(text) {\n if (!text) return '';\n try {\n return md.render(text);\n } catch (err) {\n return this.escapeHtml(text);\n }\n },\n renderStreaming(text, msgKey) {\n if (!text) return '';\n const cache = this.contentCache[msgKey];\n const lines = text.split('\\n');\n const lastLineIdx = lines.length - 1;\n if (!cache) {\n try {\n const html = md.render(text);\n this.$set(this.contentCache, msgKey, {\n renderedLines: lastLineIdx,\n html,\n fullText: text\n });\n return html;\n } catch {\n return this.escapeHtml(text);\n }\n }\n if (text === cache.fullText) return cache.html;\n cache.fullText = text;\n\n // 行数没变,仅末尾追加字符,直接拼接尾行\n if (lastLineIdx === cache.renderedLines) {\n return cache.html + this.escapeHtml(lines[lastLineIdx]);\n }\n\n // 场景1:行数变少/文本回退截断,全量重渲染\n if (lastLineIdx < cache.renderedLines) {\n try {\n const fullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (fullLines.length > 0) {\n newBaseHtml = md.render(fullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n return cache.html + (lastLine || '');\n } catch {\n return this.escapeHtml(text);\n }\n }\n\n // 场景2:新增完整行\n if (cache.renderedLines < lastLineIdx) {\n const allFullLines = lines.slice(0, lastLineIdx);\n let newBaseHtml = '';\n if (allFullLines.length > 0) {\n newBaseHtml = md.render(allFullLines.join('\\n'));\n }\n cache.html = newBaseHtml;\n cache.renderedLines = lastLineIdx;\n }\n const lastLine = this.escapeHtml(lines[lastLineIdx]);\n cache.renderedLines = lastLineIdx;\n return cache.html + (lastLine || '');\n },\n getChatAvatarStyle(agent, avatar) {\n if (avatar) {\n return {\n backgroundImage: `url(${avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n const _agent = this.agentList.find(x => x.agentCode === agent);\n if (_agent && _agent.avatar) {\n return {\n backgroundImage: `url(${_agent.avatar})`,\n backgroundSize: 'cover',\n backgroundPosition: 'center',\n backgroundRepeat: 'no-repeat'\n };\n }\n // 没有网络图片时,根据智能体在 agentList 中的索引位置决定颜色\n const index = this.agentList.findIndex(item => item.agentCode === agent);\n if (index !== -1) {\n const colorIndex = index % 4 + 1;\n return {\n background: this.getGradientByColorIndex(colorIndex)\n };\n }\n return {\n background: 'linear-gradient(135deg, #1677ff, #4096ff)'\n };\n },\n getGradientByColorIndex(index) {\n const gradients = {\n 1: 'linear-gradient(135deg, #1677ff, #4096ff)',\n 2: 'linear-gradient(135deg, #52c41a, #73d13d)',\n 3: 'linear-gradient(135deg, #fa8c16, #ffa940)',\n 4: 'linear-gradient(135deg, #722ed1, #b37feb)'\n };\n return gradients[index] || gradients[1];\n },\n sendQuickMsg(message) {\n if (message) {\n this.inputText = message;\n this.sendMessage();\n }\n },\n formatMixText(str) {\n if (!str) return '';\n let res = str.replace(/([a-zA-Z0-9])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z0-9])/g, '$1 $2');\n res = res.replace(/([a-z])([A-Z])/g, '$1 $2');\n return res;\n },\n // 检测是否滚动到底部,控制向下箭头显示隐藏\n checkScrollBottom(forceShowWhenNotBottom = false) {\n const el = this.$refs.messagesRef;\n if (!el) {\n this.showDownBtn = false;\n return;\n }\n const {\n scrollHeight,\n scrollTop,\n clientHeight\n } = el;\n const remain = scrollHeight - scrollTop - clientHeight;\n const atBottom = remain <= 80;\n\n // 检测用户是否主动向上滚动(远离底部)\n if (remain > 80 && scrollTop < this.lastScrollTop) {\n this._userScrolledUp = true;\n }\n if (atBottom) {\n this._userScrolledUp = false;\n this.showDownBtn = false;\n } else if (this._userScrolledUp) {\n // 用户已主动上滚,保持显示\"到底部\"按钮,不自动回滚\n this.showDownBtn = true;\n } else if (this.sending && scrollTop >= this.lastScrollTop && !forceShowWhenNotBottom) {\n this.showDownBtn = false;\n } else {\n this.showDownBtn = true;\n }\n this.lastScrollTop = scrollTop;\n this.$emit('scroll-bottom-check', {\n showDownBtn: this.showDownBtn,\n lastScrollTop: this.lastScrollTop\n });\n },\n // H5 输入框获取焦点 - 滚动到底部让键盘不遮挡\n onInputFocus() {\n if (!this.isMobile) return;\n setTimeout(() => {\n window.scrollTo(0, document.body.scrollHeight);\n }, 300);\n },\n // visualViewport 变化时(如键盘弹出)调整滚动位置\n onViewportResize() {\n if (!this.isMobile) return;\n const activeEl = document.activeElement;\n if (activeEl && (activeEl.tagName === 'TEXTAREA' || activeEl.tagName === 'INPUT')) {\n window.scrollTo(0, document.body.scrollHeight);\n }\n },\n autoResizeTextarea() {\n const ta = this.$refs.taRef;\n if (!ta) return;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n const maxH = isMobile ? 100 : 180;\n ta.style.height = `${baseH}px`;\n const realH = ta.scrollHeight;\n if (realH > baseH) {\n ta.style.height = `${Math.min(realH, maxH)}px`;\n }\n this.$nextTick(() => {\n this.checkScrollBottom(true);\n });\n },\n // Ctrl+Enter换行\n newLine(e) {\n e.preventDefault();\n const ta = this.$refs.taRef;\n const start = ta.selectionStart;\n const end = ta.selectionEnd;\n this.inputText = this.inputText.slice(0, start) + '\\n' + this.inputText.slice(end);\n this.$nextTick(() => {\n ta.selectionStart = ta.selectionEnd = start + 1;\n this.autoResizeTextarea();\n });\n },\n toggleVoiceMode() {\n if (this.voiceMode) {\n this.voiceMode = false;\n this.releaseAudio();\n return;\n }\n // 进入语音模式前提前请求麦克风权限,避免触摸时异步弹窗导致 touchend 丢失\n this.ensureAudioReady().then(ready => {\n if (ready) {\n this.voiceMode = true;\n } else {\n this.voiceMode = false;\n }\n });\n },\n // 确保音频设备已就绪\n async ensureAudioReady() {\n if (this.mediaRecorder && this.audioStream) return true;\n if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {\n alert('当前环境不支持麦克风访问');\n return false;\n }\n if (!window.MediaRecorder) {\n alert('当前浏览器不支持录音功能');\n return false;\n }\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: true\n });\n // ========== 新增:校验音频轨道是否有效(iOS兼容) ==========\n const audioTracks = stream.getAudioTracks();\n if (audioTracks.length === 0 || audioTracks[0].readyState !== 'live') {\n alert('麦克风未正常激活,请重新授权');\n stream.getTracks().forEach(t => t.stop());\n return false;\n }\n this.audioStream = stream;\n\n // 检测平台支持的 MIME 类型\n const mimeType = this._getSupportedMimeType();\n const options = mimeType ? {\n mimeType\n } : {};\n const recorder = new MediaRecorder(stream, options);\n recorder.ondataavailable = e => {\n if (e.data && e.data.size > 0) {\n this.recordedChunks.push(e.data);\n }\n };\n recorder.onstop = () => {\n this._onMediaRecorderStop();\n };\n recorder.onerror = () => {\n console.error('MediaRecorder error');\n this.recordedChunks = [];\n };\n this.mediaRecorder = recorder;\n\n // ========== 改动:AudioContext全局单例,不重复new ==========\n if (!this.audioContext) {\n this.audioContext = new (window.AudioContext || window.webkitAudioContext)();\n }\n if (this.audioContext.state === 'suspended') {\n await this.audioContext.resume();\n }\n return true;\n } catch (err) {\n console.error('麦克风访问失败:', err);\n alert('麦克风权限被拒绝');\n return false;\n }\n },\n _onMediaRecorderStop() {\n if (this.recordedChunks.length === 0) {\n console.warn('录音数据为空');\n return;\n }\n const blob = new Blob(this.recordedChunks, {\n type: this.mediaRecorder.mimeType || 'audio/mp4'\n });\n this.recordedChunks = [];\n\n // 解码压缩音频为 PCM\n const reader = new FileReader();\n reader.onload = () => {\n const arrayBuffer = reader.result;\n this.audioContext.decodeAudioData(arrayBuffer, audioBuffer => {\n // 取第一个声道的数据\n const channelData = audioBuffer.getChannelData(0);\n const wavBlob = this._encodePCMToWavFromBuffer(channelData, audioBuffer.sampleRate);\n this.sendAudioBlob(wavBlob);\n }, err => {\n // ========== 新增:iOS解码失败兜底,直接上传原始mp4 blob ==========\n console.error('decodeAudioData 失败,降级上传原始音频:', err);\n this.sendAudioBlob(blob);\n });\n };\n reader.onerror = () => {\n console.error('FileReader 读取失败,降级上传原始音频');\n this.sendAudioBlob(blob);\n };\n reader.readAsArrayBuffer(blob);\n },\n _encodePCMToWavFromBuffer(channelData, sampleRate) {\n const numChannels = 1;\n const bitsPerSample = 16;\n const byteRate = sampleRate * numChannels * bitsPerSample / 8;\n const blockAlign = numChannels * bitsPerSample / 8;\n const dataSize = channelData.length * (bitsPerSample / 8);\n const headerSize = 44;\n const totalSize = headerSize + dataSize;\n const buffer = new ArrayBuffer(totalSize);\n const view = new DataView(buffer);\n this._wavWriteString(view, 0, 'RIFF');\n view.setUint32(4, totalSize - 8, true);\n this._wavWriteString(view, 8, 'WAVE');\n this._wavWriteString(view, 12, 'fmt ');\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true); // PCM\n view.setUint16(22, numChannels, true);\n view.setUint32(24, sampleRate, true);\n view.setUint32(28, byteRate, true);\n view.setUint16(32, blockAlign, true);\n view.setUint16(34, bitsPerSample, true);\n this._wavWriteString(view, 36, 'data');\n view.setUint32(40, dataSize, true);\n const pcmView = new Int16Array(buffer, headerSize, channelData.length);\n for (let i = 0; i < channelData.length; i++) {\n const s = Math.max(-1, Math.min(1, channelData[i]));\n pcmView[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;\n }\n return new Blob([buffer], {\n type: 'audio/wav'\n });\n },\n // 检测支持的 MIME 类型(改动:优先mp4,兜底mp4防止空)\n _getSupportedMimeType() {\n const types = ['audio/mp4;codecs=mp4a.40.2', 'audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'];\n for (const t of types) {\n if (MediaRecorder.isTypeSupported(t)) return t;\n }\n // iOS兜底强制mp4\n return 'audio/mp4;codecs=mp4a.40.2';\n },\n // H5语音录制 - 触摸开始\n async onRecordTouchStart(e) {\n if (this.sending) return;\n const ready = await this.ensureAudioReady();\n if (!ready) return;\n this.isCancelSwipe = false;\n this.recordStartY = e.touches[0].clientY;\n this.recordDuration = 0;\n this.recordedChunks = [];\n this.isRecording = true;\n // ========== 核心修复:增加1000ms分片,iOS必须带timeslice ==========\n this.mediaRecorder.start(1000);\n this.recordingTimer = setInterval(() => {\n this.recordDuration++;\n if (this.recordDuration >= 60) {\n this.onRecordTouchEnd();\n }\n }, 1000);\n },\n // H5语音录制 - 触摸移动\n onRecordTouchMove(e) {\n if (!this.isRecording) return;\n const touch = e.touches[0];\n const moveY = touch.clientY;\n const cancelThreshold = 60;\n this.isCancelSwipe = this.recordStartY - moveY > cancelThreshold;\n },\n // H5语音录制 - 触摸结束\n // H5语音录制 - 触摸结束\n onRecordTouchEnd() {\n if (!this.isRecording) return;\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n // 上滑取消:丢弃录音数据\n if (this.isCancelSwipe) {\n this.recordedChunks = [];\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n return;\n }\n // 正常发送:停止 MediaRecorder → 触发 onstop → _onMediaRecorderStop → 解码 → 发送\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n },\n // 停止音频采集并输出 WAV\n stopAudioCapture(shouldSend) {\n this.isRecording = false;\n if (shouldSend && this.audioChunks.length > 0) {\n const wavBlob = this.encodePCMToWav(this.audioChunks, this.audioSampleRate || 16000);\n this.sendAudioBlob(wavBlob);\n }\n this.audioChunks = [];\n },\n // 取消录音\n cancelRecording() {\n this.isRecording = false;\n if (this.recordingTimer) {\n clearInterval(this.recordingTimer);\n this.recordingTimer = null;\n }\n if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {\n this.mediaRecorder.stop();\n }\n this.recordedChunks = [];\n this.recordDuration = 0;\n this.isCancelSwipe = false;\n },\n // 释放音频资源\n releaseAudio() {\n this.cancelRecording();\n if (this.mediaRecorder) {\n if (this.mediaRecorder.state !== 'inactive') {\n try {\n this.mediaRecorder.stop();\n } catch (e) {}\n }\n this.mediaRecorder = null;\n }\n if (this.audioSource) {\n this.audioSource.disconnect();\n this.audioSource = null;\n }\n // 只关闭上下文,不清空实例,复用单例\n if (this.audioContext) {\n this.audioContext.close().catch(() => {});\n }\n if (this.audioStream) {\n this.audioStream.getTracks().forEach(t => t.stop());\n this.audioStream = null;\n }\n this.recordedChunks = [];\n this.voiceMode = false;\n },\n // 将 Float32 PCM 数据编码为 WAV Blob\n encodePCMToWav(chunks, sampleRate) {\n let totalLength = 0;\n for (const chunk of chunks) {\n totalLength += chunk.length;\n }\n const pcmData = new Int16Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n for (let i = 0; i < chunk.length; i++) {\n const s = Math.max(-1, Math.min(1, chunk[i]));\n pcmData[offset++] = s < 0 ? s * 0x8000 : s * 0x7FFF;\n }\n }\n const numChannels = 1;\n const bitsPerSample = 16;\n const byteRate = sampleRate * numChannels * bitsPerSample / 8;\n const blockAlign = numChannels * bitsPerSample / 8;\n const dataSize = pcmData.length * (bitsPerSample / 8);\n const headerSize = 44;\n const totalSize = headerSize + dataSize;\n const buffer = new ArrayBuffer(totalSize);\n const view = new DataView(buffer);\n this._wavWriteString(view, 0, 'RIFF');\n view.setUint32(4, totalSize - 8, true);\n this._wavWriteString(view, 8, 'WAVE');\n this._wavWriteString(view, 12, 'fmt ');\n view.setUint32(16, 16, true);\n view.setUint16(20, 1, true);\n view.setUint16(22, numChannels, true);\n view.setUint32(24, sampleRate, true);\n view.setUint32(28, byteRate, true);\n view.setUint16(32, blockAlign, true);\n view.setUint16(34, bitsPerSample, true);\n this._wavWriteString(view, 36, 'data');\n view.setUint32(40, dataSize, true);\n const pcmView = new Int16Array(buffer, headerSize, pcmData.length);\n pcmView.set(pcmData);\n return new Blob([buffer], {\n type: 'audio/wav'\n });\n },\n _wavWriteString(view, offset, str) {\n for (let i = 0; i < str.length; i++) {\n view.setUint8(offset + i, str.charCodeAt(i));\n }\n },\n // 发送音频\n // 发送音频\n sendAudioBlob(blob) {\n let appSeqId = _index2.default.getAppSeqId();\n let name = '';\n // 区分格式命名\n if (blob.type === 'audio/wav') {\n name = appSeqId + '.wav';\n } else {\n name = appSeqId + '.mp4';\n }\n const file = new File([blob], name, {\n type: blob.type\n });\n this.sendAudioMessage(file);\n },\n sendAudioMessage(file) {\n const sttKey = 'stt_' + Date.now();\n const sttMsg = {\n role: 'user',\n content: '转化中...',\n _sttStatus: 'pending',\n _sttKey: sttKey,\n createTime: new Date().toISOString(),\n _key: sttKey\n };\n this.messages.push(sttMsg);\n this.scrollToBottom(true);\n (0, _index.fileUploadToObs)({\n _file: file,\n appSeqId: file.name\n }).then(resp => {\n if (resp.CommonMsg.Status === 200) {\n return _apiObs.default.aliAudioToText({\n url: resp.obs_upload_data.obsUrl\n }).then(result => {\n if (result.code === '200' && result.data) {\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.messages.splice(idx, 1);\n }\n this.sendQuickMsg(result.data);\n } else {\n throw new Error(result.msg || '语音转文字失败');\n }\n });\n } else {\n throw new Error(resp.CommonMsg.Message || '文件上传失败');\n }\n }).catch(err => {\n console.error('语音转文字失败:', err);\n const idx = this.messages.findIndex(m => m._sttKey === sttKey);\n if (idx !== -1) {\n this.$set(this.messages, idx, {\n ...this.messages[idx],\n _sttStatus: 'error',\n content: '语音转文字失败'\n });\n }\n this.$message.error('语音转文字失败');\n });\n this.$message.success(`录音完成 (${this.recordDuration}s)`);\n },\n clearWaitTimer() {\n if (this.waitTimer) {\n clearInterval(this.waitTimer);\n this.waitTimer = null;\n }\n },\n loadMoreMessages() {\n const nextPage = this.msgPage + 1;\n const maxPage = Math.ceil(this.msgTotal / 10);\n if (nextPage > maxPage) {\n this.loadingMore = false;\n return;\n }\n (0, _apichat.getSessionMessages)(this.activeSessionId, nextPage, 10).then(res => {\n const list = Array.isArray(res) ? res : res.list || [];\n this.msgPage = nextPage;\n const asAsc = (list || []).slice().reverse();\n const prepend = asAsc.map(m => ({\n role: m.role,\n content: m.content || '',\n thinking: m.thinking || '',\n toolCalls: [],\n agentName: '',\n createTime: m.createTime || null,\n _key: m.createTime ? 'msg_' + m.createTime : 'msg_' + Date.now() + '_' + Math.random()\n }));\n const container = this.$refs.messagesRef;\n const oldScrollHeight = container ? container.scrollHeight : 0;\n const oldScrollTop = container ? container.scrollTop : 0;\n this.messages = [...prepend, ...this.messages];\n this.$nextTick(() => {\n if (container) {\n container.scrollTop = oldScrollTop + (container.scrollHeight - oldScrollHeight);\n }\n this.loadingMore = false;\n });\n }).catch(() => {\n this.loadingMore = false;\n });\n },\n onScrollMessages() {\n const container = this.$refs.messagesRef;\n if (!container || this.loadingMore) return;\n const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 100;\n this.checkScrollBottom();\n if (isAtBottom) return;\n if (container.scrollTop < 50 && this.msgPage < Math.ceil(this.msgTotal / 10)) {\n this.loadingMore = true;\n this.loadMoreMessages();\n }\n },\n async sendMessage() {\n const text = this.inputText.trim();\n if (!text || this.sending) return;\n if (text.length > 300) {\n this.$message({\n message: '消息内容不能超过300字',\n type: 'warning',\n duration: 2000\n });\n return;\n }\n if (!this.activeSessionId) {\n this.$emit('start-session');\n this._pendingSendText = text;\n return;\n }\n this._sendMessage(text);\n },\n async _sendMessage(text) {\n this.inputText = '';\n const ta = this.$refs.taRef;\n const isMobile = this.isMobile;\n const baseH = isMobile ? 36 : 100;\n if (ta) {\n ta.style.height = baseH + 'px';\n }\n this.showDownBtn = false;\n this.messages.push({\n role: 'user',\n content: text,\n createTime: new Date().toISOString(),\n _key: 'user_' + Date.now() + '_' + Math.random()\n });\n const agentName = this.currentAgentName;\n const msgIdx = this.messages.push({\n role: \"assistant\",\n content: \"\",\n thinking: \"\",\n loading: true,\n toolCalls: [],\n agentName,\n createTime: new Date().toISOString(),\n _key: 'asst_' + Date.now() + '_' + Math.random()\n }) - 1;\n this.scrollToBottom(true);\n this.sending = true;\n this.waitSeconds = 0;\n if (this.waitTimer) clearInterval(this.waitTimer);\n this.waitTimer = setInterval(() => {\n this.waitSeconds++;\n }, 1000);\n let thinkingBuffer = '';\n let finalContent = '';\n let answerBuffer = '';\n let thinkDisplayed = 0;\n let typeTimer = null;\n let streamDone = false;\n typeTimer = setInterval(() => {\n if (!this.messages[msgIdx]) return;\n\n // Phase 1: 逐字展示思考内容(不 return,让 Phase 2 也能同时执行)\n if (thinkDisplayed < thinkingBuffer.length) {\n thinkDisplayed += 50;\n if (thinkDisplayed > thinkingBuffer.length) thinkDisplayed = thinkingBuffer.length;\n this.messages[msgIdx].thinking = thinkingBuffer.substring(0, thinkDisplayed);\n this.scrollToBottom();\n // 不 return,让 Phase 2 也能执行,避免 iOS 上思考内容过长阻塞回答内容展示\n }\n\n // Phase 2: 逐行展示(已完成的行整行出现,未完成的行也即时显示)\n const target = finalContent || answerBuffer;\n const msg = this.messages[msgIdx];\n const revealedLen = msg._revealedLen || 0;\n if (target.length > revealedLen) {\n const remaining = target.substring(revealedLen);\n const nextNewline = remaining.indexOf('\\n');\n if (nextNewline !== -1) {\n // 新的一行已完成 → 整行出现\n const endPos = revealedLen + nextNewline + 1;\n msg._revealedLen = endPos;\n msg.content = target.substring(0, endPos);\n this.scrollToBottom();\n return;\n }\n\n // 当前行未完成 → 逐步展示(每次 30 字符),确保 iOS 上也有渐进效果\n const revealStep = 30;\n const newRevealedLen = Math.min(revealedLen + revealStep, target.length);\n msg._revealedLen = newRevealedLen;\n msg.content = target.substring(0, newRevealedLen);\n this.scrollToBottom();\n return;\n }\n\n // Phase 3: 流已结束且内容已全部展示 → 收尾\n if (streamDone) {\n clearInterval(typeTimer);\n const key = this.messages[msgIdx]._key;\n if (key) {\n this.$delete(this.contentCache, key);\n this.$delete(this.contentCache, key + '_t');\n }\n typeTimer = null;\n this.messages[msgIdx].content = finalContent;\n this.messages[msgIdx].thinking = thinkingBuffer;\n this.messages[msgIdx].loading = false;\n this.sending = false;\n this.clearWaitTimer();\n this.scrollToBottom();\n this.$emit('session-refresh');\n this.$emit('message-sent');\n }\n }, 30);\n const url = (0, _apichat.createStreamUrl)(this.activeSessionId, text);\n if (this.eventSource) {\n this.eventSource.close();\n }\n this.eventSource = new EventSource(url);\n const es = this.eventSource;\n es.addEventListener('reasoning', e => {\n if (e.data) {\n const chunk = e.data;\n let cleanChunk = chunk.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2').replace(/\\s+([:,.!?])/g, '$1').replace(/([:,.!?])\\s+/g, '$1 ');\n cleanChunk = cleanChunk.replace(/\\s+/g, ' ');\n const lastChar = thinkingBuffer.slice(-1);\n const firstChar = cleanChunk.slice(0, 1);\n const isLetter = c => /[a-zA-Z]/.test(c);\n if (isLetter(lastChar) && isLetter(firstChar)) {\n thinkingBuffer += ' ' + cleanChunk;\n } else {\n thinkingBuffer += cleanChunk;\n }\n }\n });\n es.addEventListener('reasoning_result', e => {\n if (e.data) {\n thinkingBuffer = e.data;\n }\n });\n es.addEventListener('answer', e => {\n if (e.data) {\n answerBuffer += e.data.replace(/([a-zA-Z]+)([\\u4e00-\\u9fff])/g, '$1 $2').replace(/([\\u4e00-\\u9fff])([a-zA-Z]+)/g, '$1 $2').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-zA-Z])(\\d)/g, '$1 $2').replace(/(\\d)([a-zA-Z])/g, '$1 $2');\n }\n });\n es.addEventListener('tool_result', e => {\n if (e.data && this.messages[msgIdx]) {\n if (!this.messages[msgIdx].toolCalls) this.messages[msgIdx].toolCalls = [];\n this.messages[msgIdx].toolCalls.push(e.data);\n this.scrollToBottom();\n }\n });\n es.addEventListener('agent_result', e => {\n finalContent = e.data || '';\n streamDone = true;\n es.close();\n this.eventSource = null;\n });\n es.onerror = err => {\n console.log('[SSE] onerror', err);\n if (thinkingBuffer.length > 0 && !streamDone) finalContent = thinkingBuffer;\n if (!streamDone) finalContent = '❌ 连接中断';\n streamDone = true;\n es.close();\n this.eventSource = null;\n };\n },\n stopAgent() {\n if (this.eventSource) {\n this.eventSource.close();\n this.eventSource = null;\n }\n if (this.activeSessionId) {\n (0, _apichat.stopStream)(this.activeSessionId).catch(() => {});\n }\n if (this.messages.length > 0) {\n const last = this.messages[this.messages.length - 1];\n if (last && last.loading) {\n last.content = last.content || last.thinking || '⏹ 已停止';\n last.loading = false;\n this.$delete(this.contentCache, last._key);\n }\n }\n this.sending = false;\n this.clearWaitTimer();\n },\n scrollToTop() {\n const el = this.$refs.messagesRef;\n if (el) el.scrollTop = 0;\n },\n scrollToBottom(force = false) {\n if (this.showDownBtn && !force) return;\n this.$nextTick(() => {\n const el = this.$refs.messagesRef;\n if (el) {\n el.scrollTop = el.scrollHeight;\n this.lastScrollTop = el.scrollTop;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n }\n });\n },\n scrollToBottomOnce() {\n clearTimeout(this._scrollTimer);\n this.$nextTick(() => {\n const tryScroll = () => {\n const el = this.$refs.messagesRef;\n if (!el) {\n this._scrollTimer = setTimeout(tryScroll, 50);\n return;\n }\n const originalScrollBehavior = el.style.scrollBehavior;\n el.style.scrollBehavior = 'auto';\n el.scrollTop = el.scrollHeight - el.clientHeight;\n el.style.scrollBehavior = originalScrollBehavior;\n this.showDownBtn = false;\n this._userScrolledUp = false;\n };\n tryScroll();\n });\n },\n copyText(content) {\n if (!content) return;\n const text = content.replace(/<[^>]+>/g, '');\n\n // 优先使用 Clipboard API(HTTPS 环境)\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(() => {\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n this._fallbackCopyText(text);\n });\n } else {\n this._fallbackCopyText(text);\n }\n },\n _fallbackCopyText(text) {\n try {\n const ta = document.createElement('textarea');\n ta.value = text;\n ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';\n document.body.appendChild(ta);\n ta.focus();\n ta.select();\n document.execCommand('copy');\n document.body.removeChild(ta);\n this.$message({\n message: '已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n } catch {\n this.$message({\n message: '复制失败',\n type: 'error',\n duration: 2000\n });\n }\n },\n copyAsImage(content) {\n if (!content) return;\n const div = document.createElement('div');\n div.className = 'copy-image-render';\n div.style.cssText = 'position:fixed;left:-9999px;top:0;width:600px;padding:20px;background:#fff;font-size:14px;line-height:1.6;color:#333;border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;';\n // 内联表格样式(因 Vue 作用域样式不覆盖复制用的脱离文档流 div)\n div.innerHTML = this.renderMarkdown(content) + '<style>table{border-collapse:collapse;width:100%;margin:6px 0;font-size:14px}' + 'th,td{border:1px solid #e8e8e8;padding:5px 8px;text-align:left}' + 'th{background:#fafafa;font-weight:600}</style>';\n document.body.appendChild(div);\n this.$message({\n message: '正在生成图片...',\n type: 'info',\n duration: 2000\n });\n (0, _html2canvas.default)(div, {\n scale: 2,\n useCORS: true,\n backgroundColor: '#ffffff'\n }).then(canvas => {\n // 检测是否支持 ClipboardItem(部分 WebView 不支持)\n const canCopyImage = typeof ClipboardItem !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.write === 'function';\n if (canCopyImage) {\n canvas.toBlob(blob => {\n if (blob) {\n navigator.clipboard.write([new ClipboardItem({\n 'image/png': blob\n })]).then(() => {\n this.$message({\n message: '图片已复制到剪贴板',\n type: 'success',\n duration: 1500\n });\n }).catch(() => {\n // ClipboardItem 存在但写入失败 → 降级下载\n this._downloadCanvas(canvas);\n });\n }\n }, 'image/png');\n } else {\n // 不支持剪贴板写图片 → 直接下载\n this._downloadCanvas(canvas);\n }\n }).catch(() => {\n this.$message({\n message: '生成图片失败',\n type: 'error',\n duration: 2000\n });\n }).finally(() => {\n document.body.removeChild(div);\n });\n },\n _downloadCanvas(canvas) {\n const link = document.createElement('a');\n link.download = '消息图片.png';\n link.href = canvas.toDataURL('image/png');\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n this.$message({\n message: '已保存图片到本地',\n type: 'success',\n duration: 2000\n });\n }\n }\n};\n\n//# sourceURL=webpack://ZjPluginIntelligent/./src/lib/components/chatMain/ChatDetail.vue?./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--13-0!./node_modules/_babel-loader@8.4.1@babel-loader/lib!./node_modules/_cache-loader@4.1.0@cache-loader/dist/cjs.js??ref--1-0!./node_modules/_vue-loader@15.11.1@vue-loader/lib??vue-loader-options");
|
|
964
964
|
|
|
965
965
|
/***/ }),
|
|
966
966
|
|
|
@@ -12617,7 +12617,7 @@ eval("module.exports = function(originalModule) {\n\tif (!originalModule.webpack
|
|
|
12617
12617
|
/*! exports provided: name, version, main, private, scripts, dependencies, devDependencies, browserslist, directories, keywords, license, default */
|
|
12618
12618
|
/***/ (function(module) {
|
|
12619
12619
|
|
|
12620
|
-
eval("module.exports = JSON.parse(\"{\\\"name\\\":\\\"zj-plugin-intelligent\\\",\\\"version\\\":\\\"1.2.5-beta.
|
|
12620
|
+
eval("module.exports = JSON.parse(\"{\\\"name\\\":\\\"zj-plugin-intelligent\\\",\\\"version\\\":\\\"1.2.5-beta.30\\\",\\\"main\\\":\\\"lib/ZjPluginIntelligent.umd.min.js\\\",\\\"private\\\":false,\\\"scripts\\\":{\\\"serve\\\":\\\"vue-cli-service serve\\\",\\\"build\\\":\\\"vue-cli-service build\\\",\\\"lib\\\":\\\"vue-cli-service build --target lib --name ZjPluginIntelligent --dest lib src/lib/index.js\\\",\\\"lib:test\\\":\\\"vue-cli-service build --mode test --target lib --name ZjPluginIntelligent --dest lib src/lib/index.js\\\"},\\\"dependencies\\\":{\\\"html2canvas\\\":\\\"^1.4.1\\\",\\\"markdown-it\\\":\\\"^14.1.0\\\"},\\\"devDependencies\\\":{\\\"@babel/preset-env\\\":\\\"^7.14.9\\\",\\\"@vue/cli-plugin-babel\\\":\\\"~4.5.0\\\",\\\"@vue/cli-plugin-eslint\\\":\\\"^4.5.13\\\",\\\"@vue/cli-plugin-vuex\\\":\\\"~4.5.0\\\",\\\"@vue/cli-service\\\":\\\"~4.5.0\\\",\\\"axios\\\":\\\"^0.27.2\\\",\\\"babel-eslint\\\":\\\"^10.1.0\\\",\\\"babel-plugin-component\\\":\\\"^1.1.1\\\",\\\"compression-webpack-plugin\\\":\\\"^6.0.3\\\",\\\"core-js\\\":\\\"^3.6.5\\\",\\\"element-ui\\\":\\\"^2.15.4\\\",\\\"eslint\\\":\\\"^7.30.0\\\",\\\"eslint-plugin-vue\\\":\\\"^7.13.0\\\",\\\"js-cookie\\\":\\\"^2.2.1\\\",\\\"node-sass\\\":\\\"^4.12.0\\\",\\\"obs-upload\\\":\\\"^1.0.4-alpha.0\\\",\\\"reconnecting-websocket\\\":\\\"^4.4.0\\\",\\\"sass-loader\\\":\\\"^8.0.2\\\",\\\"snowflake-id\\\":\\\"^1.1.0\\\",\\\"vue\\\":\\\"^2.6.11\\\",\\\"vue-template-compiler\\\":\\\"^2.6.11\\\",\\\"vuex\\\":\\\"^3.4.0\\\",\\\"pako\\\":\\\"^2.1.0\\\",\\\"webpack-bundle-analyzer\\\":\\\"^4.4.2\\\"},\\\"browserslist\\\":[\\\"> 1%\\\",\\\"last 2 versions\\\",\\\"not dead\\\"],\\\"directories\\\":{\\\"lib\\\":\\\"lib\\\"},\\\"keywords\\\":[],\\\"license\\\":\\\"ISC\\\"}\");\n\n//# sourceURL=webpack://ZjPluginIntelligent/./package.json?");
|
|
12621
12621
|
|
|
12622
12622
|
/***/ }),
|
|
12623
12623
|
|
|
@@ -13509,7 +13509,7 @@ eval("\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n
|
|
|
13509
13509
|
/***/ (function(module, exports, __webpack_require__) {
|
|
13510
13510
|
|
|
13511
13511
|
"use strict";
|
|
13512
|
-
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\nexports.getAuth = getAuth;\nexports.setAuth = setAuth;\nexports.setBaseURL = setBaseURL;\nexports.setToken = setToken;\nexports.signQueryParams = signQueryParams;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.filter.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.filter.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\nvar _axios = _interopRequireDefault(__webpack_require__(/*! axios */ \"./node_modules/_axios@0.27.2@axios/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _message = __webpack_require__(/*! @lib/utils/message.js */ \"./src/lib/utils/message.js\");\n/*\r\n * @Author: 高瑞廷 2419056691@qq.com\r\n * @Date: 2026-06-04 11:27:13\r\n * @LastEditors: 高瑞廷 2419056691@qq.com\r\n * @LastEditTime: 2026-07-
|
|
13512
|
+
eval("\n\nvar _interopRequireDefault = __webpack_require__(/*! ./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js */ \"./node_modules/_@babel_runtime@7.28.4@@babel/runtime/helpers/interopRequireDefault.js\").default;\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.default = void 0;\nexports.getAuth = getAuth;\nexports.setAuth = setAuth;\nexports.setBaseURL = setBaseURL;\nexports.setToken = setToken;\nexports.signQueryParams = signQueryParams;\n__webpack_require__(/*! core-js/modules/es.error.cause.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.error.cause.js\");\n__webpack_require__(/*! core-js/modules/es.array.push.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.array.push.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.constructor.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.constructor.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.filter.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.filter.js\");\n__webpack_require__(/*! core-js/modules/es.iterator.map.js */ \"./node_modules/_core-js@3.45.1@core-js/modules/es.iterator.map.js\");\nvar _axios = _interopRequireDefault(__webpack_require__(/*! axios */ \"./node_modules/_axios@0.27.2@axios/index.js\"));\nvar _cookie = __webpack_require__(/*! @lib/utils/cookie.js */ \"./src/lib/utils/cookie.js\");\nvar _message = __webpack_require__(/*! @lib/utils/message.js */ \"./src/lib/utils/message.js\");\n/*\r\n * @Author: 高瑞廷 2419056691@qq.com\r\n * @Date: 2026-06-04 11:27:13\r\n * @LastEditors: 高瑞廷 2419056691@qq.com\r\n * @LastEditTime: 2026-07-16 14:14:10\r\n * @FilePath: \\zjkj-nodejs-npm_customer-client\\src\\lib\\utils\\request.js\r\n * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE\r\n */\n\nlet runtimeAK = Object({\"NODE_ENV\":\"test\",\"VUE_APP_API\":\"https://api.qgbtech.cn\",\"VUE_APP_BASE_API\":\"https://ai.jybtech.cn\",\"VUE_APP_NAME\":\"development\",\"BASE_URL\":\"/\"}).VUE_APP_BASE_AK || '';\nlet runtimeSK = Object({\"NODE_ENV\":\"test\",\"VUE_APP_API\":\"https://api.qgbtech.cn\",\"VUE_APP_BASE_API\":\"https://ai.jybtech.cn\",\"VUE_APP_NAME\":\"development\",\"BASE_URL\":\"/\"}).VUE_APP_BASE_SK || '';\nlet runtimeToken = '';\n\n// let _user = '202007091611352'\nlet _user = '';\n// let _companyNo = '201912241159840'\nlet _companyNo = '';\nlet runtimeBaseURL = \"https://ai.jybtech.cn\" || false;\nconst CryptoJS = __webpack_require__(/*! crypto-js */ \"./node_modules/_crypto-js@4.2.0@crypto-js/index.js\");\nconst service = _axios.default.create({\n baseURL: runtimeBaseURL,\n timeout: 15000\n});\nfunction hmacSha256Base64(rawSignStr, sk) {\n const hmac = CryptoJS.HmacSHA256(rawSignStr, sk);\n // 转为 Base64\n return CryptoJS.enc.Base64.stringify(hmac);\n}\nfunction setAuth(ak, sk, user, companyNo, token) {\n runtimeAK = ak || '';\n runtimeSK = sk || '';\n _user = user;\n _companyNo = companyNo;\n runtimeToken = token || '';\n}\nfunction setToken(token) {\n runtimeToken = token || '';\n}\nfunction getAuth() {\n return {\n ak: runtimeAK,\n sk: runtimeSK,\n token: runtimeToken,\n user: _user,\n companyNo: _companyNo\n };\n}\nfunction setBaseURL(url) {\n runtimeBaseURL = url || '';\n service.defaults.baseURL = runtimeBaseURL;\n}\nfunction signQueryParams(params, secretKey) {\n const keys = Object.keys(params).filter(k => k !== 'ak' && k !== 'sign').sort();\n const raw = keys.map(k => `${k}=${params[k] || ''}`).join('&');\n return hmacSha256Base64(raw, secretKey);\n}\nservice.interceptors.request.use(config => {\n // 流式接口跳过 Header 签名\n if (config.url.includes('/chat/stream')) {\n return config;\n }\n const ts = Date.now().toString();\n const token = runtimeToken || (0, _cookie.getToken)();\n const externalUser = _user || '';\n const signParts = [];\n // 1. 第一位:token(有值才加入)\n if (_companyNo) {\n signParts.push(`companyNo=${_companyNo}`);\n }\n if (token) {\n signParts.push(`token=${token}`);\n }\n // 2. 第二位:user(有值才加入)\n if (externalUser) {\n signParts.push(`user=${externalUser}`);\n }\n // 3. 第三位:ts(必选,永远存在)\n signParts.push(`ts=${ts}`);\n\n // 拼接最终待签字符串\n const signStr = signParts.join('&');\n // 计算签名\n const sign = hmacSha256Base64(signStr, runtimeSK);\n if (_companyNo) {\n config.headers['X-Company-No'] = _companyNo;\n }\n\n // 设置请求头\n config.headers['X-AK'] = runtimeAK;\n config.headers['X-Ts'] = ts;\n config.headers['X-Sign'] = sign;\n if (token) {\n config.headers['X-Auth-Token'] = `${token}`;\n config.headers.Authorization = `Bearer ${token}`;\n }\n config.headers['X-External-User'] = externalUser;\n config.headers['Content-Type'] = 'application/json';\n\n // console.log('排序后key:', sortedKeys)\n\n return config;\n}, error => Promise.reject(error));\nservice.interceptors.response.use(response => {\n const res = response.data;\n if (res.code === 200) {\n return res;\n } else if (res.code === 40101) {\n (0, _message.showMessage)('登录已过期,请重新登录');\n return Promise.reject(new Error(res.message));\n } else {\n (0, _message.showMessage)(res.message || '请求失败');\n return Promise.reject(new Error(res.message));\n }\n}, error => {\n (0, _message.showMessage)(error.message || '网络异常');\n return Promise.reject(error);\n});\nvar _default = exports.default = service;\n\n//# sourceURL=webpack://ZjPluginIntelligent/./src/lib/utils/request.js?");
|
|
13513
13513
|
|
|
13514
13514
|
/***/ }),
|
|
13515
13515
|
|