mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
405 lines
14 KiB
Vue
405 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { useColorMode } from '@vueuse/core'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useI18n } from 'vue-i18n'
|
||
import TitleBar from '@/components/common/TitleBar.vue'
|
||
import Sidebar from '@/components/common/Sidebar.vue'
|
||
import { StartupLoading } from '@/components/UI'
|
||
import { useSessionStore } from '@/stores/session'
|
||
import { useLayoutStore } from '@/stores/layout'
|
||
import { useSettingsStore } from '@/stores/settings'
|
||
import { useLLMStore } from '@/stores/llm'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useApiServerStore } from '@/stores/apiServer'
|
||
import { initServices } from '@/services'
|
||
import {
|
||
applyPresentationPreferences,
|
||
initPreferencesSync,
|
||
loadPresentationPreferences,
|
||
} from '@/composables/usePreferencesSync'
|
||
import { useWindowsTitleBarOverlay } from '@/composables/useWindowsTitleBarOverlay'
|
||
import { configureHttpClient } from '@/services/utils/http'
|
||
import { IS_ELECTRON } from '@/utils/platform'
|
||
import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities'
|
||
import { usePlatformService } from '@/services'
|
||
import { resolvePageTransitionKey } from '@/routes/page-transition-key'
|
||
import { useLockScreenBootstrap } from '@/components/lock-screen/bootstrap'
|
||
import { initializeAppRuntime, initializeProgressiveAppRuntime } from '@/bootstrap/app-initialization'
|
||
import { markStartupPhase, markStartupPhaseAfterPaint } from '@/bootstrap/startup-performance'
|
||
|
||
const LockScreen = IS_ELECTRON ? defineAsyncComponent(() => import('@/components/lock-screen/LockScreen.vue')) : null
|
||
const DataDirCleanupNotice = defineAsyncComponent(() => import('@/components/common/DataDirCleanupNotice.vue'))
|
||
const loadScreenCaptureModal = () => import('@/components/common/ScreenCaptureModal.vue')
|
||
const loadSettingsModal = () => import('@/components/common/SettingsModal.vue')
|
||
const loadChatRecordDrawer = () => import('@/components/common/ChatRecord/ChatRecordDrawer.vue')
|
||
const loadGlobalTaskBar = () => import('@/components/AIChat/GlobalTaskBar.vue')
|
||
const loadDebugToolsPanel = () => import('@/components/layout/DebugToolsPanel.vue')
|
||
const ScreenCaptureModal = defineAsyncComponent(loadScreenCaptureModal)
|
||
const SettingsModal = defineAsyncComponent(loadSettingsModal)
|
||
const ChatRecordDrawer = defineAsyncComponent(loadChatRecordDrawer)
|
||
const GlobalTaskBar = defineAsyncComponent(loadGlobalTaskBar)
|
||
const DebugToolsPanel = defineAsyncComponent(loadDebugToolsPanel)
|
||
|
||
const { t } = useI18n()
|
||
|
||
const sessionStore = useSessionStore()
|
||
const layoutStore = useLayoutStore()
|
||
const settingsStore = useSettingsStore()
|
||
const llmStore = useLLMStore()
|
||
const authStore = useAuthStore()
|
||
const apiServerStore = useApiServerStore()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const { isBootstrapMaskVisible, isApplicationInteractive, markLockScreenReady, syncBootstrapMask, updateLockState } =
|
||
useLockScreenBootstrap(IS_ELECTRON)
|
||
|
||
const isLoginPage = computed(() => PLATFORM_CAPABILITIES.requiresAuth && route.name === 'login')
|
||
const pageTransitionKey = computed(() => resolvePageTransitionKey(route))
|
||
const isShellReady = ref(false)
|
||
const initError = ref<string | null>(null)
|
||
const presentationWarning = ref(false)
|
||
const settingsModalMounted = ref(layoutStore.showSettings)
|
||
const bootstrapDialogRef = ref<HTMLDialogElement | null>(null)
|
||
const colorMode = useColorMode({
|
||
emitAuto: true,
|
||
initialValue: 'light',
|
||
})
|
||
|
||
const tooltip = {
|
||
delayDuration: 100,
|
||
}
|
||
|
||
const toaster = {
|
||
position: 'top-center' as const,
|
||
progress: false,
|
||
duration: 2000,
|
||
}
|
||
|
||
let initInProgress = false
|
||
let unlistenPullResult: (() => void) | null = null
|
||
let cancelNonCriticalUiPrefetch: (() => void) | null = null
|
||
|
||
function scheduleNonCriticalUiPrefetch() {
|
||
if (cancelNonCriticalUiPrefetch) return
|
||
const prefetch = () => {
|
||
cancelNonCriticalUiPrefetch = null
|
||
void Promise.allSettled([
|
||
loadSettingsModal(),
|
||
loadChatRecordDrawer(),
|
||
loadScreenCaptureModal(),
|
||
loadDebugToolsPanel(),
|
||
])
|
||
}
|
||
|
||
if (typeof window.requestIdleCallback === 'function') {
|
||
const idleId = window.requestIdleCallback(prefetch, { timeout: 3_000 })
|
||
cancelNonCriticalUiPrefetch = () => window.cancelIdleCallback(idleId)
|
||
return
|
||
}
|
||
|
||
const timer = window.setTimeout(prefetch, 1_000)
|
||
cancelNonCriticalUiPrefetch = () => window.clearTimeout(timer)
|
||
}
|
||
|
||
async function initializeApp() {
|
||
if (initInProgress || isShellReady.value) return
|
||
initInProgress = true
|
||
initError.value = null
|
||
presentationWarning.value = false
|
||
try {
|
||
if (!PLATFORM_CAPABILITIES.usesBrowserRuntime) {
|
||
const result = await initializeProgressiveAppRuntime({
|
||
initializeServices: async () => {
|
||
await initServices()
|
||
markStartupPhase('services-ready')
|
||
},
|
||
loadPresentation: loadPresentationPreferences,
|
||
applyPresentation: async (presentation) => {
|
||
applyPresentationPreferences(presentation)
|
||
await settingsStore.initLocale()
|
||
markStartupPhase('locale-settled')
|
||
},
|
||
applyPresentationFallback: async () => {
|
||
await settingsStore.initLocale()
|
||
markStartupPhase('locale-settled')
|
||
},
|
||
initializeBackground: [
|
||
{
|
||
name: 'preferences',
|
||
run: async () => {
|
||
try {
|
||
await initPreferencesSync({ presentationInitialized: true })
|
||
} finally {
|
||
markStartupPhase('preferences-settled')
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: 'llm',
|
||
run: async () => {
|
||
try {
|
||
await llmStore.init()
|
||
} finally {
|
||
markStartupPhase('llm-settled')
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: 'sessions',
|
||
run: async () => {
|
||
try {
|
||
await sessionStore.loadSessions({ throwOnError: true })
|
||
} finally {
|
||
markStartupPhase('sessions-settled')
|
||
}
|
||
},
|
||
},
|
||
],
|
||
listenForPullResults: () => apiServerStore.listenPullResult(),
|
||
})
|
||
unlistenPullResult ??= result.stopListeningForPullResults
|
||
presentationWarning.value = result.presentationError !== null
|
||
isShellReady.value = true
|
||
void result.background.then((failures) => {
|
||
failures.forEach(({ name, error }) => console.error(`[Startup] Background task failed: ${name}`, error))
|
||
markStartupPhase('startup-settled')
|
||
})
|
||
usePlatformService()
|
||
.trackDailyActive(settingsStore.locale)
|
||
.catch(() => {})
|
||
return
|
||
}
|
||
|
||
const result = await initializeAppRuntime({
|
||
capabilities: PLATFORM_CAPABILITIES,
|
||
initializeServices: async () => {
|
||
await initServices()
|
||
markStartupPhase('services-ready')
|
||
},
|
||
initializePreferences: async () => {
|
||
await initPreferencesSync()
|
||
markStartupPhase('preferences-settled')
|
||
},
|
||
initializeLocale: async () => {
|
||
await settingsStore.initLocale()
|
||
markStartupPhase('locale-settled')
|
||
},
|
||
initializeLlm: async () => {
|
||
await llmStore.init()
|
||
markStartupPhase('llm-settled')
|
||
},
|
||
loadSessions: async () => {
|
||
await sessionStore.loadSessions()
|
||
markStartupPhase('sessions-settled')
|
||
},
|
||
listenForPullResults: () => apiServerStore.listenPullResult(),
|
||
})
|
||
unlistenPullResult ??= result.stopListeningForPullResults
|
||
isShellReady.value = true
|
||
markStartupPhase('startup-settled')
|
||
usePlatformService()
|
||
.trackDailyActive(settingsStore.locale)
|
||
.catch(() => {})
|
||
} catch (err) {
|
||
console.error('App initialization failed:', err)
|
||
initError.value = err instanceof Error ? err.message : String(err)
|
||
} finally {
|
||
initInProgress = false
|
||
}
|
||
}
|
||
|
||
function handleGlobalKeydown(e: KeyboardEvent) {
|
||
if (!isApplicationInteractive.value) return
|
||
const isMeta = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
|
||
// Ctrl+, → 打开设置
|
||
if (isMeta && e.key === ',') {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (!layoutStore.showSettings) {
|
||
layoutStore.openSettings()
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
// After login success, route changes from login → app; trigger init
|
||
watch(isLoginPage, (isLogin) => {
|
||
if (!isLogin) initializeApp()
|
||
})
|
||
|
||
watch(
|
||
() => layoutStore.showSettings,
|
||
(visible) => {
|
||
if (visible) settingsModalMounted.value = true
|
||
}
|
||
)
|
||
|
||
watch(isShellReady, async (ready) => {
|
||
if (!ready) return
|
||
await nextTick()
|
||
await markStartupPhaseAfterPaint('shell-interactive')
|
||
scheduleNonCriticalUiPrefetch()
|
||
})
|
||
|
||
watch(
|
||
colorMode,
|
||
(val) => {
|
||
if (!IS_ELECTRON) return
|
||
const mode = val === 'auto' ? 'system' : (val as 'light' | 'dark')
|
||
window.api?.setThemeSource(mode)
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
watch(isBootstrapMaskVisible, () => syncBootstrapMask(bootstrapDialogRef.value), { flush: 'post' })
|
||
|
||
useWindowsTitleBarOverlay([
|
||
colorMode,
|
||
() => route.fullPath,
|
||
() => layoutStore.showSettings,
|
||
() => layoutStore.showScreenCaptureModal,
|
||
() => layoutStore.showChatRecordDrawer,
|
||
])
|
||
|
||
onMounted(async () => {
|
||
// 同步原生 modal 在异步锁屏就绪前保持底层文档 inert;chunk 加载失败时不会自动放行。
|
||
syncBootstrapMask(bootstrapDialogRef.value)
|
||
window.addEventListener('keydown', handleGlobalKeydown)
|
||
const platform = navigator.platform.toLowerCase()
|
||
if (platform.includes('win')) {
|
||
document.documentElement.classList.add('platform-windows')
|
||
} else if (platform.includes('linux')) {
|
||
document.documentElement.classList.add('platform-linux')
|
||
}
|
||
|
||
if (IS_ELECTRON) {
|
||
// Electron: get Internal API Server endpoint from preload
|
||
const ep = await window.internalApi?.getEndpoint()
|
||
if (ep) {
|
||
configureHttpClient({ baseUrl: `${ep.baseUrl}/_web`, token: ep.token })
|
||
}
|
||
} else if (PLATFORM_CAPABILITIES.usesCliWebHttp) {
|
||
// CLI Web: use relative paths + dynamic token from auth store
|
||
let redirectingTo401 = false
|
||
const on401 = () => {
|
||
if (redirectingTo401 || router.currentRoute.value.name === 'login') return
|
||
redirectingTo401 = true
|
||
authStore.markRequiresAuth()
|
||
authStore.logout()
|
||
const currentPath = router.currentRoute.value.fullPath
|
||
const redirect = currentPath.startsWith('/login') ? '/' : currentPath
|
||
router.push({ name: 'login', query: { redirect } }).finally(() => {
|
||
redirectingTo401 = false
|
||
})
|
||
}
|
||
configureHttpClient({ getToken: () => authStore.token, on401 })
|
||
}
|
||
|
||
if (isLoginPage.value) return
|
||
|
||
await initializeApp()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('keydown', handleGlobalKeydown)
|
||
unlistenPullResult?.()
|
||
unlistenPullResult = null
|
||
cancelNonCriticalUiPrefetch?.()
|
||
cancelNonCriticalUiPrefetch = null
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<UApp :tooltip="tooltip" :toaster="toaster">
|
||
<template v-if="isLoginPage">
|
||
<router-view />
|
||
</template>
|
||
<template v-else>
|
||
<!-- 自定义标题栏 - 拖拽区域 + 窗口控制按钮 -->
|
||
<TitleBar />
|
||
<div class="relative flex h-screen w-full overflow-hidden bg-page-bg dark:bg-page-dark">
|
||
<!-- 主内容区域 -->
|
||
<template v-if="!isShellReady">
|
||
<div class="flex h-full w-full items-center justify-center">
|
||
<div v-if="initError" class="flex flex-col items-center justify-center gap-3 text-center">
|
||
<UIcon name="i-heroicons-exclamation-triangle" class="h-8 w-8 text-red-500" />
|
||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ t('common.initFailed') }}</p>
|
||
<p class="max-w-sm text-xs text-gray-500">{{ initError }}</p>
|
||
<UButton size="sm" color="primary" variant="soft" @click="initializeApp">
|
||
{{ t('common.retry') }}
|
||
</UButton>
|
||
</div>
|
||
<StartupLoading v-else />
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<Sidebar :backend-features="true" />
|
||
<main class="relative flex-1 overflow-hidden">
|
||
<div
|
||
v-if="presentationWarning"
|
||
class="absolute inset-x-3 top-3 z-30 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 shadow-sm dark:border-amber-800 dark:bg-amber-950/80 dark:text-amber-200"
|
||
role="status"
|
||
>
|
||
{{ t('common.presentationFallback') }}
|
||
</div>
|
||
<router-view v-slot="{ Component }">
|
||
<Transition name="page-fade" mode="out-in">
|
||
<component :is="Component" :key="pageTransitionKey" />
|
||
</Transition>
|
||
</router-view>
|
||
</main>
|
||
<DebugToolsPanel v-if="settingsStore.debugMode" />
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<ScreenCaptureModal
|
||
v-if="layoutStore.showScreenCaptureModal || layoutStore.screenCaptureImage"
|
||
:open="layoutStore.showScreenCaptureModal"
|
||
:image-data="layoutStore.screenCaptureImage"
|
||
@update:open="(v) => (v ? null : layoutStore.closeScreenCaptureModal())"
|
||
/>
|
||
<!-- 全局设置弹窗 -->
|
||
<SettingsModal v-if="settingsModalMounted" />
|
||
<!-- 全局聊天记录查看器 -->
|
||
<ChatRecordDrawer v-if="layoutStore.showChatRecordDrawer || layoutStore.chatRecordQuery" />
|
||
<!-- 全局 AI 后台任务条:允许用户离开当前页面后仍然快速返回进行中的对话。 -->
|
||
<GlobalTaskBar />
|
||
<!-- Desktop 与 CLI Web 迁移后都提醒人工清理。 -->
|
||
<DataDirCleanupNotice v-if="!isLoginPage && isShellReady" />
|
||
<!-- 原生模态锁屏:锁定后由浏览器 top layer 隔离全部底层操作 -->
|
||
<LockScreen v-if="IS_ELECTRON" @ready="markLockScreenReady" @lock-state-change="updateLockState" />
|
||
<Teleport v-if="IS_ELECTRON" to="body">
|
||
<dialog
|
||
ref="bootstrapDialogRef"
|
||
:aria-label="t('common.initializing')"
|
||
aria-busy="true"
|
||
class="pointer-events-auto m-0 h-screen max-h-none w-screen max-w-none border-0 bg-white p-0 outline-none backdrop:bg-white dark:bg-page-dark dark:backdrop:bg-page-dark"
|
||
tabindex="-1"
|
||
@cancel.prevent
|
||
>
|
||
<StartupLoading v-if="isBootstrapMaskVisible" />
|
||
</dialog>
|
||
</Teleport>
|
||
</UApp>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page-fade-enter-active,
|
||
.page-fade-leave-active {
|
||
transition:
|
||
opacity 0.2s ease,
|
||
transform 0.2s ease;
|
||
}
|
||
|
||
.page-fade-enter-from {
|
||
opacity: 0;
|
||
transform: translateY(10px);
|
||
}
|
||
|
||
.page-fade-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
}
|
||
</style>
|