mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(ui): resilient lazy-route loading during heavy agent runs (#515)
- router.onError fallback: a failed route-chunk load hard-navigates to the clicked route once (guarded against reload loops) instead of hanging silently until a manual refresh - warm all lazy route chunks during idle time after login, so sidebar navigation no longer depends on live chunk fetches under load - SSE executor switches to a virtual-thread-per-task executor, matching the app-wide virtual-thread model
This commit is contained in:
parent
6f875215ab
commit
cf43294a9e
@ -68,8 +68,11 @@ public class ChatController {
|
||||
private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver;
|
||||
private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService;
|
||||
|
||||
// 使用虚拟线程池处理 SSE(Java 17+ 兼容,Java 21 可用 Executors.newVirtualThreadPerTaskExecutor())
|
||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||
// Virtual thread per SSE task: matches the app-wide virtual-thread model
|
||||
// (spring.threads.virtual.enabled=true) and, unlike a cached platform-thread
|
||||
// pool, never reuses a thread across tasks, so no ThreadLocal state can leak
|
||||
// from one stream into another.
|
||||
private final ExecutorService sseExecutor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/**
|
||||
* SSE 流式对话(支持断线重连)
|
||||
|
||||
@ -2,6 +2,9 @@ export default {
|
||||
app: {
|
||||
title: 'MateClaw - AI Assistant',
|
||||
},
|
||||
router: {
|
||||
chunkLoadFailed: 'Failed to load page resources. Check your network and try again.',
|
||||
},
|
||||
common: {
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
|
||||
@ -2,6 +2,9 @@ export default {
|
||||
app: {
|
||||
title: 'MateClaw - AI 助手',
|
||||
},
|
||||
router: {
|
||||
chunkLoadFailed: '页面资源加载失败,请检查网络后重试',
|
||||
},
|
||||
common: {
|
||||
save: '保存',
|
||||
saving: '保存中...',
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Capability } from '@/composables/capabilities'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
// Augment vue-router's RouteMeta so each route can declare its capability gate.
|
||||
declare module 'vue-router' {
|
||||
@ -382,4 +384,74 @@ router.beforeEach(async (to) => {
|
||||
return true
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy-chunk resilience (issue #515)
|
||||
// ---------------------------------------------------------------------------
|
||||
// A route chunk fetch that stalls or fails while an agent run keeps the
|
||||
// backend busy leaves the navigation silently hung, and the browser module
|
||||
// map caches the failed dynamic import for the rest of the session — the menu
|
||||
// item stays dead until a full page reload. Two layers fix that class:
|
||||
// 1. router.onError: hard-navigate to the clicked route once (resets the
|
||||
// module map; the history-mode fallback serves index.html for any path),
|
||||
// guarded so a persistently failing chunk cannot reload-loop.
|
||||
// 2. warmRouteChunks(): pre-fetch every lazy route chunk during idle time
|
||||
// right after login, so navigation stops depending on live HTTP fetches.
|
||||
|
||||
const CHUNK_RELOAD_GUARD_KEY = 'mc-chunk-reload-at'
|
||||
const CHUNK_RELOAD_MIN_INTERVAL_MS = 10_000
|
||||
|
||||
function isChunkLoadError(error: unknown): boolean {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
// Chrome / Firefox / Safari wordings respectively.
|
||||
return /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed/i
|
||||
.test(msg)
|
||||
}
|
||||
|
||||
router.onError((error, to) => {
|
||||
if (!isChunkLoadError(error)) return
|
||||
const lastReload = Number(sessionStorage.getItem(CHUNK_RELOAD_GUARD_KEY) || 0)
|
||||
if (Date.now() - lastReload < CHUNK_RELOAD_MIN_INTERVAL_MS) {
|
||||
// Auto-reloaded moments ago and the chunk still fails — surface the
|
||||
// failure instead of looping. Cast around vue-i18n's typed instance:
|
||||
// resolving its fully typed `global.t` overloads at this call site blows
|
||||
// up TS instantiation depth (TS2589).
|
||||
const { t } = (i18n as unknown as { global: { t: (key: string) => string } }).global
|
||||
ElMessage.error(t('router.chunkLoadFailed'))
|
||||
return
|
||||
}
|
||||
sessionStorage.setItem(CHUNK_RELOAD_GUARD_KEY, String(Date.now()))
|
||||
window.location.assign(to.fullPath)
|
||||
})
|
||||
|
||||
/**
|
||||
* Warm every lazy route chunk during browser idle time. Menu navigation then
|
||||
* hits the module cache instead of depending on a fresh HTTP fetch, which can
|
||||
* stall or fail while an agent run keeps the backend and the HTTP/1.1
|
||||
* connection pool busy. Sequential on purpose — a single in-flight chunk
|
||||
* request at a time, yielding back to the browser between chunks. Failures
|
||||
* are ignored: real navigation still gets the onError fallback above.
|
||||
*/
|
||||
export function warmRouteChunks(): void {
|
||||
const idle = typeof window.requestIdleCallback === 'function'
|
||||
? (fn: () => void) => window.requestIdleCallback(fn, { timeout: 10_000 })
|
||||
: (fn: () => void) => window.setTimeout(fn, 1_000)
|
||||
// vue-router replaces a record's lazy loader with the resolved component
|
||||
// after its first navigation, so the function filter naturally skips views
|
||||
// that are already loaded.
|
||||
const loaders = router.getRoutes()
|
||||
.map((route) => route.components?.default)
|
||||
.filter((component): component is () => Promise<unknown> => typeof component === 'function')
|
||||
let index = 0
|
||||
const next = () => {
|
||||
if (index >= loaders.length) return
|
||||
const load = loaders[index]
|
||||
index += 1
|
||||
Promise.resolve()
|
||||
.then(() => load())
|
||||
.catch(() => { /* best-effort warmup; navigation has its own fallback */ })
|
||||
.finally(() => idle(next))
|
||||
}
|
||||
idle(next)
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
@ -222,6 +222,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { warmRouteChunks } from '@/router'
|
||||
import { useIsMobile, useMediaQuery } from '@/composables/useBreakpoint'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useThemeStore } from '@/stores/useThemeStore'
|
||||
@ -376,6 +377,11 @@ onMounted(async () => {
|
||||
fetchAutoApproveSummary()
|
||||
// Sidebar attention counts (live / security) are driven by
|
||||
// useNotificationCenter — it polls when admins are mounted.
|
||||
|
||||
// Warm every lazy route chunk into the browser cache while the tab is
|
||||
// idle, so sidebar navigation never depends on a live chunk fetch while an
|
||||
// agent run keeps the backend busy (issue #515).
|
||||
warmRouteChunks()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user