diff --git a/UI-DESIGN.md b/UI-DESIGN.md index e508bc3c..184d7965 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -570,6 +570,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 使用 `reading` 壳层,消息流与输入区共享宽度。 - 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。 +- 已结束且包含正文的助手回复在底部提供带可访问名称和工具提示的复制图标;复制 Markdown + 原文,不包含推理过程、工具日志或引用元数据,结果通过应用通知反馈。流式回复完成前不显示 + 复制入口。 - 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行 或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态, 使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。 diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index e274737b..dd866192 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -242,6 +242,29 @@ describe('terminal IPC boundary', () => { 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(() => electronMocks.handlers.get(ipcChannels.terminalWrite)?.( { @@ -295,7 +318,8 @@ const electronMocks = vi.hoisted(() => { })), openPath: vi.fn(async () => ''), 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') }, BrowserWindow: class {}, + clipboard: { + writeText: electronMocks.writeClipboardText + }, dialog: { showOpenDialog: electronMocks.showOpenDialog, showSaveDialog: electronMocks.showSaveDialog diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a3728679..11e039a6 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, + clipboard, dialog, ipcMain, shell @@ -31,6 +32,7 @@ import { browserReloadRequestSchema, browserStopLoadingRequestSchema, browserStopRequestSchema, + clipboardTextSchema, conversationQueueUserInputSchema, defaultRuntimeSettings, knowledgeCreateSchema, @@ -3298,6 +3300,11 @@ export function registerIpcHandlers( } }) + registerHandler(ipcChannels.clipboardWriteText, (event, input) => { + assertTrustedSender(event, window) + clipboard.writeText(clipboardTextSchema.parse(input)) + }) + registerHandler( ipcChannels.appRendererPersistenceReady, (event) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 5d336d45..8034ddc7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -254,6 +254,11 @@ const desktopApi: DesktopApi = { return () => ipcRenderer.removeListener(ipcChannels.settingsOpen, handler) } }, + clipboard: { + writeText: async (text: string) => { + await ipcRenderer.invoke(ipcChannels.clipboardWriteText, text) + } + }, agent: { getStatus: (selection) => ipcRenderer.invoke( diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index b20bcec8..23d2f1b9 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -21,6 +21,20 @@ describe('sandboxed preload', () => { 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: \{(?[\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', () => { const source = readFileSync( join(process.cwd(), 'src', 'preload', 'index.ts'), diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 8f8844e8..ed5f7af0 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -215,6 +215,9 @@ const api: DesktopApi = { }), onOpenSettings: vi.fn(() => () => {}) }, + clipboard: { + writeText: vi.fn(async () => {}) + }, feedback: { submit: vi.fn(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() const topbar = container.querySelector('.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() + + 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() + + 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' diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a64e8c42..fb7bd5de 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -857,6 +857,7 @@ function ChatHistoryPane({ artifactById, conversation, locale, + onCopyMessage, onDownloadImage, onOpenCitationContext, onOpenCitationSource, @@ -876,6 +877,7 @@ function ChatHistoryPane({ artifactById: ReadonlyMap conversation: Conversation locale: TimeFormatLocale + onCopyMessage: (content: string) => Promise 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 => { + 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 => { @@ -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 => + 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} diff --git a/src/renderer/src/ChatTimeline.test.tsx b/src/renderer/src/ChatTimeline.test.tsx index 1bbbdb30..28957568 100644 --- a/src/renderer/src/ChatTimeline.test.tsx +++ b/src/renderer/src/ChatTimeline.test.tsx @@ -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( + + ) + + 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[] = [ { diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 19a665fc..03e35b91 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -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 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')} )} + {message.role === 'assistant' && + message.state !== 'streaming' && + Boolean(message.content.trim()) && ( +
+ +
+ )} {message.role === 'assistant' && @@ -1028,6 +1052,7 @@ type ChatTimelineProps = { messageStartIndex: number messages: Message[] onArticleRef: (messageId: string, element: HTMLElement | null) => void + onCopyMessage: (content: string) => Promise 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} diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index c879e486..b2563360 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -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', diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 25c32940..08171d88 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -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: '已创建并打开分支会话', diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 07fc75ed..cb5a8ce1 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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%; diff --git a/src/shared/contracts.test.ts b/src/shared/contracts.test.ts index 11a474b0..cfba6924 100644 --- a/src/shared/contracts.test.ts +++ b/src/shared/contracts.test.ts @@ -5,10 +5,23 @@ import { browserNavigateRequestSchema, browserStopLoadingRequestSchema, builtinEmbeddingConnectionId, + clipboardTextSchema, legacyEmbeddingConnectionId, runtimeSettingsInputSchema } 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', () => { it('requires authoritative toolbar metadata and accepts committed URLs up to 8192 characters', () => { const base = { diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index caca8022..06c2733f 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1116,6 +1116,8 @@ export type ContextFileSelectionProgress = { export const maximumPastedImageBytes = 12 * 1024 * 1024 +export const clipboardTextSchema = z.string() + export const pastedImageInputSchema = z .object({ data: z @@ -1561,6 +1563,9 @@ export type DesktopApi = { onNewConversation: (listener: () => void) => () => void onOpenSettings: (listener: () => void) => () => void } + clipboard: { + writeText: (text: string) => Promise + } agent: { getStatus: ( selection?: AgentRuntimeSelection diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 641abaf0..f03a10e0 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -11,6 +11,7 @@ export const ipcChannels = { appRendererPersistenceRequest: 'app:renderer-persistence-request', appRendererPersistenceComplete: 'app:renderer-persistence-complete', appClearLocalData: 'app:clear-local-data', + clipboardWriteText: 'clipboard:write-text', conversationNew: 'conversation:new', settingsOpen: 'settings:open', agentStatus: 'agent:get-status',