mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +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.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver;
|
||||||
private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService;
|
private final vip.mate.tool.document.preview.OfficePreviewService officePreviewService;
|
||||||
|
|
||||||
// 使用虚拟线程池处理 SSE(Java 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 流式对话(支持断线重连)
|
||||||
|
|||||||
@ -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...',
|
||||||
|
|||||||
@ -2,6 +2,9 @@ export default {
|
|||||||
app: {
|
app: {
|
||||||
title: 'MateClaw - AI 助手',
|
title: 'MateClaw - AI 助手',
|
||||||
},
|
},
|
||||||
|
router: {
|
||||||
|
chunkLoadFailed: '页面资源加载失败,请检查网络后重试',
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
save: '保存',
|
save: '保存',
|
||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user