@@ -21,6 +21,21 @@ interface ChatFileDownloadAllProps {
2121 files : ChatFile [ ]
2222}
2323
24+ class DirectDownloadRequiredError extends Error {
25+ constructor ( readonly url : string ) {
26+ super ( 'This file must be downloaded directly in the browser.' )
27+ }
28+ }
29+
30+ async function fetchExternalFile ( url : string ) : Promise < Response > {
31+ try {
32+ return await fetch ( url , { cache : 'no-store' } )
33+ } catch {
34+ /** A navigation can download external files whose hosts do not allow CORS reads. */
35+ throw new DirectDownloadRequiredError ( url )
36+ }
37+ }
38+
2439function formatFileSize ( bytes : number ) : string {
2540 if ( bytes === 0 ) return '0 B'
2641 const k = 1024
@@ -80,13 +95,17 @@ async function triggerDownload(file: ChatFile): Promise<void> {
8095 if ( ! url ) throw new Error ( 'File has no download URL' )
8196
8297 /** The same serve route as execution logs resolves current storage access on each click. */
83- // boundary-raw-fetch: binary file download, including externally hosted file URLs
84- let response = await fetch ( url , { cache : 'no-store' } )
98+ let response = hasStorageKey
99+ ? // boundary-raw-fetch: binary file download through the authorized serve route
100+ await fetch ( url , { cache : 'no-store' } )
101+ : await fetchExternalFile ( url )
85102 if ( hasStorageKey && response . status === 401 && isSafeHttpUrl ( file . url ) ) {
103+ await response . body ?. cancel ( )
86104 /** Public chat visitors may only have the file access already delivered in the response. */
87- response = await fetch ( file . url , { cache : 'no-store' } )
105+ response = await fetchExternalFile ( file . url )
88106 }
89107 if ( ! response . ok ) {
108+ await response . body ?. cancel ( )
90109 throw new Error ( 'Unable to download this file. Please try again or request a new copy.' )
91110 }
92111
@@ -95,22 +114,22 @@ async function triggerDownload(file: ChatFile): Promise<void> {
95114
96115export function ChatFileDownload ( { file } : ChatFileDownloadProps ) {
97116 const [ isDownloading , setIsDownloading ] = useState ( false )
98- const [ downloadFailed , setDownloadFailed ] = useState ( false )
117+ const [ downloadError , setDownloadError ] = useState < { directUrl ?: string } | null > ( null )
99118 const [ failedPreviewUrl , setFailedPreviewUrl ] = useState < string | null > ( null )
100119 const fileUrl = getFileUrl ( file )
101120
102121 const handleDownload = async ( ) => {
103122 if ( isDownloading ) return
104123
105124 setIsDownloading ( true )
106- setDownloadFailed ( false )
125+ setDownloadError ( null )
107126
108127 try {
109128 logger . info ( `Initiating download for file: ${ file . name } ` )
110129 await triggerDownload ( file )
111130 } catch ( error ) {
112131 logger . error ( `Failed to download file ${ file . name } :` , error )
113- setDownloadFailed ( true )
132+ setDownloadError ( error instanceof DirectDownloadRequiredError ? { directUrl : error . url } : { } )
114133 } finally {
115134 setIsDownloading ( false )
116135 }
@@ -158,9 +177,24 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) {
158177 ) }
159178 </ div >
160179 </ Button >
161- { downloadFailed && (
180+ { downloadError && (
162181 < p role = 'alert' className = 'text-[var(--text-error)] text-xs' >
163- Unable to download this file. Please try again or request a new copy.
182+ { downloadError . directUrl ? (
183+ < >
184+ Unable to download automatically.{ ' ' }
185+ < a
186+ href = { downloadError . directUrl }
187+ download = { file . name }
188+ target = '_blank'
189+ rel = 'noopener noreferrer'
190+ className = 'underline'
191+ >
192+ Download directly
193+ </ a >
194+ </ >
195+ ) : (
196+ 'Unable to download this file. Please try again or request a new copy.'
197+ ) }
164198 </ p >
165199 ) }
166200 </ div >
0 commit comments