From 9ac696d629a79d1f3f81642a0c0df01c96cd1ef2 Mon Sep 17 00:00:00 2001 From: hanbings Date: Sun, 22 Feb 2026 17:00:12 -0500 Subject: [PATCH 1/3] fix(chatui): add copy rollback path and error message. --- dashboard/src/components/chat/MessageList.vue | 204 ++++++++++++------ .../src/i18n/locales/en-US/core/common.json | 1 + .../src/i18n/locales/zh-CN/core/common.json | 1 + 3 files changed, 141 insertions(+), 65 deletions(-) diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index 42129d7922..da06c883b9 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -143,8 +143,8 @@ + :class="{ 'copy-success': isCopySuccess(index), 'copy-failed': isCopyFailure(index) }" + @click="copyBotMessage(msg.content.message, index)" :title="getCopyTitle(index)" /> @@ -185,6 +185,7 @@ import 'markstream-vue/index.css' import 'katex/dist/katex.min.css' import 'highlight.js/styles/github.css'; import axios from 'axios'; +import { useToast } from '@/utils/toast' import ReasoningBlock from './message_list_comps/ReasoningBlock.vue'; import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue'; import RefNode from './message_list_comps/RefNode.vue'; @@ -226,10 +227,12 @@ export default { setup() { const { t } = useI18n(); const { tm } = useModuleI18n('features/chat'); + const toast = useToast() return { t, - tm + tm, + toast }; }, provide() { @@ -241,6 +244,7 @@ export default { data() { return { copiedMessages: new Set(), + copyFailedMessages: new Set(), isUserNearBottom: true, scrollThreshold: 1, scrollTimer: null, @@ -496,91 +500,133 @@ export default { }, // 复制代码到剪贴板 - copyCodeToClipboard(code) { - navigator.clipboard.writeText(code).then(() => { - console.log('代码已复制到剪贴板'); - }).catch(err => { - console.error('复制失败:', err); - // 如果现代API失败,使用传统方法 + tryExecCommandCopy(text) { + try { const textArea = document.createElement('textarea'); - textArea.value = code; + textArea.value = text; document.body.appendChild(textArea); + textArea.focus(); textArea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textArea); + return ok; + } catch (_) { + return false; + } + }, + + async copyTextToClipboard(text) { + // 优先使用同步复制,尽量保留用户手势上下文; + // 在非安全来源(例如通过局域网 IP + vite --host)时成功率更高。 + if (this.tryExecCommandCopy(text)) { + return true; + } + + if (navigator.clipboard?.writeText) { try { - document.execCommand('copy'); - console.log('代码已复制到剪贴板 (fallback)'); - } catch (fallbackErr) { - console.error('复制失败 (fallback):', fallbackErr); + await navigator.clipboard.writeText(text); + return true; + } catch (_) { + return false; } - document.body.removeChild(textArea); - }); + } + + return false; }, - // 复制bot消息到剪贴板 - copyBotMessage(messageParts, messageIndex) { - let textToCopy = ''; + buildCopyTextFromParts(messageParts) { + if (typeof messageParts === 'string') { + return messageParts.trim(); + } + if (!Array.isArray(messageParts)) { + return ''; + } - if (Array.isArray(messageParts)) { - // 提取所有文本内容 - const textContents = messageParts - .filter(part => part.type === 'plain' && part.text) - .map(part => part.text); - textToCopy = textContents.join('\n'); + const textContents = messageParts + .filter(part => part && typeof part === 'object' && part.type === 'plain' && part.text) + .map(part => part.text); - // 检查是否有图片 - const imageCount = messageParts.filter(part => part.type === 'image' && part.embedded_url).length; - if (imageCount > 0) { - if (textToCopy) textToCopy += '\n\n'; - textToCopy += `[包含 ${imageCount} 张图片]`; - } + let textToCopy = textContents.join('\n'); - // 检查是否有音频 - const hasAudio = messageParts.some(part => part.type === 'record' && part.embedded_url); - if (hasAudio) { - if (textToCopy) textToCopy += '\n\n'; - textToCopy += '[包含音频内容]'; - } + const imageCount = messageParts.filter(part => part?.type === 'image' && part.embedded_url).length; + if (imageCount > 0) { + if (textToCopy) textToCopy += '\n\n'; + textToCopy += `[包含 ${imageCount} 张图片]`; } - // 如果没有任何内容,使用默认文本 - if (!textToCopy.trim()) { - textToCopy = '[媒体内容]'; + const hasAudio = messageParts.some(part => part?.type === 'record' && part.embedded_url); + if (hasAudio) { + if (textToCopy) textToCopy += '\n\n'; + textToCopy += '[包含音频内容]'; + } + + return String(textToCopy || '').trim(); + }, + + async copyCodeToClipboard(code) { + const text = String(code ?? ''); + if (!text) return false; + + const ok = await this.copyTextToClipboard(text); + if (ok) { + this.toast?.success?.(this.t('core.common.copied')); + } else { + this.toast?.error?.(this.t('core.common.copyFailed')); } + return ok; + }, - navigator.clipboard.writeText(textToCopy).then(() => { - console.log('消息已复制到剪贴板'); + // 复制bot消息到剪贴板 + async copyBotMessage(messageParts, messageIndex) { + let textToCopy = this.buildCopyTextFromParts(messageParts); + if (!textToCopy) textToCopy = '[媒体内容]'; + + const ok = await this.copyTextToClipboard(textToCopy); + if (ok) { this.showCopySuccess(messageIndex); - }).catch(err => { - console.error('复制失败:', err); - // 如果现代API失败,使用传统方法 - const textArea = document.createElement('textarea'); - textArea.value = textToCopy; - document.body.appendChild(textArea); - textArea.select(); - try { - document.execCommand('copy'); - console.log('消息已复制到剪贴板 (fallback)'); - this.showCopySuccess(messageIndex); - } catch (fallbackErr) { - console.error('复制失败 (fallback):', fallbackErr); - } - document.body.removeChild(textArea); - }); + this.toast?.success?.(this.t('core.common.copied')); + } else { + this.showCopyFailure(messageIndex); + this.toast?.error?.(this.t('core.common.copyFailed')); + } }, // 显示复制成功提示 showCopySuccess(messageIndex) { + if (this.copyFailedMessages.has(messageIndex)) { + this.copyFailedMessages.delete(messageIndex); + this.copyFailedMessages = new Set(this.copyFailedMessages); + } this.copiedMessages.add(messageIndex); + this.copiedMessages = new Set(this.copiedMessages); // 2秒后移除成功状态 setTimeout(() => { this.copiedMessages.delete(messageIndex); + this.copiedMessages = new Set(this.copiedMessages); + }, 2000); + }, + + // 显示复制失败提示 + showCopyFailure(messageIndex) { + if (this.copiedMessages.has(messageIndex)) { + this.copiedMessages.delete(messageIndex); + this.copiedMessages = new Set(this.copiedMessages); + } + this.copyFailedMessages.add(messageIndex); + this.copyFailedMessages = new Set(this.copyFailedMessages); + + setTimeout(() => { + this.copyFailedMessages.delete(messageIndex); + this.copyFailedMessages = new Set(this.copyFailedMessages); }, 2000); }, // 获取复制按钮图标 getCopyIcon(messageIndex) { - return this.copiedMessages.has(messageIndex) ? 'mdi-check' : 'mdi-content-copy'; + if (this.copiedMessages.has(messageIndex)) return 'mdi-check'; + if (this.copyFailedMessages.has(messageIndex)) return 'mdi-alert-circle-outline'; + return 'mdi-content-copy'; }, // 检查是否为复制成功状态 @@ -588,6 +634,18 @@ export default { return this.copiedMessages.has(messageIndex); }, + // 检查是否为复制失败状态 + isCopyFailure(messageIndex) { + return this.copyFailedMessages.has(messageIndex); + }, + + // 获取复制按钮提示文本 + getCopyTitle(messageIndex) { + if (this.isCopySuccess(messageIndex)) return this.t('core.common.copied'); + if (this.isCopyFailure(messageIndex)) return this.t('core.common.copyFailed'); + return this.t('core.common.copy'); + }, + // 获取复制图标SVG getCopyIconSvg() { return ''; @@ -598,6 +656,11 @@ export default { return ''; }, + // 获取失败图标SVG + getErrorIconSvg() { + return ''; + }, + // 初始化代码块复制按钮 initCodeCopyButtons() { this.$nextTick(() => { @@ -608,15 +671,16 @@ export default { const button = document.createElement('button'); button.className = 'copy-code-btn'; button.innerHTML = this.getCopyIconSvg(); - button.title = '复制代码'; - button.addEventListener('click', () => { - this.copyCodeToClipboard(codeBlock.textContent); - // 显示复制成功提示 - button.innerHTML = this.getSuccessIconSvg(); - button.style.color = '#4caf50'; + button.title = this.t('core.common.copy'); + button.addEventListener('click', async () => { + const ok = await this.copyCodeToClipboard(codeBlock.textContent || ''); + button.innerHTML = ok ? this.getSuccessIconSvg() : this.getErrorIconSvg(); + button.style.color = ok ? '#4caf50' : '#f44336'; + button.setAttribute("title", this.t(`core.common.${ok ? "copied" : "copyFailed"}`)); setTimeout(() => { button.innerHTML = this.getCopyIconSvg(); button.style.color = ''; + button.setAttribute("title", this.t('core.common.copy')); }, 2000); }); pre.style.position = 'relative'; @@ -1086,6 +1150,16 @@ export default { background-color: rgba(76, 175, 80, 0.1); } +.copy-message-btn.copy-failed { + color: #f44336; + opacity: 1; +} + +.copy-message-btn.copy-failed:hover { + color: #f44336; + background-color: rgba(244, 67, 54, 0.1); +} + .reply-message-btn { opacity: 0.6; transition: all 0.2s ease; diff --git a/dashboard/src/i18n/locales/en-US/core/common.json b/dashboard/src/i18n/locales/en-US/core/common.json index e89a1ce526..973feea542 100644 --- a/dashboard/src/i18n/locales/en-US/core/common.json +++ b/dashboard/src/i18n/locales/en-US/core/common.json @@ -4,6 +4,7 @@ "close": "Close", "copy": "Copy", "copied": "Copied", + "copyFailed": "Copy failed", "delete": "Delete", "edit": "Edit", "add": "Add", diff --git a/dashboard/src/i18n/locales/zh-CN/core/common.json b/dashboard/src/i18n/locales/zh-CN/core/common.json index 171dfbf16f..fa1a8bf8b6 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/common.json +++ b/dashboard/src/i18n/locales/zh-CN/core/common.json @@ -4,6 +4,7 @@ "close": "关闭", "copy": "复制", "copied": "已复制", + "copyFailed": "复制失败", "delete": "删除", "edit": "编辑", "add": "添加", From 2cd60b979f51245ae58ac05304933af404f34173 Mon Sep 17 00:00:00 2001 From: hanbings Date: Sun, 22 Feb 2026 17:38:59 -0500 Subject: [PATCH 2/3] fix(chatui): fixed textarea leak in the copy button. --- dashboard/src/components/chat/MessageList.vue | 62 +++++++++++-------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index da06c883b9..1e7ac9b17a 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -501,17 +501,23 @@ export default { // 复制代码到剪贴板 tryExecCommandCopy(text) { + let textArea = null; try { - const textArea = document.createElement('textarea'); + textArea = document.createElement('textarea'); textArea.value = text; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const ok = document.execCommand('copy'); - document.body.removeChild(textArea); return ok; } catch (_) { return false; + } finally { + try { + textArea?.remove?.(); + } catch (_) { + // ignore cleanup errors + } } }, @@ -519,19 +525,37 @@ export default { // 优先使用同步复制,尽量保留用户手势上下文; // 在非安全来源(例如通过局域网 IP + vite --host)时成功率更高。 if (this.tryExecCommandCopy(text)) { - return true; + return { ok: true, method: 'execCommand' }; } if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); - return true; - } catch (_) { - return false; + return { ok: true, method: 'clipboard' }; + } catch (error) { + return { ok: false, method: 'clipboard', error }; } } - return false; + return { ok: false, method: 'unavailable' }; + }, + + async copyWithFeedback(text, messageIndex = null) { + const result = await this.copyTextToClipboard(text); + const ok = !!result?.ok; + + if (messageIndex !== null && messageIndex !== undefined) { + if (ok) this.showCopySuccess(messageIndex); + else this.showCopyFailure(messageIndex); + } + + if (ok) { + this.toast?.success?.(this.t('core.common.copied')); + } else { + this.toast?.error?.(this.t('core.common.copyFailed')); + } + + return result; }, buildCopyTextFromParts(messageParts) { @@ -565,30 +589,15 @@ export default { async copyCodeToClipboard(code) { const text = String(code ?? ''); - if (!text) return false; - - const ok = await this.copyTextToClipboard(text); - if (ok) { - this.toast?.success?.(this.t('core.common.copied')); - } else { - this.toast?.error?.(this.t('core.common.copyFailed')); - } - return ok; + if (!text) return { ok: false, method: 'empty' }; + return await this.copyWithFeedback(text, null); }, // 复制bot消息到剪贴板 async copyBotMessage(messageParts, messageIndex) { let textToCopy = this.buildCopyTextFromParts(messageParts); if (!textToCopy) textToCopy = '[媒体内容]'; - - const ok = await this.copyTextToClipboard(textToCopy); - if (ok) { - this.showCopySuccess(messageIndex); - this.toast?.success?.(this.t('core.common.copied')); - } else { - this.showCopyFailure(messageIndex); - this.toast?.error?.(this.t('core.common.copyFailed')); - } + await this.copyWithFeedback(textToCopy, messageIndex); }, // 显示复制成功提示 @@ -673,7 +682,8 @@ export default { button.innerHTML = this.getCopyIconSvg(); button.title = this.t('core.common.copy'); button.addEventListener('click', async () => { - const ok = await this.copyCodeToClipboard(codeBlock.textContent || ''); + const res = await this.copyCodeToClipboard(codeBlock.textContent || ''); + const ok = !!res?.ok; button.innerHTML = ok ? this.getSuccessIconSvg() : this.getErrorIconSvg(); button.style.color = ok ? '#4caf50' : '#f44336'; button.setAttribute("title", this.t(`core.common.${ok ? "copied" : "copyFailed"}`)); From bbfb263207f7728330f609fd0335ceb9dfa4ae36 Mon Sep 17 00:00:00 2001 From: hanbings Date: Sun, 22 Feb 2026 17:44:55 -0500 Subject: [PATCH 3/3] fix(chatui): use color styles from the component library. --- dashboard/src/components/chat/MessageList.vue | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index 1e7ac9b17a..e30a34a401 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -685,7 +685,9 @@ export default { const res = await this.copyCodeToClipboard(codeBlock.textContent || ''); const ok = !!res?.ok; button.innerHTML = ok ? this.getSuccessIconSvg() : this.getErrorIconSvg(); - button.style.color = ok ? '#4caf50' : '#f44336'; + button.style.color = ok + ? 'rgb(var(--v-theme-success))' + : 'rgb(var(--v-theme-error))'; button.setAttribute("title", this.t(`core.common.${ok ? "copied" : "copyFailed"}`)); setTimeout(() => { button.innerHTML = this.getCopyIconSvg(); @@ -1151,23 +1153,23 @@ export default { } .copy-message-btn.copy-success { - color: #4caf50; + color: rgb(var(--v-theme-success)); opacity: 1; } .copy-message-btn.copy-success:hover { - color: #4caf50; - background-color: rgba(76, 175, 80, 0.1); + color: rgb(var(--v-theme-success)); + background-color: rgba(var(--v-theme-success), 0.1); } .copy-message-btn.copy-failed { - color: #f44336; + color: rgb(var(--v-theme-error)); opacity: 1; } .copy-message-btn.copy-failed:hover { - color: #f44336; - background-color: rgba(244, 67, 54, 0.1); + color: rgb(var(--v-theme-error)); + background-color: rgba(var(--v-theme-error), 0.1); } .reply-message-btn {