feat: add copy bubble button to copy entire message content (#245)
- Hover over any message to reveal a copy icon button - Click to copy the full message text to clipboard - Shows success/error toast notification - Skips tool messages (no copy button shown) - i18n support for all 8 languages (EN/ZH/DE/ES/FR/JA/KO/PT) - Dark mode compatible styling Co-authored-by: 356252190-star <356252190-star@users.noreply.github.com>
This commit is contained in:
@@ -27,6 +27,25 @@ const previewUrl = ref<string | null>(null);
|
||||
const chatStore = useChatStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// Copy entire bubble content
|
||||
const copyableContent = computed(() => {
|
||||
if (props.message.role === 'tool') return null
|
||||
const content = props.message.content || ''
|
||||
if (!content.trim()) return null
|
||||
return content
|
||||
})
|
||||
|
||||
async function copyBubbleContent() {
|
||||
const text = copyableContent.value
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success(t('chat.copiedBubble'))
|
||||
} catch {
|
||||
toast.error(t('chat.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const parsedThinking = computed(() =>
|
||||
parseThinking(props.message.content || "", { streaming: !!props.message.isStreaming }),
|
||||
);
|
||||
@@ -414,7 +433,20 @@ const renderedToolResult = computed(() => {
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="message-time">{{ timeStr }}</div>
|
||||
<div class="message-meta">
|
||||
<button
|
||||
v-if="copyableContent"
|
||||
class="copy-bubble-btn"
|
||||
@click="copyBubbleContent"
|
||||
:title="t('chat.copyBubble')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="message-time">{{ timeStr }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -620,11 +652,53 @@ const renderedToolResult = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
.message:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-bubble-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: $text-muted;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
padding: 0;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: $text-secondary;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.dark & {
|
||||
color: #999999;
|
||||
|
||||
&:hover {
|
||||
color: #cccccc;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
user-select: none;
|
||||
|
||||
.dark & {
|
||||
color: #999999;
|
||||
|
||||
@@ -137,6 +137,9 @@ export default {
|
||||
thinkingHide: 'Denkprozess ausblenden',
|
||||
thinkingDuration: 'Beobachtet {duration}',
|
||||
thinkingChars: '{count} Zeichen',
|
||||
copyBubble: 'Nachricht kopieren',
|
||||
copiedBubble: 'Nachricht kopiert',
|
||||
copyFailed: 'Kopieren fehlgeschlagen',
|
||||
},
|
||||
|
||||
// Jobs
|
||||
|
||||
@@ -160,6 +160,9 @@ export default {
|
||||
thinkingHide: 'Hide thinking',
|
||||
thinkingDuration: 'Observed {duration}',
|
||||
thinkingChars: '{count} chars',
|
||||
copyBubble: 'Copy message',
|
||||
copiedBubble: 'Message copied',
|
||||
copyFailed: 'Copy failed',
|
||||
},
|
||||
|
||||
// Jobs
|
||||
|
||||
@@ -137,6 +137,9 @@ export default {
|
||||
thinkingHide: 'Ocultar pensamiento',
|
||||
thinkingDuration: 'Observado {duration}',
|
||||
thinkingChars: '{count} caracteres',
|
||||
copyBubble: 'Copiar mensaje',
|
||||
copiedBubble: 'Mensaje copiado',
|
||||
copyFailed: 'Error al copiar',
|
||||
},
|
||||
|
||||
// Jobs
|
||||
|
||||
@@ -137,6 +137,9 @@ export default {
|
||||
thinkingHide: 'Masquer le raisonnement',
|
||||
thinkingDuration: 'Observé {duration}',
|
||||
thinkingChars: '{count} caractères',
|
||||
copyBubble: 'Copier le message',
|
||||
copiedBubble: 'Message copié',
|
||||
copyFailed: 'Échec de la copie',
|
||||
},
|
||||
|
||||
// Jobs
|
||||
|
||||
@@ -137,6 +137,9 @@ export default {
|
||||
thinkingHide: '思考過程を隠す',
|
||||
thinkingDuration: '観測 {duration}',
|
||||
thinkingChars: '{count} 文字',
|
||||
copyBubble: 'メッセージをコピー',
|
||||
copiedBubble: 'コピーしました',
|
||||
copyFailed: 'コピーに失敗しました',
|
||||
},
|
||||
|
||||
// スケジュールジョブ
|
||||
|
||||
@@ -137,6 +137,9 @@ export default {
|
||||
thinkingHide: '사고 과정 접기',
|
||||
thinkingDuration: '관측 {duration}',
|
||||
thinkingChars: '{count}자',
|
||||
copyBubble: '메시지 복사',
|
||||
copiedBubble: '복사됨',
|
||||
copyFailed: '복사 실패',
|
||||
},
|
||||
|
||||
// 예약 작업
|
||||
|
||||
@@ -137,6 +137,9 @@ export default {
|
||||
thinkingHide: 'Ocultar raciocínio',
|
||||
thinkingDuration: 'Observado {duration}',
|
||||
thinkingChars: '{count} caracteres',
|
||||
copyBubble: 'Copiar mensagem',
|
||||
copiedBubble: 'Mensagem copiada',
|
||||
copyFailed: 'Falha ao copiar',
|
||||
},
|
||||
|
||||
// Jobs
|
||||
|
||||
@@ -160,6 +160,9 @@ export default {
|
||||
thinkingHide: '收起思考过程',
|
||||
thinkingDuration: '已观察 {duration}',
|
||||
thinkingChars: '{count} 字',
|
||||
copyBubble: '复制消息',
|
||||
copiedBubble: '已复制',
|
||||
copyFailed: '复制失败',
|
||||
},
|
||||
|
||||
// 定时任务
|
||||
|
||||
Reference in New Issue
Block a user