diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue
index 42129d792..e30a34a40 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,142 @@ export default {
},
// 复制代码到剪贴板
- copyCodeToClipboard(code) {
- navigator.clipboard.writeText(code).then(() => {
- console.log('代码已复制到剪贴板');
- }).catch(err => {
- console.error('复制失败:', err);
- // 如果现代API失败,使用传统方法
- const textArea = document.createElement('textarea');
- textArea.value = code;
+ tryExecCommandCopy(text) {
+ let textArea = null;
+ try {
+ textArea = document.createElement('textarea');
+ textArea.value = text;
document.body.appendChild(textArea);
+ textArea.focus();
textArea.select();
+ const ok = document.execCommand('copy');
+ return ok;
+ } catch (_) {
+ return false;
+ } finally {
try {
- document.execCommand('copy');
- console.log('代码已复制到剪贴板 (fallback)');
- } catch (fallbackErr) {
- console.error('复制失败 (fallback):', fallbackErr);
+ textArea?.remove?.();
+ } catch (_) {
+ // ignore cleanup errors
}
- document.body.removeChild(textArea);
- });
+ }
},
- // 复制bot消息到剪贴板
- copyBotMessage(messageParts, messageIndex) {
- let textToCopy = '';
-
- if (Array.isArray(messageParts)) {
- // 提取所有文本内容
- const textContents = messageParts
- .filter(part => part.type === 'plain' && part.text)
- .map(part => part.text);
- textToCopy = textContents.join('\n');
+ async copyTextToClipboard(text) {
+ // 优先使用同步复制,尽量保留用户手势上下文;
+ // 在非安全来源(例如通过局域网 IP + vite --host)时成功率更高。
+ if (this.tryExecCommandCopy(text)) {
+ return { ok: true, method: 'execCommand' };
+ }
- // 检查是否有图片
- const imageCount = messageParts.filter(part => part.type === 'image' && part.embedded_url).length;
- if (imageCount > 0) {
- if (textToCopy) textToCopy += '\n\n';
- textToCopy += `[包含 ${imageCount} 张图片]`;
+ if (navigator.clipboard?.writeText) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return { ok: true, method: 'clipboard' };
+ } catch (error) {
+ return { ok: false, method: 'clipboard', error };
}
+ }
- // 检查是否有音频
- const hasAudio = messageParts.some(part => part.type === 'record' && part.embedded_url);
- if (hasAudio) {
- if (textToCopy) textToCopy += '\n\n';
- textToCopy += '[包含音频内容]';
- }
+ 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 (!textToCopy.trim()) {
- textToCopy = '[媒体内容]';
+ if (ok) {
+ this.toast?.success?.(this.t('core.common.copied'));
+ } else {
+ this.toast?.error?.(this.t('core.common.copyFailed'));
}
- navigator.clipboard.writeText(textToCopy).then(() => {
- console.log('消息已复制到剪贴板');
- 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);
- });
+ return result;
+ },
+
+ buildCopyTextFromParts(messageParts) {
+ if (typeof messageParts === 'string') {
+ return messageParts.trim();
+ }
+ if (!Array.isArray(messageParts)) {
+ return '';
+ }
+
+ const textContents = messageParts
+ .filter(part => part && typeof part === 'object' && part.type === 'plain' && part.text)
+ .map(part => part.text);
+
+ let textToCopy = textContents.join('\n');
+
+ const imageCount = messageParts.filter(part => part?.type === 'image' && part.embedded_url).length;
+ if (imageCount > 0) {
+ if (textToCopy) textToCopy += '\n\n';
+ textToCopy += `[包含 ${imageCount} 张图片]`;
+ }
+
+ 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 { ok: false, method: 'empty' };
+ return await this.copyWithFeedback(text, null);
+ },
+
+ // 复制bot消息到剪贴板
+ async copyBotMessage(messageParts, messageIndex) {
+ let textToCopy = this.buildCopyTextFromParts(messageParts);
+ if (!textToCopy) textToCopy = '[媒体内容]';
+ await this.copyWithFeedback(textToCopy, messageIndex);
},
// 显示复制成功提示
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 +643,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 +665,11 @@ export default {
return '';
},
+ // 获取失败图标SVG
+ getErrorIconSvg() {
+ return '';
+ },
+
// 初始化代码块复制按钮
initCodeCopyButtons() {
this.$nextTick(() => {
@@ -608,15 +680,19 @@ 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 res = await this.copyCodeToClipboard(codeBlock.textContent || '');
+ const ok = !!res?.ok;
+ button.innerHTML = ok ? this.getSuccessIconSvg() : this.getErrorIconSvg();
+ 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();
button.style.color = '';
+ button.setAttribute("title", this.t('core.common.copy'));
}, 2000);
});
pre.style.position = 'relative';
@@ -1077,13 +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: rgb(var(--v-theme-error));
+ opacity: 1;
+}
+
+.copy-message-btn.copy-failed:hover {
+ color: rgb(var(--v-theme-error));
+ background-color: rgba(var(--v-theme-error), 0.1);
}
.reply-message-btn {
diff --git a/dashboard/src/i18n/locales/en-US/core/common.json b/dashboard/src/i18n/locales/en-US/core/common.json
index e89a1ce52..973feea54 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 171dfbf16..fa1a8bf8b 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": "添加",