diff --git a/apps/desktop/src/components/assistant-ui/markdown-file-links.test.tsx b/apps/desktop/src/components/assistant-ui/markdown-file-links.test.tsx new file mode 100644 index 0000000000000..7485de1616ba2 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/markdown-file-links.test.tsx @@ -0,0 +1,37 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { $connection } from '@/store/session' + +import { MarkdownTextContent } from './markdown-text' + +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } +const initialHermesDesktop = desktopWindow.hermesDesktop + +afterEach(() => { + cleanup() + $connection.set(null) + vi.restoreAllMocks() + + if (initialHermesDesktop) { + desktopWindow.hermesDesktop = initialHermesDesktop + } else { + delete desktopWindow.hermesDesktop + } +}) + +describe('MarkdownTextContent file links', () => { + it('opens file links through the remote download endpoint', () => { + const openExternal = vi.fn().mockResolvedValue(undefined) + desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop'] + $connection.set({ baseUrl: 'https://gw', mode: 'remote', token: 's e/cret' } as never) + + render() + + fireEvent.click(screen.getByRole('link', { name: 'Open a b.pdf' })) + + expect(openExternal).toHaveBeenCalledWith( + 'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.pdf&token=s%20e%2Fcret' + ) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index beabcbf8cc272..8f2ce4e3bb2a9 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -213,6 +213,21 @@ function MediaAttachment({ path }: { path: string }) { ) } + if (kind === 'file') { + return ( + { + event.preventDefault() + openExternalLink(mediaExternalUrl(path)) + }} + > + Open {name} + + ) + } + return ( "'`*.,;:!?]` class. const RAW_URL_RE = /https?:\/\/[^\s<>"'`*]+[^\s<>"'`*.,;:!?]/g +const LOCAL_FILE_LINK_RE = /(?"'`]*/gi const LOCAL_PREVIEW_ONLY_RE = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?\/?$/i const URL_ONLY_LINE_RE = /^\s*https?:\/\/\S+\s*$/i @@ -138,6 +139,43 @@ function autoLinkRawUrls(text: string): string { }) } +function filePathFromHref(href: string): string { + if (/^file:/i.test(href)) { + try { + return decodeURIComponent(new URL(href).pathname) + } catch { + return href.replace(/^file:\/\//i, '') + } + } + + try { + return decodeURIComponent(href) + } catch { + return href + } +} + +function isAbsoluteFileHref(href: string): boolean { + if (/^file:/i.test(href)) { + return true + } + + const clean = href.split(/[?#]/, 1)[0] || '' + const name = clean.split('/').filter(Boolean).pop() || '' + + return /^\.[A-Za-z0-9]{1,12}$/.test(name.slice(name.lastIndexOf('.'))) +} + +function rewriteLocalFileLinks(text: string): string { + return text.replace(LOCAL_FILE_LINK_RE, (match: string, label: string, href: string) => { + if (!isAbsoluteFileHref(href)) { + return match + } + + return `[${label}](#media:${encodeURIComponent(filePathFromHref(href))})` + }) +} + function normalizeVisibleProse(text: string): string { return text .split(INLINE_CODE_SPLIT_RE) @@ -145,7 +183,9 @@ function normalizeVisibleProse(text: string): string { part.startsWith('`') ? part : autoLinkRawUrls( - part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '') + rewriteLocalFileLinks( + part.replace(/`{3,}/g, '').replace(LOCAL_PREVIEW_URL_RE, '$1').replace(CITATION_MARKER_RE, '') + ) ) ) .join('')