feat: add copy action to AI responses

Completed AI responses now expose a bottom copy action that copies their Markdown source without reasoning, tool logs, or citation metadata. Clipboard writes use a validated Preload/Main bridge so sandboxed Electron sessions no longer fail with a renderer clipboard permission error; full-conversation copy uses the same path.

Release note: AI 回复底部现在可以直接复制 Markdown 原文,并修复了沙箱环境下回复和完整会话可能无法写入剪贴板的问题。
This commit is contained in:
mesalogo
2026-09-02 16:35:51 +08:00
parent 4d9ab72778
commit 42cbfa43f1
15 changed files with 243 additions and 19 deletions
+38 -6
View File
@@ -215,6 +215,9 @@ const api: DesktopApi = {
}),
onOpenSettings: vi.fn(() => () => {})
},
clipboard: {
writeText: vi.fn(async () => {})
},
feedback: {
submit: vi.fn<DesktopApi['feedback']['submit']>(async () => ({
ok: true,
@@ -2513,11 +2516,6 @@ describe('App', () => {
})
it('keeps conversation actions in the conversation list', async () => {
const writeText = vi.fn(async () => {})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText }
})
const { container } = render(<App />)
const topbar = container.querySelector<HTMLElement>('.topbar')
const conversationList =
@@ -2595,10 +2593,44 @@ describe('App', () => {
name: '复制完整会话'
})
)
await waitFor(() => expect(writeText).toHaveBeenCalledOnce())
await waitFor(() =>
expect(api.clipboard.writeText).toHaveBeenCalledOnce()
)
expect(await screen.findByRole('status')).toBeVisible()
})
it('copies a completed assistant reply through the desktop bridge', async () => {
render(<App />)
fireEvent.click(
screen.getByRole('button', { name: '复制此回复' })
)
await waitFor(() =>
expect(api.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('你好,我是 GoodBuddy')
)
)
expect(
await screen.findByText('回复已复制到剪贴板')
).toBeVisible()
})
it('reports a desktop clipboard write failure', async () => {
vi.mocked(api.clipboard.writeText).mockRejectedValueOnce(
new Error('clipboard unavailable')
)
render(<App />)
fireEvent.click(
screen.getByRole('button', { name: '复制此回复' })
)
expect(
await screen.findByRole('alert')
).toHaveTextContent('无法访问剪贴板,请检查系统权限')
})
it('flushes and opens an independent conversation branch with provenance badges', async () => {
const sourceConversationId =
'00000000-0000-4000-8000-000000000421'
+35 -12
View File
@@ -857,6 +857,7 @@ function ChatHistoryPane({
artifactById,
conversation,
locale,
onCopyMessage,
onDownloadImage,
onOpenCitationContext,
onOpenCitationSource,
@@ -876,6 +877,7 @@ function ChatHistoryPane({
artifactById: ReadonlyMap<string, AssistantArtifact>
conversation: Conversation
locale: TimeFormatLocale
onCopyMessage: (content: string) => Promise<void>
onDownloadImage: (item: ImageViewerItem) => void
onOpenCitationContext: (
reference: KnowledgeSearchReference
@@ -1167,6 +1169,7 @@ function ChatHistoryPane({
messageArticleRefs.current.delete(messageId)
}
}}
onCopyMessage={onCopyMessage}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource}
@@ -6702,6 +6705,27 @@ function App(): React.JSX.Element {
focusConversationActions(conversationId)
}
const writeClipboardText = useCallback(
async (
content: string,
successMessage: string
): Promise<void> => {
try {
await window.goodbuddy.clipboard.writeText(content)
notify({
tone: 'success',
message: successMessage
})
} catch {
notify({
tone: 'error',
message: t('notices.clipboardUnavailable')
})
}
},
[t]
)
const copyConversation = async (
conversation: ConversationSnapshot
): Promise<void> => {
@@ -6718,20 +6742,18 @@ function App(): React.JSX.Element {
})
)
.join('\n\n')
try {
await navigator.clipboard.writeText(transcript)
notify({
tone: 'success',
message: t('notices.conversationCopied')
})
} catch {
notify({
tone: 'error',
message: t('notices.clipboardUnavailable')
})
}
await writeClipboardText(
transcript,
t('notices.conversationCopied')
)
}
const copyMessage = useCallback(
(content: string): Promise<void> =>
writeClipboardText(content, t('notices.messageCopied')),
[t, writeClipboardText]
)
const exportConversation = (
conversation: ConversationSnapshot
): void => {
@@ -9195,6 +9217,7 @@ function App(): React.JSX.Element {
conversation={conversation}
key={conversation.id}
locale={locale}
onCopyMessage={copyMessage}
onDownloadImage={downloadImage}
onOpenCitationContext={openCitationContext}
onOpenCitationSource={openCitationSource}
+58
View File
@@ -22,6 +22,7 @@ vi.mock('./MarkdownRenderer', () => ({
const callbacks = {
onArticleRef: vi.fn(),
onCopyMessage: vi.fn(async () => undefined),
onDownloadImage: vi.fn(),
onOpenCitationContext: vi.fn(async () => undefined),
onOpenCitationSource: vi.fn(async () => undefined),
@@ -195,6 +196,63 @@ describe('ChatTimeline', () => {
).toHaveTextContent('每日状态')
})
it('copies a completed assistant reply from its bottom action', () => {
const messages: Message[] = [
{
id: 'assistant-message',
role: 'assistant',
content: '## Answer\n\nKeep the Markdown source.',
reasoning: 'private reasoning',
createdAt: 1_775_000_000_000,
state: 'complete'
},
{
id: 'streaming-message',
role: 'assistant',
content: 'Partial answer',
createdAt: 1_775_000_001_000,
state: 'streaming'
},
{
id: 'user-message',
role: 'user',
content: 'User content',
createdAt: 1_775_000_002_000,
state: 'complete'
}
]
render(
<ChatTimeline
artifactById={new Map()}
conversationId="conversation-1"
hiddenMessageCount={0}
isUnusedConversation={false}
locale="zh-CN"
messageStartIndex={0}
messages={messages}
{...callbacks}
retryContent=""
totalMessageCount={messages.length}
/>
)
const copyButton = screen.getByRole('button', {
name: '复制此回复'
})
expect(copyButton).toHaveAttribute('title', '复制此回复')
expect(
screen.getAllByRole('button', { name: '复制此回复' })
).toHaveLength(1)
fireEvent.click(copyButton)
expect(callbacks.onCopyMessage).toHaveBeenCalledOnce()
expect(callbacks.onCopyMessage).toHaveBeenCalledWith(
'## Answer\n\nKeep the Markdown source.'
)
})
it('shows every parallel expert output in its own expandable card', () => {
const messages: Message[] = [
{
+27
View File
@@ -3,6 +3,7 @@ import {
CheckCircle2,
ChevronRight,
Clock3,
Copy,
Download,
FileText,
Library,
@@ -382,6 +383,7 @@ type ChatMessageRowProps = {
locale: TimeFormatLocale
message: Message
onArticleRef: (messageId: string, element: HTMLElement | null) => void
onCopyMessage: (content: string) => Promise<void>
onDownloadImage: (item: ImageViewerItem) => void
onOpenCitationContext: (
reference: KnowledgeSearchReference
@@ -414,6 +416,7 @@ function ChatMessageRowView({
locale,
message,
onArticleRef,
onCopyMessage,
onDownloadImage,
onOpenCitationContext,
onOpenCitationSource,
@@ -986,6 +989,27 @@ function ChatMessageRowView({
{t('chat.retry')}
</button>
)}
{message.role === 'assistant' &&
message.state !== 'streaming' &&
Boolean(message.content.trim()) && (
<div className="message__actions">
<button
aria-label={t('chat.copyMessage')}
className="icon-button"
onClick={() =>
void onCopyMessage(
greeting
? t('conversation.greeting')
: message.content
)
}
title={t('chat.copyMessage')}
type="button"
>
<Copy aria-hidden="true" size={15} />
</button>
</div>
)}
</div>
</article>
{message.role === 'assistant' &&
@@ -1028,6 +1052,7 @@ type ChatTimelineProps = {
messageStartIndex: number
messages: Message[]
onArticleRef: (messageId: string, element: HTMLElement | null) => void
onCopyMessage: (content: string) => Promise<void>
onDownloadImage: (item: ImageViewerItem) => void
onOpenCitationContext: (
reference: KnowledgeSearchReference
@@ -1063,6 +1088,7 @@ export function ChatTimeline({
messageStartIndex,
messages,
onArticleRef,
onCopyMessage,
onDownloadImage,
onOpenCitationContext,
onOpenCitationSource,
@@ -1104,6 +1130,7 @@ export function ChatTimeline({
locale={locale}
message={message}
onArticleRef={onArticleRef}
onCopyMessage={onCopyMessage}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource}
@@ -331,6 +331,7 @@ export const app = {
}
},
retry: 'Edit and send again',
copyMessage: 'Copy this response',
loadEarlierMessages: 'Load earlier messages ({{count}} remaining)',
scrollToBottom: 'Scroll to bottom',
status: {
@@ -615,6 +616,7 @@ export const app = {
deletedConversationBrowserCloseFailed:
'Failed to close the browser for the deleted conversation',
conversationCopied: 'Conversation copied to the clipboard',
messageCopied: 'Response copied to the clipboard',
clipboardUnavailable:
'Cannot access the clipboard. Check your system permissions.',
conversationExported: 'Conversation exported',
@@ -314,6 +314,7 @@ export const app = {
}
},
retry: '重新编辑并发送',
copyMessage: '复制此回复',
loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)',
scrollToBottom: '到底部',
status: {
@@ -572,6 +573,7 @@ export const app = {
'删除本地会话失败,已保留当前对话',
deletedConversationBrowserCloseFailed: '关闭已删除对话的浏览器失败',
conversationCopied: '对话已复制到剪贴板',
messageCopied: '回复已复制到剪贴板',
clipboardUnavailable: '无法访问剪贴板,请检查系统权限',
conversationExported: '对话已导出',
conversationBranched: '已创建并打开分支会话',
+5
View File
@@ -5363,6 +5363,11 @@ button > svg {
justify-content: flex-end;
}
.message__actions {
display: flex;
margin-top: var(--space-2);
}
.message-attachments {
display: flex;
max-width: 100%;