mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(ui): intercept generated-file downloads so failures never wedge the SPA (#243)
This commit is contained in:
parent
1388b6eec8
commit
9073c94de1
@ -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(() => {
|
||||||
|
|||||||
96
mateclaw-ui/src/composables/useGlobalFileDownloadClick.ts
Normal file
96
mateclaw-ui/src/composables/useGlobalFileDownloadClick.ts
Normal 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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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: {
|
||||||
|
|||||||
@ -158,6 +158,9 @@ export default {
|
|||||||
},
|
},
|
||||||
copy: '复制',
|
copy: '复制',
|
||||||
copied: '已复制',
|
copied: '已复制',
|
||||||
|
downloadStarted: '开始下载:{name}',
|
||||||
|
downloadExpired: '文件已失效或过期,请重新让助手生成后再下载',
|
||||||
|
downloadFailed: '下载失败:{reason}',
|
||||||
regenerate: '重新生成',
|
regenerate: '重新生成',
|
||||||
replyModel: '本条回复模型: {model}',
|
replyModel: '本条回复模型: {model}',
|
||||||
routing: {
|
routing: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user