diff --git a/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx b/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx
index 686ed236e8a..fb73fc38704 100644
--- a/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx
+++ b/web/app/components/base/markdown-blocks/__tests__/link.spec.tsx
@@ -170,6 +170,57 @@ describe('Link component', () => {
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
+ it('adds attachment mode to file-preview links by default', () => {
+ mockIsValidUrl.mockReturnValue(true)
+
+ const node = {
+ properties: {
+ href: 'http://localhost:5001/files/123/file-preview?timestamp=1&nonce=2&sign=3',
+ },
+ }
+
+ render(doc.pdf)
+
+ expect(screen.getByText('doc.pdf')).toHaveAttribute(
+ 'href',
+ 'http://localhost:5001/files/123/file-preview?timestamp=1&nonce=2&sign=3&as_attachment=true',
+ )
+ })
+
+ it('does not duplicate attachment mode for file-preview links', () => {
+ mockIsValidUrl.mockReturnValue(true)
+
+ const node = {
+ properties: {
+ href: 'http://localhost:5001/files/123/file-preview?timestamp=1&nonce=2&sign=3&as_attachment=true',
+ },
+ }
+
+ render(doc.pdf)
+
+ expect(screen.getByText('doc.pdf')).toHaveAttribute(
+ 'href',
+ 'http://localhost:5001/files/123/file-preview?timestamp=1&nonce=2&sign=3&as_attachment=true',
+ )
+ })
+
+ it('keeps protocol-relative file-preview links protocol-relative', () => {
+ mockIsValidUrl.mockReturnValue(true)
+
+ const node = {
+ properties: {
+ href: '//localhost:5001/files/123/file-preview?timestamp=1#page',
+ },
+ }
+
+ render(doc.pdf)
+
+ expect(screen.getByText('doc.pdf')).toHaveAttribute(
+ 'href',
+ '//localhost:5001/files/123/file-preview?timestamp=1&as_attachment=true#page',
+ )
+ })
+
// --------------------------
// NO HREF
// --------------------------
diff --git a/web/app/components/base/markdown-blocks/link.tsx b/web/app/components/base/markdown-blocks/link.tsx
index d9fab217d4b..cf7bdf6c08c 100644
--- a/web/app/components/base/markdown-blocks/link.tsx
+++ b/web/app/components/base/markdown-blocks/link.tsx
@@ -7,6 +7,19 @@ import * as React from 'react'
import { useChatContext } from '@/app/components/base/chat/chat/context'
import { isValidUrl } from './utils'
+const filePreviewPathPattern = /\/files\/[^/?#]+\/file-preview(?:[?#]|$)/
+const asAttachmentParamPattern = /[?&]as_attachment=/
+
+const withFilePreviewAttachment = (href: string) => {
+ if (!filePreviewPathPattern.test(href) || asAttachmentParamPattern.test(href))
+ return href
+
+ const url = new URL(href, 'http://dify.local')
+ url.searchParams.set('as_attachment', 'true')
+
+ return href.startsWith('//') ? url.href.replace(url.protocol, '') : url.href
+}
+
const Link = ({ node, children, ...props }: any) => {
const { onSend } = useChatContext()
const commonClassName = 'cursor-pointer underline decoration-primary-700! decoration-dashed'
@@ -16,8 +29,9 @@ const Link = ({ node, children, ...props }: any) => {
return onSend?.(hidden_text)} title={node.children[0]?.value || ''}>{node.children[0]?.value || ''}
}
else {
- const href = props.href || node.properties?.href
- if (href && /^#[\w-]+$/.test(href.toString())) {
+ const rawHref = props.href || node.properties?.href
+ const href = rawHref?.toString()
+ if (href && /^#[\w-]+$/.test(href)) {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
// scroll to target element if exists within the answer container
@@ -36,7 +50,7 @@ const Link = ({ node, children, ...props }: any) => {
if (!href || !isValidUrl(href))
return {children}
- return {children || 'Download'}
+ return {children || 'Download'}
}
}