feat(desktop): route assistant message links to in-app preview surfaces

Wire L1 link routing into assistant message rendering. AssistantMessage now
accepts an optional sessionId and, when present, builds an onLinkClick that runs
handlePreviewLink against the running local server base (getServerBaseUrl) and
the browser/workspace panel stores, preventing default on handled links.
MessageList passes sessionId through in the assistant_text case.

Adds getServerBaseUrl() to desktopRuntime as a synchronous wrapper over the api
client's cached getBaseUrl().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 12:40:29 +08:00
parent 13222b375d
commit d9ebf4c2f1
4 changed files with 70 additions and 3 deletions

View File

@ -0,0 +1,24 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { openBrowser } = vi.hoisted(() => ({ openBrowser: vi.fn() }))
vi.mock('../../stores/browserPanelStore', () => ({
useBrowserPanelStore: { getState: () => ({ open: openBrowser }) },
}))
vi.mock('../../lib/desktopRuntime', async (orig) => ({
...(await orig<Record<string, unknown>>()),
getServerBaseUrl: () => 'http://127.0.0.1:4321',
}))
import { AssistantMessage } from './AssistantMessage'
afterEach(() => openBrowser.mockReset())
describe('AssistantMessage link routing', () => {
it('opens a localhost link in the in-app browser', () => {
render(<AssistantMessage sessionId="s1" content={'打开 [预览](http://localhost:5173/)'} />)
fireEvent.click(screen.getByText('预览'))
expect(openBrowser).toHaveBeenCalledWith('s1', 'http://localhost:5173/')
})
})

View File

@ -1,15 +1,43 @@
import { memo } from 'react'
import { memo, useCallback } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
import { InlineImageGallery } from './InlineImageGallery'
import { handlePreviewLink } from '../../lib/handlePreviewLink'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
type Props = {
content: string
isStreaming?: boolean
branchAction?: MessageBranchAction
sessionId?: string
}
export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction }: Props) {
export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction, sessionId }: Props) {
const handleLinkClick = useCallback(
(href: string, event: ReactMouseEvent<HTMLDivElement>): boolean => {
if (!sessionId) return false
const handled = handlePreviewLink(href, {
sessionId,
serverBaseUrl: getServerBaseUrl(),
openBrowser: (id, url) => useBrowserPanelStore.getState().open(id, url),
openFilePreview: (id, path) => {
void useWorkspacePanelStore.getState().openPreview(id, path, 'file')
},
openExternal: (url) => {
void import('@tauri-apps/plugin-shell')
.then((m) => m.open(url))
.catch(() => window.open(url, '_blank'))
},
})
if (handled) event.preventDefault()
return handled
},
[sessionId],
)
if (!content.trim()) return null
const documentLayout = shouldUseDocumentLayout(content)
@ -32,6 +60,7 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
content={content}
variant={documentLayout ? 'document' : 'default'}
streaming={isStreaming}
onLinkClick={sessionId ? handleLinkClick : undefined}
/>
{!isStreaming && <InlineImageGallery text={content} />}
{isStreaming && (

View File

@ -1960,7 +1960,7 @@ export const MessageBlock = memo(function MessageBlock({
role="assistant"
content={message.content}
>
<AssistantMessage content={message.content} branchAction={branchAction} />
<AssistantMessage content={message.content} branchAction={branchAction} sessionId={sessionId ?? undefined} />
</SelectableChatMessage>
)
case 'thinking':

View File

@ -1,5 +1,6 @@
import {
api,
getBaseUrl,
getDefaultBaseUrl,
hasExplicitDefaultBaseUrl,
setAuthToken,
@ -41,6 +42,19 @@ export function isBrowserH5Runtime() {
return typeof window !== 'undefined' && !isTauriRuntime()
}
/**
* Synchronously return the running local server's base URL (e.g.
* `http://127.0.0.1:<port>`).
*
* The api client caches the resolved base after startup: `initializeDesktopServerUrl`
* calls `invoke('get_server_url')` (desktop) or resolves a browser/H5 URL, then
* `setBaseUrl(...)`. Until that runs, `getBaseUrl()` returns the default
* (`http://127.0.0.1:3456` or `VITE_DESKTOP_SERVER_URL`).
*/
export function getServerBaseUrl(): string {
return getBaseUrl()
}
export function readStoredH5Connection(): StoredH5Connection {
if (typeof window === 'undefined') {
return { serverUrl: null, token: null }