fix(ui): intercept generated-file downloads so failures never wedge the SPA (#243)

This commit is contained in:
matevip 2026-06-01 20:21:21 +08:00
parent 1388b6eec8
commit 9073c94de1
4 changed files with 109 additions and 0 deletions

View File

@ -15,6 +15,7 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { currentLocale } from '@/i18n' import { currentLocale } from '@/i18n'
import { useThemeStore } from '@/stores/useThemeStore' import { useThemeStore } from '@/stores/useThemeStore'
import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick' import { useGlobalWikilinkClick } from '@/composables/useGlobalWikilinkClick'
import { useGlobalFileDownloadClick } from '@/composables/useGlobalFileDownloadClick'
import McConfirmHost from '@/components/common/McConfirmHost.vue' import McConfirmHost from '@/components/common/McConfirmHost.vue'
// Initialize theme applies .dark class to <html> immediately // Initialize theme applies .dark class to <html> immediately
@ -25,6 +26,12 @@ useThemeStore()
// clicks (those carry data-slug); this catches everything else. // clicks (those carry data-slug); this catches everything else.
useGlobalWikilinkClick() useGlobalWikilinkClick()
// Global click delegator for tool-generated file download links
// (`/api/v1/files/...`). Downloads via authenticated fetch blob so an
// expired/missing file degrades to a toast instead of a full-page navigation
// to the backend's 404 JSON, which would otherwise replace the whole SPA.
useGlobalFileDownloadClick()
const { t } = useI18n() const { t } = useI18n()
watchEffect(() => { watchEffect(() => {

View File

@ -0,0 +1,96 @@
// Global click delegator for tool-generated file download links.
//
// `useMarkdownRenderer.link()` turns a tool-returned download URL such as
// `[报告.docx](/api/v1/files/generated/<id>)` into a plain same-origin
// `<a href>`. With no consumer, clicking it lets the browser perform a
// whole-window navigation to that URL. When the file has expired, was never
// produced, or the backend restarted, the endpoint answers
// `404 {"error":"File not found or expired"}` — and because it is a full-page
// navigation, that JSON *replaces the entire SPA*. In the desktop shell there
// is no back affordance, so the user is stuck and must restart the app.
//
// This composable closes that gap. It intercepts clicks on any same-origin
// `/api/v1/files/...` anchor and downloads via an authenticated fetch → blob
// instead of navigating:
// - success → trigger a transient `<a download>`; the SPA never unmounts.
// - failure (404 / expired / network) → an inline toast; the user stays in
// the conversation with the chat intact.
//
// Mounted exactly once at app root (see App.vue). Because the root component
// never unmounts, detaching the listener on unmount is a formality.
import { onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { fetchAuthenticatedBlob } from '@/api/index'
import { mcToast } from '@/composables/useMcToast'
// Matches every backend-served file path: in-memory generated files
// (`/api/v1/files/generated/<id>`) and conversation-scoped media/attachments
// (`/api/v1/files/...`, `/api/v1/chat/files/...`).
const FILE_PATH_RE = /^\/api\/v1\/(files|chat\/files)\//
function filenameFor(anchor: HTMLAnchorElement, pathname: string): string {
const text = (anchor.textContent || '').trim()
// The markdown link label is the human filename ("报告.docx"); prefer it
// when it carries an extension, otherwise fall back to the URL's last segment.
if (text && /\.[a-z0-9]{1,8}$/i.test(text)) return text
const seg = decodeURIComponent(pathname.split('/').filter(Boolean).pop() || '')
return seg || text || 'download'
}
export function useGlobalFileDownloadClick() {
const { t } = useI18n()
async function handleClick(e: MouseEvent) {
// Honour modifier-clicks (open in new tab / window) and non-primary buttons.
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const target = e.target as HTMLElement | null
if (!target) return
const anchor = target.closest<HTMLAnchorElement>('a[href]')
if (!anchor) return
// Only same-origin file-API links; leave everything else to the browser.
let url: URL
try {
url = new URL(anchor.href, window.location.href)
} catch {
return
}
if (url.origin !== window.location.origin || !FILE_PATH_RE.test(url.pathname)) return
// From here the link is ours: never let it become a full-page navigation.
e.preventDefault()
e.stopPropagation()
const name = filenameFor(anchor, url.pathname)
try {
const blob = await fetchAuthenticatedBlob(url.href)
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000)
mcToast.success(t('chat.downloadStarted', { name }))
} catch (err: any) {
// A cache-miss / expiry surfaces as a non-OK fetch ("Fetch failed: 404").
const status = /(\d{3})/.exec(err?.message || '')?.[1]
if (status === '404' || status === '410') {
mcToast.error(t('chat.downloadExpired'))
} else {
mcToast.error(t('chat.downloadFailed', { reason: err?.message || 'unknown' }))
}
}
}
onMounted(() => {
// Capture phase so we intercept before any descendant handler, and before
// the browser's default navigation on the anchor.
document.addEventListener('click', handleClick, { capture: true })
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClick, { capture: true })
})
}

View File

@ -158,6 +158,9 @@ export default {
}, },
copy: 'Copy', copy: 'Copy',
copied: 'Copied', copied: 'Copied',
downloadStarted: 'Downloading: {name}',
downloadExpired: 'This file has expired or is no longer available. Ask the assistant to generate it again, then download.',
downloadFailed: 'Download failed: {reason}',
regenerate: 'Regenerate', regenerate: 'Regenerate',
replyModel: 'Reply model: {model}', replyModel: 'Reply model: {model}',
routing: { routing: {

View File

@ -158,6 +158,9 @@ export default {
}, },
copy: '复制', copy: '复制',
copied: '已复制', copied: '已复制',
downloadStarted: '开始下载:{name}',
downloadExpired: '文件已失效或过期,请重新让助手生成后再下载',
downloadFailed: '下载失败:{reason}',
regenerate: '重新生成', regenerate: '重新生成',
replyModel: '本条回复模型: {model}', replyModel: '本条回复模型: {model}',
routing: { routing: {