From d9ebf4c2f16cda02154d04a4a3690b78c0a76ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 29 May 2026 12:40:29 +0800 Subject: [PATCH] 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) --- .../AssistantMessage.linkrouting.test.tsx | 24 ++++++++++++++ .../src/components/chat/AssistantMessage.tsx | 33 +++++++++++++++++-- desktop/src/components/chat/MessageList.tsx | 2 +- desktop/src/lib/desktopRuntime.ts | 14 ++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx diff --git a/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx b/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx new file mode 100644 index 00000000..3a7a00af --- /dev/null +++ b/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx @@ -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>()), + 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() + fireEvent.click(screen.getByText('预览')) + expect(openBrowser).toHaveBeenCalledWith('s1', 'http://localhost:5173/') + }) +}) diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index 7f254788..3a8d057a 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -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): 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 && } {isStreaming && ( diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 4c20bcc6..9d1efe71 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -1960,7 +1960,7 @@ export const MessageBlock = memo(function MessageBlock({ role="assistant" content={message.content} > - + ) case 'thinking': diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index f39ff09b..bafa0298 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -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:`). + * + * 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 }