fix(chat): support code block copy feedback (#349)
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user