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:
mateaix 2026-07-13 21:50:53 +08:00
parent 6f875215ab
commit cf43294a9e
5 changed files with 89 additions and 2 deletions

View File

@ -68,8 +68,11 @@ public class ChatController {
private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver; private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver;
private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService; private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService;
// 使用虚拟线程池处理 SSEJava 17+ 兼容Java 21 可用 Executors.newVirtualThreadPerTaskExecutor() // Virtual thread per SSE task: matches the app-wide virtual-thread model
private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); // (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 流式对话支持断线重连 * SSE 流式对话支持断线重连

View File

@ -2,6 +2,9 @@ export default {
app: { app: {
title: 'MateClaw - AI Assistant', title: 'MateClaw - AI Assistant',
}, },
router: {
chunkLoadFailed: 'Failed to load page resources. Check your network and try again.',
},
common: { common: {
save: 'Save', save: 'Save',
saving: 'Saving...', saving: 'Saving...',

View File

@ -2,6 +2,9 @@ export default {
app: { app: {
title: 'MateClaw - AI 助手', title: 'MateClaw - AI 助手',
}, },
router: {
chunkLoadFailed: '页面资源加载失败,请检查网络后重试',
},
common: { common: {
save: '保存', save: '保存',
saving: '保存中...', saving: '保存中...',

View File

@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { Capability } from '@/composables/capabilities' import type { Capability } from '@/composables/capabilities'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore' import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { i18n } from '@/i18n'
// Augment vue-router's RouteMeta so each route can declare its capability gate. // Augment vue-router's RouteMeta so each route can declare its capability gate.
declare module 'vue-router' { declare module 'vue-router' {
@ -382,4 +384,74 @@ router.beforeEach(async (to) => {
return true 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 export default router

View File

@ -222,6 +222,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { warmRouteChunks } from '@/router'
import { useIsMobile, useMediaQuery } from '@/composables/useBreakpoint' import { useIsMobile, useMediaQuery } from '@/composables/useBreakpoint'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useThemeStore } from '@/stores/useThemeStore' import { useThemeStore } from '@/stores/useThemeStore'
@ -376,6 +377,11 @@ onMounted(async () => {
fetchAutoApproveSummary() fetchAutoApproveSummary()
// Sidebar attention counts (live / security) are driven by // Sidebar attention counts (live / security) are driven by
// useNotificationCenter it polls when admins are mounted. // 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(() => { onBeforeUnmount(() => {