diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 746117ae..9d5317cc 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -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 流式对话(支持断线重连) diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 043d3970..7632ed91 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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...', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index c88b0cc9..3f2ac345 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -2,6 +2,9 @@ export default { app: { title: 'MateClaw - AI 助手', }, + router: { + chunkLoadFailed: '页面资源加载失败,请检查网络后重试', + }, common: { save: '保存', saving: '保存中...', diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index 6aea06b4..a811cf7d 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -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 => 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 diff --git a/mateclaw-ui/src/views/layout/MainLayout.vue b/mateclaw-ui/src/views/layout/MainLayout.vue index 62b89f43..a195265f 100644 --- a/mateclaw-ui/src/views/layout/MainLayout.vue +++ b/mateclaw-ui/src/views/layout/MainLayout.vue @@ -222,6 +222,7 @@