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
+3
View File
@@ -570,6 +570,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 使用 `reading` 壳层,消息流与输入区共享宽度。 - 使用 `reading` 壳层,消息流与输入区共享宽度。
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。 - 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
- 已结束且包含正文的助手回复在底部提供带可访问名称和工具提示的复制图标;复制 Markdown
原文,不包含推理过程、工具日志或引用元数据,结果通过应用通知反馈。流式回复完成前不显示
复制入口。
- 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行 - 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行
或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态, 或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态,
使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。 使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。
+28 -1
View File
@@ -242,6 +242,29 @@ describe('terminal IPC boundary', () => {
snapshot.sessionId snapshot.sessionId
) )
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
event,
'## Copied response'
)
expect(electronMocks.writeClipboardText).toHaveBeenCalledWith(
'## Copied response'
)
expect(() =>
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
event,
{ text: 'invalid' }
)
).toThrow()
expect(() =>
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
{
sender: { ...webContents, id: 99 },
senderFrame: webContents.mainFrame
},
'untrusted'
)
).toThrow('拒绝来自未知窗口的 IPC 请求')
expect(() => expect(() =>
electronMocks.handlers.get(ipcChannels.terminalWrite)?.( electronMocks.handlers.get(ipcChannels.terminalWrite)?.(
{ {
@@ -295,7 +318,8 @@ const electronMocks = vi.hoisted(() => {
})), })),
openPath: vi.fn(async () => ''), openPath: vi.fn(async () => ''),
showItemInFolder: vi.fn(), showItemInFolder: vi.fn(),
openExternal: vi.fn(async () => undefined) openExternal: vi.fn(async () => undefined),
writeClipboardText: vi.fn()
} }
}) })
@@ -1106,6 +1130,9 @@ vi.mock('electron', () => ({
getVersion: vi.fn(() => '0.1.0') getVersion: vi.fn(() => '0.1.0')
}, },
BrowserWindow: class {}, BrowserWindow: class {},
clipboard: {
writeText: electronMocks.writeClipboardText
},
dialog: { dialog: {
showOpenDialog: electronMocks.showOpenDialog, showOpenDialog: electronMocks.showOpenDialog,
showSaveDialog: electronMocks.showSaveDialog showSaveDialog: electronMocks.showSaveDialog
+7
View File
@@ -1,6 +1,7 @@
import { import {
app, app,
BrowserWindow, BrowserWindow,
clipboard,
dialog, dialog,
ipcMain, ipcMain,
shell shell
@@ -31,6 +32,7 @@ import {
browserReloadRequestSchema, browserReloadRequestSchema,
browserStopLoadingRequestSchema, browserStopLoadingRequestSchema,
browserStopRequestSchema, browserStopRequestSchema,
clipboardTextSchema,
conversationQueueUserInputSchema, conversationQueueUserInputSchema,
defaultRuntimeSettings, defaultRuntimeSettings,
knowledgeCreateSchema, knowledgeCreateSchema,
@@ -3298,6 +3300,11 @@ export function registerIpcHandlers(
} }
}) })
registerHandler(ipcChannels.clipboardWriteText, (event, input) => {
assertTrustedSender(event, window)
clipboard.writeText(clipboardTextSchema.parse(input))
})
registerHandler( registerHandler(
ipcChannels.appRendererPersistenceReady, ipcChannels.appRendererPersistenceReady,
(event) => { (event) => {
+5
View File
@@ -254,6 +254,11 @@ const desktopApi: DesktopApi = {
return () => ipcRenderer.removeListener(ipcChannels.settingsOpen, handler) return () => ipcRenderer.removeListener(ipcChannels.settingsOpen, handler)
} }
}, },
clipboard: {
writeText: async (text: string) => {
await ipcRenderer.invoke(ipcChannels.clipboardWriteText, text)
}
},
agent: { agent: {
getStatus: (selection) => getStatus: (selection) =>
ipcRenderer.invoke( ipcRenderer.invoke(
+14
View File
@@ -21,6 +21,20 @@ describe('sandboxed preload', () => {
expect(source).not.toMatch(/\bfrom\s+['"]zod['"]/u) expect(source).not.toMatch(/\bfrom\s+['"]zod['"]/u)
}) })
it('exposes only explicit clipboard text writing', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
'utf8'
)
const clipboard =
source.match(
/clipboard: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}agent:/u
)?.groups?.body ?? ''
expect(clipboard).toContain('writeText: async (text: string)')
expect(clipboard).toContain('ipcChannels.clipboardWriteText')
expect(clipboard).not.toMatch(/\b(?:read|image|html|rtf|bookmark)\b/iu)
})
it('exposes only narrow browser-control methods', () => { it('exposes only narrow browser-control methods', () => {
const source = readFileSync( const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'), join(process.cwd(), 'src', 'preload', 'index.ts'),
+38 -6
View File
@@ -215,6 +215,9 @@ const api: DesktopApi = {
}), }),
onOpenSettings: vi.fn(() => () => {}) onOpenSettings: vi.fn(() => () => {})
}, },
clipboard: {
writeText: vi.fn(async () => {})
},
feedback: { feedback: {
submit: vi.fn<DesktopApi['feedback']['submit']>(async () => ({ submit: vi.fn<DesktopApi['feedback']['submit']>(async () => ({
ok: true, ok: true,
@@ -2513,11 +2516,6 @@ describe('App', () => {
}) })
it('keeps conversation actions in the conversation list', async () => { 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 { container } = render(<App />)
const topbar = container.querySelector<HTMLElement>('.topbar') const topbar = container.querySelector<HTMLElement>('.topbar')
const conversationList = const conversationList =
@@ -2595,10 +2593,44 @@ describe('App', () => {
name: '复制完整会话' name: '复制完整会话'
}) })
) )
await waitFor(() => expect(writeText).toHaveBeenCalledOnce()) await waitFor(() =>
expect(api.clipboard.writeText).toHaveBeenCalledOnce()
)
expect(await screen.findByRole('status')).toBeVisible() 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 () => { it('flushes and opens an independent conversation branch with provenance badges', async () => {
const sourceConversationId = const sourceConversationId =
'00000000-0000-4000-8000-000000000421' '00000000-0000-4000-8000-000000000421'
+35 -12
View File
@@ -857,6 +857,7 @@ function ChatHistoryPane({
artifactById, artifactById,
conversation, conversation,
locale, locale,
onCopyMessage,
onDownloadImage, onDownloadImage,
onOpenCitationContext, onOpenCitationContext,
onOpenCitationSource, onOpenCitationSource,
@@ -876,6 +877,7 @@ function ChatHistoryPane({
artifactById: ReadonlyMap<string, AssistantArtifact> artifactById: ReadonlyMap<string, AssistantArtifact>
conversation: Conversation conversation: Conversation
locale: TimeFormatLocale locale: TimeFormatLocale
onCopyMessage: (content: string) => Promise<void>
onDownloadImage: (item: ImageViewerItem) => void onDownloadImage: (item: ImageViewerItem) => void
onOpenCitationContext: ( onOpenCitationContext: (
reference: KnowledgeSearchReference reference: KnowledgeSearchReference
@@ -1167,6 +1169,7 @@ function ChatHistoryPane({
messageArticleRefs.current.delete(messageId) messageArticleRefs.current.delete(messageId)
} }
}} }}
onCopyMessage={onCopyMessage}
onDownloadImage={onDownloadImage} onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext} onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource} onOpenCitationSource={onOpenCitationSource}
@@ -6702,6 +6705,27 @@ function App(): React.JSX.Element {
focusConversationActions(conversationId) 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 ( const copyConversation = async (
conversation: ConversationSnapshot conversation: ConversationSnapshot
): Promise<void> => { ): Promise<void> => {
@@ -6718,20 +6742,18 @@ function App(): React.JSX.Element {
}) })
) )
.join('\n\n') .join('\n\n')
try { await writeClipboardText(
await navigator.clipboard.writeText(transcript) transcript,
notify({ t('notices.conversationCopied')
tone: 'success', )
message: t('notices.conversationCopied')
})
} catch {
notify({
tone: 'error',
message: t('notices.clipboardUnavailable')
})
}
} }
const copyMessage = useCallback(
(content: string): Promise<void> =>
writeClipboardText(content, t('notices.messageCopied')),
[t, writeClipboardText]
)
const exportConversation = ( const exportConversation = (
conversation: ConversationSnapshot conversation: ConversationSnapshot
): void => { ): void => {
@@ -9195,6 +9217,7 @@ function App(): React.JSX.Element {
conversation={conversation} conversation={conversation}
key={conversation.id} key={conversation.id}
locale={locale} locale={locale}
onCopyMessage={copyMessage}
onDownloadImage={downloadImage} onDownloadImage={downloadImage}
onOpenCitationContext={openCitationContext} onOpenCitationContext={openCitationContext}
onOpenCitationSource={openCitationSource} onOpenCitationSource={openCitationSource}
+58
View File
@@ -22,6 +22,7 @@ vi.mock('./MarkdownRenderer', () => ({
const callbacks = { const callbacks = {
onArticleRef: vi.fn(), onArticleRef: vi.fn(),
onCopyMessage: vi.fn(async () => undefined),
onDownloadImage: vi.fn(), onDownloadImage: vi.fn(),
onOpenCitationContext: vi.fn(async () => undefined), onOpenCitationContext: vi.fn(async () => undefined),
onOpenCitationSource: vi.fn(async () => undefined), onOpenCitationSource: vi.fn(async () => undefined),
@@ -195,6 +196,63 @@ describe('ChatTimeline', () => {
).toHaveTextContent('每日状态') ).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', () => { it('shows every parallel expert output in its own expandable card', () => {
const messages: Message[] = [ const messages: Message[] = [
{ {
+27
View File
@@ -3,6 +3,7 @@ import {
CheckCircle2, CheckCircle2,
ChevronRight, ChevronRight,
Clock3, Clock3,
Copy,
Download, Download,
FileText, FileText,
Library, Library,
@@ -382,6 +383,7 @@ type ChatMessageRowProps = {
locale: TimeFormatLocale locale: TimeFormatLocale
message: Message message: Message
onArticleRef: (messageId: string, element: HTMLElement | null) => void onArticleRef: (messageId: string, element: HTMLElement | null) => void
onCopyMessage: (content: string) => Promise<void>
onDownloadImage: (item: ImageViewerItem) => void onDownloadImage: (item: ImageViewerItem) => void
onOpenCitationContext: ( onOpenCitationContext: (
reference: KnowledgeSearchReference reference: KnowledgeSearchReference
@@ -414,6 +416,7 @@ function ChatMessageRowView({
locale, locale,
message, message,
onArticleRef, onArticleRef,
onCopyMessage,
onDownloadImage, onDownloadImage,
onOpenCitationContext, onOpenCitationContext,
onOpenCitationSource, onOpenCitationSource,
@@ -986,6 +989,27 @@ function ChatMessageRowView({
{t('chat.retry')} {t('chat.retry')}
</button> </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> </div>
</article> </article>
{message.role === 'assistant' && {message.role === 'assistant' &&
@@ -1028,6 +1052,7 @@ type ChatTimelineProps = {
messageStartIndex: number messageStartIndex: number
messages: Message[] messages: Message[]
onArticleRef: (messageId: string, element: HTMLElement | null) => void onArticleRef: (messageId: string, element: HTMLElement | null) => void
onCopyMessage: (content: string) => Promise<void>
onDownloadImage: (item: ImageViewerItem) => void onDownloadImage: (item: ImageViewerItem) => void
onOpenCitationContext: ( onOpenCitationContext: (
reference: KnowledgeSearchReference reference: KnowledgeSearchReference
@@ -1063,6 +1088,7 @@ export function ChatTimeline({
messageStartIndex, messageStartIndex,
messages, messages,
onArticleRef, onArticleRef,
onCopyMessage,
onDownloadImage, onDownloadImage,
onOpenCitationContext, onOpenCitationContext,
onOpenCitationSource, onOpenCitationSource,
@@ -1104,6 +1130,7 @@ export function ChatTimeline({
locale={locale} locale={locale}
message={message} message={message}
onArticleRef={onArticleRef} onArticleRef={onArticleRef}
onCopyMessage={onCopyMessage}
onDownloadImage={onDownloadImage} onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext} onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource} onOpenCitationSource={onOpenCitationSource}
@@ -331,6 +331,7 @@ export const app = {
} }
}, },
retry: 'Edit and send again', retry: 'Edit and send again',
copyMessage: 'Copy this response',
loadEarlierMessages: 'Load earlier messages ({{count}} remaining)', loadEarlierMessages: 'Load earlier messages ({{count}} remaining)',
scrollToBottom: 'Scroll to bottom', scrollToBottom: 'Scroll to bottom',
status: { status: {
@@ -615,6 +616,7 @@ export const app = {
deletedConversationBrowserCloseFailed: deletedConversationBrowserCloseFailed:
'Failed to close the browser for the deleted conversation', 'Failed to close the browser for the deleted conversation',
conversationCopied: 'Conversation copied to the clipboard', conversationCopied: 'Conversation copied to the clipboard',
messageCopied: 'Response copied to the clipboard',
clipboardUnavailable: clipboardUnavailable:
'Cannot access the clipboard. Check your system permissions.', 'Cannot access the clipboard. Check your system permissions.',
conversationExported: 'Conversation exported', conversationExported: 'Conversation exported',
@@ -314,6 +314,7 @@ export const app = {
} }
}, },
retry: '重新编辑并发送', retry: '重新编辑并发送',
copyMessage: '复制此回复',
loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)', loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)',
scrollToBottom: '到底部', scrollToBottom: '到底部',
status: { status: {
@@ -572,6 +573,7 @@ export const app = {
'删除本地会话失败,已保留当前对话', '删除本地会话失败,已保留当前对话',
deletedConversationBrowserCloseFailed: '关闭已删除对话的浏览器失败', deletedConversationBrowserCloseFailed: '关闭已删除对话的浏览器失败',
conversationCopied: '对话已复制到剪贴板', conversationCopied: '对话已复制到剪贴板',
messageCopied: '回复已复制到剪贴板',
clipboardUnavailable: '无法访问剪贴板,请检查系统权限', clipboardUnavailable: '无法访问剪贴板,请检查系统权限',
conversationExported: '对话已导出', conversationExported: '对话已导出',
conversationBranched: '已创建并打开分支会话', conversationBranched: '已创建并打开分支会话',
+5
View File
@@ -5363,6 +5363,11 @@ button > svg {
justify-content: flex-end; justify-content: flex-end;
} }
.message__actions {
display: flex;
margin-top: var(--space-2);
}
.message-attachments { .message-attachments {
display: flex; display: flex;
max-width: 100%; max-width: 100%;
+13
View File
@@ -5,10 +5,23 @@ import {
browserNavigateRequestSchema, browserNavigateRequestSchema,
browserStopLoadingRequestSchema, browserStopLoadingRequestSchema,
builtinEmbeddingConnectionId, builtinEmbeddingConnectionId,
clipboardTextSchema,
legacyEmbeddingConnectionId, legacyEmbeddingConnectionId,
runtimeSettingsInputSchema runtimeSettingsInputSchema
} from './contracts' } from './contracts'
describe('clipboard text contract', () => {
it('accepts text and rejects non-text input', () => {
expect(clipboardTextSchema.parse('Markdown response')).toBe(
'Markdown response'
)
expect(clipboardTextSchema.parse('')).toBe('')
expect(clipboardTextSchema.safeParse({ text: 'no' }).success).toBe(
false
)
})
})
describe('browser control contracts', () => { describe('browser control contracts', () => {
it('requires authoritative toolbar metadata and accepts committed URLs up to 8192 characters', () => { it('requires authoritative toolbar metadata and accepts committed URLs up to 8192 characters', () => {
const base = { const base = {
+5
View File
@@ -1116,6 +1116,8 @@ export type ContextFileSelectionProgress = {
export const maximumPastedImageBytes = 12 * 1024 * 1024 export const maximumPastedImageBytes = 12 * 1024 * 1024
export const clipboardTextSchema = z.string()
export const pastedImageInputSchema = z export const pastedImageInputSchema = z
.object({ .object({
data: z data: z
@@ -1561,6 +1563,9 @@ export type DesktopApi = {
onNewConversation: (listener: () => void) => () => void onNewConversation: (listener: () => void) => () => void
onOpenSettings: (listener: () => void) => () => void onOpenSettings: (listener: () => void) => () => void
} }
clipboard: {
writeText: (text: string) => Promise<void>
}
agent: { agent: {
getStatus: ( getStatus: (
selection?: AgentRuntimeSelection selection?: AgentRuntimeSelection
+1
View File
@@ -11,6 +11,7 @@ export const ipcChannels = {
appRendererPersistenceRequest: 'app:renderer-persistence-request', appRendererPersistenceRequest: 'app:renderer-persistence-request',
appRendererPersistenceComplete: 'app:renderer-persistence-complete', appRendererPersistenceComplete: 'app:renderer-persistence-complete',
appClearLocalData: 'app:clear-local-data', appClearLocalData: 'app:clear-local-data',
clipboardWriteText: 'clipboard:write-text',
conversationNew: 'conversation:new', conversationNew: 'conversation:new',
settingsOpen: 'settings:open', settingsOpen: 'settings:open',
agentStatus: 'agent:get-status', agentStatus: 'agent:get-status',