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:
+28
-1
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: \{(?<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', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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: '已创建并打开分支会话',
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
agent: {
|
||||
getStatus: (
|
||||
selection?: AgentRuntimeSelection
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user