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 chatStore = useChatStore();
|
||||||
const settingsStore = useSettingsStore();
|
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(() =>
|
const parsedThinking = computed(() =>
|
||||||
parseThinking(props.message.content || "", { streaming: !!props.message.isStreaming }),
|
parseThinking(props.message.content || "", { streaming: !!props.message.isStreaming }),
|
||||||
);
|
);
|
||||||
@@ -414,7 +433,20 @@ const renderedToolResult = computed(() => {
|
|||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 {
|
.message-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
margin-top: 4px;
|
user-select: none;
|
||||||
padding: 0 4px;
|
|
||||||
|
|
||||||
.dark & {
|
.dark & {
|
||||||
color: #999999;
|
color: #999999;
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export default {
|
|||||||
thinkingHide: 'Denkprozess ausblenden',
|
thinkingHide: 'Denkprozess ausblenden',
|
||||||
thinkingDuration: 'Beobachtet {duration}',
|
thinkingDuration: 'Beobachtet {duration}',
|
||||||
thinkingChars: '{count} Zeichen',
|
thinkingChars: '{count} Zeichen',
|
||||||
|
copyBubble: 'Nachricht kopieren',
|
||||||
|
copiedBubble: 'Nachricht kopiert',
|
||||||
|
copyFailed: 'Kopieren fehlgeschlagen',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ export default {
|
|||||||
thinkingHide: 'Hide thinking',
|
thinkingHide: 'Hide thinking',
|
||||||
thinkingDuration: 'Observed {duration}',
|
thinkingDuration: 'Observed {duration}',
|
||||||
thinkingChars: '{count} chars',
|
thinkingChars: '{count} chars',
|
||||||
|
copyBubble: 'Copy message',
|
||||||
|
copiedBubble: 'Message copied',
|
||||||
|
copyFailed: 'Copy failed',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export default {
|
|||||||
thinkingHide: 'Ocultar pensamiento',
|
thinkingHide: 'Ocultar pensamiento',
|
||||||
thinkingDuration: 'Observado {duration}',
|
thinkingDuration: 'Observado {duration}',
|
||||||
thinkingChars: '{count} caracteres',
|
thinkingChars: '{count} caracteres',
|
||||||
|
copyBubble: 'Copiar mensaje',
|
||||||
|
copiedBubble: 'Mensaje copiado',
|
||||||
|
copyFailed: 'Error al copiar',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export default {
|
|||||||
thinkingHide: 'Masquer le raisonnement',
|
thinkingHide: 'Masquer le raisonnement',
|
||||||
thinkingDuration: 'Observé {duration}',
|
thinkingDuration: 'Observé {duration}',
|
||||||
thinkingChars: '{count} caractères',
|
thinkingChars: '{count} caractères',
|
||||||
|
copyBubble: 'Copier le message',
|
||||||
|
copiedBubble: 'Message copié',
|
||||||
|
copyFailed: 'Échec de la copie',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export default {
|
|||||||
thinkingHide: '思考過程を隠す',
|
thinkingHide: '思考過程を隠す',
|
||||||
thinkingDuration: '観測 {duration}',
|
thinkingDuration: '観測 {duration}',
|
||||||
thinkingChars: '{count} 文字',
|
thinkingChars: '{count} 文字',
|
||||||
|
copyBubble: 'メッセージをコピー',
|
||||||
|
copiedBubble: 'コピーしました',
|
||||||
|
copyFailed: 'コピーに失敗しました',
|
||||||
},
|
},
|
||||||
|
|
||||||
// スケジュールジョブ
|
// スケジュールジョブ
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export default {
|
|||||||
thinkingHide: '사고 과정 접기',
|
thinkingHide: '사고 과정 접기',
|
||||||
thinkingDuration: '관측 {duration}',
|
thinkingDuration: '관측 {duration}',
|
||||||
thinkingChars: '{count}자',
|
thinkingChars: '{count}자',
|
||||||
|
copyBubble: '메시지 복사',
|
||||||
|
copiedBubble: '복사됨',
|
||||||
|
copyFailed: '복사 실패',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 예약 작업
|
// 예약 작업
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ export default {
|
|||||||
thinkingHide: 'Ocultar raciocínio',
|
thinkingHide: 'Ocultar raciocínio',
|
||||||
thinkingDuration: 'Observado {duration}',
|
thinkingDuration: 'Observado {duration}',
|
||||||
thinkingChars: '{count} caracteres',
|
thinkingChars: '{count} caracteres',
|
||||||
|
copyBubble: 'Copiar mensagem',
|
||||||
|
copiedBubble: 'Mensagem copiada',
|
||||||
|
copyFailed: 'Falha ao copiar',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ export default {
|
|||||||
thinkingHide: '收起思考过程',
|
thinkingHide: '收起思考过程',
|
||||||
thinkingDuration: '已观察 {duration}',
|
thinkingDuration: '已观察 {duration}',
|
||||||
thinkingChars: '{count} 字',
|
thinkingChars: '{count} 字',
|
||||||
|
copyBubble: '复制消息',
|
||||||
|
copiedBubble: '已复制',
|
||||||
|
copyFailed: '复制失败',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 定时任务
|
// 定时任务
|
||||||
|
|||||||
Reference in New Issue
Block a user