mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 11:37:31 +08:00
128 lines
5.3 KiB
TypeScript
128 lines
5.3 KiB
TypeScript
// 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'
|
|
import { previewKindOf } from '@/components/chat/preview/previewKind'
|
|
import { openFilePreview } from '@/components/chat/preview/previewBus'
|
|
|
|
// 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
|
|
|
|
// Inline generated images (rendered by useMarkdownRenderer.link() as
|
|
// <img data-generated-image>) open full-size in a new tab on click, so the
|
|
// user can zoom without the picture ever becoming a download.
|
|
const genImg = target.closest<HTMLImageElement>('img[data-generated-image]')
|
|
if (genImg) {
|
|
const src = genImg.getAttribute('src')
|
|
if (src) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
window.open(src, '_blank', 'noopener,noreferrer')
|
|
return
|
|
}
|
|
}
|
|
|
|
const anchor = target.closest<HTMLAnchorElement>('a[href]')
|
|
if (!anchor) return
|
|
|
|
// Match our backend file-API links by PATH, on any origin. Generated-file
|
|
// tools mint absolute URLs against the backend base (in dev that is a
|
|
// different port than the SPA); matching by path catches those too. We
|
|
// always act on the RELATIVE path below, so the fetch hits our own origin
|
|
// (prod) or the vite proxy (dev) — never a foreign host.
|
|
let url: URL
|
|
try {
|
|
url = new URL(anchor.href, window.location.href)
|
|
} catch {
|
|
return
|
|
}
|
|
if (!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)
|
|
const relPath = url.pathname + url.search
|
|
|
|
// Previewable formats (AI-generated docx/xlsx/pdf/…) open in the global
|
|
// preview dialog instead of downloading. Unknown formats fall through to
|
|
// the authenticated download below.
|
|
if (previewKindOf({ name, contentType: '' })) {
|
|
openFilePreview({ name, url: relPath })
|
|
return
|
|
}
|
|
|
|
try {
|
|
const blob = await fetchAuthenticatedBlob(relPath)
|
|
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 })
|
|
})
|
|
}
|