fix(chat): support code block copy feedback (#349)

This commit is contained in:
Lanke
2026-04-30 18:36:00 +08:00
committed by GitHub
parent e82674039c
commit 05f15da90b
5 changed files with 66 additions and 20 deletions
@@ -199,8 +199,16 @@ onBeforeUnmount(() => {
renderGeneration += 1 renderGeneration += 1
}) })
function handleMarkdownClick(event: MouseEvent): void { async function handleMarkdownClick(event: MouseEvent): Promise<void> {
void handleCodeBlockCopyClick(event) const copyResult = await handleCodeBlockCopyClick(event)
if (copyResult !== null) {
if (copyResult) {
message.success(t('common.copied'))
} else {
message.error(t('chat.copyFailed'))
}
return
}
// Handle file path link clicks for download // Handle file path link clicks for download
const target = event.target as HTMLElement const target = event.target as HTMLElement
@@ -4,6 +4,7 @@ import { computed, onBeforeUnmount, ref, watchEffect } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useMessage } from "naive-ui"; import { useMessage } from "naive-ui";
import { downloadFile } from "@/api/hermes/download"; import { downloadFile } from "@/api/hermes/download";
import { copyToClipboard } from "@/utils/clipboard";
import MarkdownRenderer from "./MarkdownRenderer.vue"; import MarkdownRenderer from "./MarkdownRenderer.vue";
import { parseThinking, countThinkingChars } from "@/utils/thinking-parser"; import { parseThinking, countThinkingChars } from "@/utils/thinking-parser";
import { useChatStore } from "@/stores/hermes/chat"; import { useChatStore } from "@/stores/hermes/chat";
@@ -38,12 +39,12 @@ const copyableContent = computed(() => {
async function copyBubbleContent() { async function copyBubbleContent() {
const text = copyableContent.value const text = copyableContent.value
if (!text) return if (!text) return
try { const ok = await copyToClipboard(text)
await navigator.clipboard.writeText(text) if (ok) {
toast.success(t('chat.copiedBubble')) toast.success(t('chat.copiedBubble'))
} catch { return
toast.error(t('chat.copyFailed'))
} }
toast.error(t('chat.copyFailed'))
} }
const parsedThinking = computed(() => const parsedThinking = computed(() =>
@@ -229,15 +230,21 @@ async function handleToolDetailClick(event: MouseEvent): Promise<void> {
const source = button.closest<HTMLElement>("[data-copy-source]")?.dataset.copySource; const source = button.closest<HTMLElement>("[data-copy-source]")?.dataset.copySource;
if (source === "tool-args" && fullToolArgs.value) { if (source === "tool-args" && fullToolArgs.value) {
await copyTextToClipboard(fullToolArgs.value); const ok = await copyTextToClipboard(fullToolArgs.value);
if (ok) toast.success(t("common.copied"));
else toast.error(t("chat.copyFailed"));
return; return;
} }
if (source === "tool-result" && fullToolResult.value) { if (source === "tool-result" && fullToolResult.value) {
await copyTextToClipboard(fullToolResult.value); const ok = await copyTextToClipboard(fullToolResult.value);
if (ok) toast.success(t("common.copied"));
else toast.error(t("chat.copyFailed"));
return; return;
} }
await handleCodeBlockCopyClick(event); const copyResult = await handleCodeBlockCopyClick(event);
if (copyResult) toast.success(t("common.copied"));
else if (copyResult === false) toast.error(t("chat.copyFailed"));
} }
const hasAttachments = computed( const hasAttachments = computed(
@@ -1,4 +1,5 @@
import hljs from 'highlight.js' import hljs from 'highlight.js'
import { copyToClipboard } from '@/utils/clipboard'
const LANGUAGE_ALIASES: Record<string, string> = { const LANGUAGE_ALIASES: Record<string, string> = {
shellscript: 'bash', shellscript: 'bash',
@@ -80,27 +81,23 @@ export function renderHighlightedCodeBlock(
return `<pre class="hljs-code-block"><div class="code-header">${languageLabelHtml}<button type="button" class="copy-btn" data-copy-code="true">${escapeHtml(copyLabel)}</button></div><code class="hljs language-${sanitizeLanguageClass(codeClassLanguage)}">${highlighted}</code></pre>` return `<pre class="hljs-code-block"><div class="code-header">${languageLabelHtml}<button type="button" class="copy-btn" data-copy-code="true">${escapeHtml(copyLabel)}</button></div><code class="hljs language-${sanitizeLanguageClass(codeClassLanguage)}">${highlighted}</code></pre>`
} }
export async function copyTextToClipboard(text: string): Promise<void> { export async function copyTextToClipboard(text: string): Promise<boolean> {
try { return copyToClipboard(text)
await navigator.clipboard?.writeText?.(text)
} catch {
// Ignore clipboard failures; the code block still renders safely.
}
} }
export async function handleCodeBlockCopyClick(event: MouseEvent): Promise<void> { export async function handleCodeBlockCopyClick(event: MouseEvent): Promise<boolean | null> {
const target = event.target const target = event.target
if (!(target instanceof HTMLElement)) return if (!(target instanceof HTMLElement)) return null
const button = target.closest<HTMLElement>('[data-copy-code="true"]') const button = target.closest<HTMLElement>('[data-copy-code="true"]')
if (!button) return if (!button) return null
event.preventDefault() event.preventDefault()
const block = button.closest('.hljs-code-block') const block = button.closest('.hljs-code-block')
const code = block?.querySelector('code') const code = block?.querySelector('code')
const text = code?.textContent ?? '' const text = code?.textContent ?? ''
if (!text) return if (!text) return false
await copyTextToClipboard(text) return copyTextToClipboard(text)
} }
+30
View File
@@ -50,6 +50,10 @@ describe('MarkdownRenderer', () => {
svg: `<svg id="${id}" data-testid="mermaid-svg"><text>${source}</text></svg>`, svg: `<svg id="${id}" data-testid="mermaid-svg"><text>${source}</text></svg>`,
})) }))
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: true,
})
Object.defineProperty(navigator, 'clipboard', { Object.defineProperty(navigator, 'clipboard', {
configurable: true, configurable: true,
value: { value: {
@@ -466,4 +470,30 @@ describe('MarkdownRenderer', () => {
expect(writeText).toHaveBeenCalledWith(expected) expect(writeText).toHaveBeenCalledWith(expected)
}) })
it('falls back to legacy clipboard copy when the Clipboard API is unavailable', async () => {
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: false,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
})
const execCommand = vi.fn(() => true)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: execCommand,
})
const wrapper = mount(MarkdownRenderer, {
props: {
content: '```ts\nconst answer = 42\n```',
},
})
await wrapper.find('[data-copy-code="true"]').trigger('click')
expect(execCommand).toHaveBeenCalledWith('copy')
})
}) })
@@ -24,6 +24,10 @@ import type { Message } from '@/stores/hermes/chat'
describe('MessageItem tool details', () => { describe('MessageItem tool details', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
Object.defineProperty(window, 'isSecureContext', {
configurable: true,
value: true,
})
Object.defineProperty(navigator, 'clipboard', { Object.defineProperty(navigator, 'clipboard', {
configurable: true, configurable: true,
value: { value: {