fix(chat): support code block copy feedback (#349)
This commit is contained in:
@@ -199,8 +199,16 @@ onBeforeUnmount(() => {
|
||||
renderGeneration += 1
|
||||
})
|
||||
|
||||
function handleMarkdownClick(event: MouseEvent): void {
|
||||
void handleCodeBlockCopyClick(event)
|
||||
async function handleMarkdownClick(event: MouseEvent): Promise<void> {
|
||||
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
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, onBeforeUnmount, ref, watchEffect } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useMessage } from "naive-ui";
|
||||
import { downloadFile } from "@/api/hermes/download";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import MarkdownRenderer from "./MarkdownRenderer.vue";
|
||||
import { parseThinking, countThinkingChars } from "@/utils/thinking-parser";
|
||||
import { useChatStore } from "@/stores/hermes/chat";
|
||||
@@ -38,12 +39,12 @@ const copyableContent = computed(() => {
|
||||
async function copyBubbleContent() {
|
||||
const text = copyableContent.value
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
const ok = await copyToClipboard(text)
|
||||
if (ok) {
|
||||
toast.success(t('chat.copiedBubble'))
|
||||
} catch {
|
||||
toast.error(t('chat.copyFailed'))
|
||||
return
|
||||
}
|
||||
toast.error(t('chat.copyFailed'))
|
||||
}
|
||||
|
||||
const parsedThinking = computed(() =>
|
||||
@@ -229,15 +230,21 @@ async function handleToolDetailClick(event: MouseEvent): Promise<void> {
|
||||
|
||||
const source = button.closest<HTMLElement>("[data-copy-source]")?.dataset.copySource;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import hljs from 'highlight.js'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
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>`
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard?.writeText?.(text)
|
||||
} catch {
|
||||
// Ignore clipboard failures; the code block still renders safely.
|
||||
}
|
||||
export async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
return copyToClipboard(text)
|
||||
}
|
||||
|
||||
export async function handleCodeBlockCopyClick(event: MouseEvent): Promise<void> {
|
||||
export async function handleCodeBlockCopyClick(event: MouseEvent): Promise<boolean | null> {
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (!(target instanceof HTMLElement)) return null
|
||||
|
||||
const button = target.closest<HTMLElement>('[data-copy-code="true"]')
|
||||
if (!button) return
|
||||
if (!button) return null
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const block = button.closest('.hljs-code-block')
|
||||
const code = block?.querySelector('code')
|
||||
const text = code?.textContent ?? ''
|
||||
if (!text) return
|
||||
if (!text) return false
|
||||
|
||||
await copyTextToClipboard(text)
|
||||
return copyTextToClipboard(text)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ describe('MarkdownRenderer', () => {
|
||||
svg: `<svg id="${id}" data-testid="mermaid-svg"><text>${source}</text></svg>`,
|
||||
}))
|
||||
|
||||
Object.defineProperty(window, 'isSecureContext', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -466,4 +470,30 @@ describe('MarkdownRenderer', () => {
|
||||
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
Object.defineProperty(window, 'isSecureContext', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
})
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
|
||||
Reference in New Issue
Block a user