feat(desktop): wire screenshot capture button to composer prefill

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 13:19:23 +08:00
parent 26018fa40b
commit acbdd7f274
9 changed files with 83 additions and 6 deletions

File diff suppressed because one or more lines are too long

View File

@ -7,7 +7,7 @@ beforeAll(() => {
})
const { bridge } = vi.hoisted(() => ({
bridge: { open: vi.fn(), navigate: vi.fn(), setBounds: vi.fn(), setVisible: vi.fn(), close: vi.fn() },
bridge: { open: vi.fn(), navigate: vi.fn(), setBounds: vi.fn(), setVisible: vi.fn(), close: vi.fn(), eval: vi.fn() },
}))
vi.mock('../../lib/previewBridge', () => ({ previewBridge: bridge }))
vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {}) }))
@ -43,4 +43,11 @@ describe('BrowserSurface', () => {
unmount()
expect(bridge.setVisible).toHaveBeenLastCalledWith(false)
})
it('截图 button triggers a capture via preview_eval', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />)
fireEvent.click(screen.getByLabelText('截图'))
expect(bridge.eval).toHaveBeenCalledWith(expect.stringContaining('capture'))
})
})

View File

@ -1,4 +1,5 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { Camera } from 'lucide-react'
import { BrowserAddressBar } from './BrowserAddressBar'
import { computeWebviewBounds } from './computeWebviewBounds'
import { previewBridge } from '../../lib/previewBridge'
@ -56,6 +57,15 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
onForward={() => { store.goForward(sessionId); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }}
onReload={() => previewBridge.navigate(session.url)}
/>
<div className="flex items-center gap-1 border-b px-2 py-1">
<button
aria-label="截图"
className="rounded p-1 hover:bg-muted"
onClick={() => previewBridge.eval(`window.__PREVIEW_BRIDGE__?.handleHostRaw('{"v":1,"type":"capture","kind":"full"}')`)}
>
<Camera size={16} />
</button>
</div>
<div ref={hostRef} className="flex-1" data-testid="preview-host" />
</div>
)

View File

@ -21,6 +21,12 @@ describe('previewBridge', () => {
expect(invoke).toHaveBeenCalledWith('preview_set_bounds', { bounds: { x: 0, y: 0, width: 10, height: 10 } })
})
it('eval forwards js to preview_eval', async () => {
const { previewBridge } = await import('./previewBridge')
await previewBridge.eval('window.x=1')
expect(invoke).toHaveBeenCalledWith('preview_eval', { js: 'window.x=1' })
})
it('is a no-op outside the Tauri runtime', async () => {
vi.resetModules()
vi.doMock('./desktopRuntime', () => ({ isTauriRuntime: () => false }))

View File

@ -13,4 +13,5 @@ export const previewBridge = {
setBounds: (bounds: WebviewBounds) => call('preview_set_bounds', { bounds }),
setVisible: (visible: boolean) => call('preview_set_visible', { visible }),
close: () => call('preview_close'),
eval: (js: string) => call('preview_eval', { js }),
}

View File

@ -5,6 +5,9 @@ vi.mock('@tauri-apps/api/event', () => ({
listen: (name: string, cb: (e: { payload: string }) => void) => { listeners[name] = cb; return Promise.resolve(() => {}) },
}))
const prefill = vi.hoisted(() => vi.fn())
vi.mock('../stores/chatStore', () => ({ useChatStore: { getState: () => ({ queueComposerPrefill: prefill }) } }))
import { subscribePreviewEvents } from './previewEvents'
import { useBrowserPanelStore } from '../stores/browserPanelStore'
@ -15,4 +18,12 @@ describe('subscribePreviewEvents', () => {
listeners['preview://event']!({ payload: JSON.stringify({ v: 1, type: 'navigated', url: 'http://x/c', title: 'C' }) })
expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe('http://x/c')
})
it('screenshot event prefills composer with an image attachment', async () => {
await subscribePreviewEvents('s1')
listeners['preview://event']!({ payload: JSON.stringify({ v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,AAAA', kind: 'full' }) })
expect(prefill).toHaveBeenCalledWith('s1', expect.objectContaining({
attachments: [expect.objectContaining({ type: 'image', data: 'data:image/png;base64,AAAA' })],
}))
})
})

View File

@ -1,13 +1,25 @@
import { listen } from '@tauri-apps/api/event'
import { useBrowserPanelStore } from '../stores/browserPanelStore'
import { useChatStore } from '../stores/chatStore'
function kindLabel(kind?: string): string {
if (kind === 'viewport') return 'viewport'
if (kind === 'element') return 'element'
return 'full'
}
export async function subscribePreviewEvents(sessionId: string): Promise<() => void> {
return listen<string>('preview://event', (e) => {
let msg: { type?: string; url?: string; title?: string }
let msg: { type?: string; url?: string; title?: string; dataUrl?: string; kind?: string }
try { msg = JSON.parse(e.payload) } catch { return }
const store = useBrowserPanelStore.getState()
if (msg.type === 'navigated' && msg.url) store.setNavigated(sessionId, msg.url, msg.title ?? '')
else if (msg.type === 'ready') store.setReady(sessionId)
// selection / screenshot 在 M4/M5 接入
else if (msg.type === 'screenshot' && msg.dataUrl) {
useChatStore.getState().queueComposerPrefill(sessionId, {
text: '',
attachments: [{ type: 'image', name: `screenshot-${kindLabel(msg.kind)}.png`, mimeType: 'image/png', data: msg.dataUrl }],
})
}
})
}

View File

@ -10,8 +10,10 @@ export function createBridge(deps: Deps) {
reportNavigated: () => send({ type: 'navigated', url: deps.location.href, title: deps.title }),
reportError: (message: string) => send({ type: 'error', message }),
send,
on(type: HostMessage['type'], fn: (m: HostMessage) => void) {
const arr = handlers.get(type) ?? []; arr.push(fn); handlers.set(type, arr)
on<T extends HostMessage['type']>(type: T, fn: (m: Extract<HostMessage, { type: T }>) => void) {
const arr = handlers.get(type) ?? []
arr.push(fn as (m: HostMessage) => void)
handlers.set(type, arr)
},
handleHostRaw(raw: string) {
const msg = parseHostMessage(raw)

View File

@ -1,4 +1,5 @@
import { createBridge } from './bridge'
import { captureToDataUrl } from './screenshot'
;(() => {
;(window as unknown as { __PREVIEW_AGENT__?: boolean }).__PREVIEW_AGENT__ = true
@ -11,6 +12,12 @@ import { createBridge } from './bridge'
const bridge = createBridge({ postToHost, location: window.location, title: document.title })
;(window as unknown as { __PREVIEW_BRIDGE__?: unknown }).__PREVIEW_BRIDGE__ = bridge
;(window as unknown as Record<string, unknown>).__PREVIEW_AGENT_CAPTURE__ = captureToDataUrl
bridge.on('capture', async (m) => {
try { bridge.send({ type: 'screenshot', dataUrl: await captureToDataUrl(m.kind), kind: m.kind }) }
catch (e) { bridge.reportError(String(e)) }
})
const onReady = () => { bridge.reportReady(); bridge.reportNavigated() }
if (document.readyState !== 'loading') onReady()