perf(startup): progressively initialize renderer shell

This commit is contained in:
digua committed 2026-08-08 21:47:47 +08:00
1 parent d734389f29
commit 3b123b15f2
32 files changed
+1096 -101

No files matched your search

+1
View File
@@ -41,6 +41,7 @@
"build:cli-web": "vite build --config vite.cli-web.config.mts",
"build:web-wasm": "vite build --config vite.web-wasm.config.mts",
"build:web-ai-spike": "vite build --config tests/web-ai-sdk-spike/vite.config.mts",
"bench:startup": "node scripts/bench-cli-web-startup.mjs",
"docs:dev": "pnpm --filter @openchatlab/docs dev",
"docs:build": "pnpm --filter @openchatlab/docs build",
"docs:preview": "pnpm --filter @openchatlab/docs preview",
@@ -238,6 +238,14 @@ describe('registerSharedRoutes smoke tests', () => {
assert.ok([200, 500].includes(resp.statusCode), `Expected 200 or 500, got ${resp.statusCode}`)
})
it('GET /_web/preferences/bootstrap returns locale and UI config together', async () => {
const resp = await app.inject({ method: 'GET', url: '/_web/preferences/bootstrap' })
assert.equal(resp.statusCode, 200)
const body = resp.json()
assert.equal(typeof body.locale, 'string')
assert.equal(typeof body.uiConfig, 'object')
})
it('leaves lifecycle-owned automation routes to the host initializer', () => {
assert.equal(app.hasRoute({ method: 'GET', url: '/_web/automation/config' }), false)
})
@@ -20,6 +20,14 @@ export function registerPreferencesRoutes(server: FastifyInstance, ctx: Preferen
return prefManager.load()
})
server.get('/_web/preferences/bootstrap', async () => {
const config = loadConfig()
return {
locale: config.locale.lang,
uiConfig: config.ui,
}
})
server.patch<{ Body: Partial<Preferences> }>('/_web/preferences', async (request) => {
return prefManager.save(request.body)
})
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
const DEFAULT_URL = 'http://127.0.0.1:3100/'
const DEFAULT_RUNS = 10
const DEFAULT_WARMUPS = 1
const DEFAULT_TIMEOUT_MS = 15_000
function parsePositiveInteger(value, flag) {
const parsed = Number.parseInt(value, 10)
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} must be a positive integer`)
return parsed
}
function parseArgs(argv) {
const options = {
url: DEFAULT_URL,
runs: DEFAULT_RUNS,
warmups: DEFAULT_WARMUPS,
timeoutMs: DEFAULT_TIMEOUT_MS,
chromePath: process.env.CHATLAB_CHROME_PATH,
json: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--') continue
const next = () => {
const value = argv[index + 1]
if (!value) throw new Error(`${arg} requires a value`)
index += 1
return value
}
if (arg === '--url') options.url = next()
else if (arg === '--runs') options.runs = parsePositiveInteger(next(), arg)
else if (arg === '--warmups') options.warmups = parsePositiveInteger(next(), arg)
else if (arg === '--timeout') options.timeoutMs = parsePositiveInteger(next(), arg)
else if (arg === '--chrome') options.chromePath = next()
else if (arg === '--json') options.json = true
else if (arg === '--help') {
console.log(`Usage: pnpm bench:startup [options]
The CLI Web server must already be running. The benchmark launches an isolated
headless Chrome profile and reads timing-only data from the page.
Options:
--url <url> CLI Web URL (default: ${DEFAULT_URL})
--runs <count> Recorded reloads (default: ${DEFAULT_RUNS})
--warmups <count> Unrecorded warm-up reloads (default: ${DEFAULT_WARMUPS})
--timeout <ms> Per-reload startup timeout (default: ${DEFAULT_TIMEOUT_MS})
--chrome <path> Chrome/Chromium executable (or CHATLAB_CHROME_PATH)
--json Print the full result as JSON
`)
process.exit(0)
} else {
throw new Error(`Unknown option: ${arg}`)
}
}
return options
}
function resolveChromePath(explicitPath) {
const candidates = [
explicitPath,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
].filter(Boolean)
const found = candidates.find((candidate) => existsSync(candidate))
if (!found) throw new Error('Chrome executable not found; pass --chrome or set CHATLAB_CHROME_PATH')
return found
}
async function reservePort() {
return new Promise((resolve, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close()
reject(new Error('Unable to reserve a CDP port'))
return
}
server.close((error) => (error ? reject(error) : resolve(address.port)))
})
})
}
async function waitForJson(url, timeoutMs) {
const deadline = Date.now() + timeoutMs
let lastError
while (Date.now() < deadline) {
try {
const response = await fetch(url)
if (response.ok) return response.json()
lastError = new Error(`HTTP ${response.status}`)
} catch (error) {
lastError = error
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error(`Timed out waiting for ${url}: ${String(lastError)}`)
}
class CdpClient {
constructor(socket) {
this.socket = socket
this.nextId = 1
this.pending = new Map()
this.listeners = new Map()
socket.addEventListener('message', (event) => {
const payload = JSON.parse(String(event.data))
if (payload.id) {
const pending = this.pending.get(payload.id)
if (!pending) return
this.pending.delete(payload.id)
if (payload.error) pending.reject(new Error(payload.error.message))
else pending.resolve(payload.result)
return
}
if (!payload.method) return
const listeners = this.listeners.get(payload.method) ?? []
this.listeners.delete(payload.method)
for (const listener of listeners) listener(payload.params)
})
}
send(method, params = {}) {
const id = this.nextId
this.nextId += 1
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
this.socket.send(JSON.stringify({ id, method, params }))
})
}
waitFor(method, timeoutMs) {
return new Promise((resolve, reject) => {
const listeners = this.listeners.get(method) ?? []
const timer = setTimeout(() => {
this.listeners.set(
method,
(this.listeners.get(method) ?? []).filter((listener) => listener !== onEvent)
)
reject(new Error(`Timed out waiting for CDP event ${method}`))
}, timeoutMs)
const onEvent = (params) => {
clearTimeout(timer)
resolve(params)
}
listeners.push(onEvent)
this.listeners.set(method, listeners)
})
}
close() {
this.socket.close()
}
}
async function connectCdp(webSocketDebuggerUrl) {
const socket = new WebSocket(webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', () => reject(new Error('Failed to connect to Chrome DevTools')), { once: true })
})
return new CdpClient(socket)
}
async function readStartupSnapshot(client, timeoutMs) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const evaluation = await client.send('Runtime.evaluate', {
expression: 'window.__CHATLAB_STARTUP_PERFORMANCE__?.snapshot()',
returnByValue: true,
})
const snapshot = evaluation.result?.value
if (snapshot?.phases?.['shell-interactive'] !== undefined && snapshot.phases['sessions-settled'] !== undefined) {
return snapshot
}
await new Promise((resolve) => setTimeout(resolve, 25))
}
throw new Error('Startup timing API did not report shell-interactive and sessions-settled before the timeout')
}
async function navigateAndMeasure(client, url, timeoutMs) {
const loaded = client.waitFor('Page.loadEventFired', timeoutMs)
await client.send('Page.navigate', { url })
await loaded
return readStartupSnapshot(client, timeoutMs)
}
function percentile(values, fraction) {
const sorted = [...values].sort((left, right) => left - right)
const index = Math.max(0, Math.ceil(sorted.length * fraction) - 1)
return sorted[index]
}
function summarize(values) {
return {
median: percentile(values, 0.5),
p95: percentile(values, 0.95),
min: Math.min(...values),
max: Math.max(...values),
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const chromePath = resolveChromePath(options.chromePath)
const profileDir = await mkdtemp(join(tmpdir(), 'chatlab-startup-bench-'))
const port = await reservePort()
const chrome = spawn(
chromePath,
[
'--headless=new',
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
'--remote-allow-origins=*',
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'about:blank',
],
{ stdio: 'ignore' }
)
try {
await waitForJson(`http://127.0.0.1:${port}/json/version`, options.timeoutMs)
const targetResponse = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(options.url)}`, {
method: 'PUT',
})
if (!targetResponse.ok) throw new Error(`Unable to create Chrome target: HTTP ${targetResponse.status}`)
const target = await targetResponse.json()
const client = await connectCdp(target.webSocketDebuggerUrl)
try {
await client.send('Page.enable')
await client.send('Runtime.enable')
const snapshots = []
for (let index = 0; index < options.warmups + options.runs; index += 1) {
const snapshot = await navigateAndMeasure(client, options.url, options.timeoutMs)
if (index >= options.warmups) snapshots.push(snapshot)
}
const shellValues = snapshots.map((snapshot) => snapshot.phases['shell-interactive'])
const sessionValues = snapshots.map((snapshot) => snapshot.phases['sessions-settled'])
const result = {
url: options.url,
runs: options.runs,
warmups: options.warmups,
shellInteractiveMs: summarize(shellValues),
sessionsSettledMs: summarize(sessionValues),
samples: snapshots,
}
if (options.json) console.log(JSON.stringify(result, null, 2))
else {
console.table({
'shell interactive': result.shellInteractiveMs,
'sessions settled': result.sessionsSettledMs,
})
}
} finally {
client.close()
}
} finally {
const chromeExited = new Promise((resolve) => chrome.once('exit', resolve))
chrome.kill('SIGTERM')
await Promise.race([chromeExited, new Promise((resolve) => setTimeout(resolve, 2_000))])
await rm(profileDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})
+161 -22
View File
@@ -1,16 +1,10 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useColorMode } from '@vueuse/core'
import { storeToRefs } from 'pinia'
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 ScreenCaptureModal from '@/components/common/ScreenCaptureModal.vue'
import SettingsModal from '@/components/common/SettingsModal.vue'
import { ChatRecordDrawer } from '@/components/common/ChatRecord'
import GlobalTaskBar from '@/components/AIChat/GlobalTaskBar.vue'
import DebugToolsPanel from '@/components/layout/DebugToolsPanel.vue'
import { StartupLoading } from '@/components/UI'
import { useSessionStore } from '@/stores/session'
import { useLayoutStore } from '@/stores/layout'
@@ -19,7 +13,11 @@ import { useLLMStore } from '@/stores/llm'
import { useAuthStore } from '@/stores/auth'
import { useApiServerStore } from '@/stores/apiServer'
import { initServices } from '@/services'
import { initPreferencesSync } from '@/composables/usePreferencesSync'
import {
applyPresentationPreferences,
initPreferencesSync,
loadPresentationPreferences,
} from '@/composables/usePreferencesSync'
import { useWindowsTitleBarOverlay } from '@/composables/useWindowsTitleBarOverlay'
import { configureHttpClient } from '@/services/utils/http'
import { IS_ELECTRON } from '@/utils/platform'
@@ -27,10 +25,21 @@ 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 } from '@/bootstrap/app-initialization'
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()
@@ -40,7 +49,6 @@ const settingsStore = useSettingsStore()
const llmStore = useLLMStore()
const authStore = useAuthStore()
const apiServerStore = useApiServerStore()
const { isInitialized } = storeToRefs(sessionStore)
const route = useRoute()
const router = useRouter()
const { isBootstrapMaskVisible, isApplicationInteractive, markLockScreenReady, syncBootstrapMask, updateLockState } =
@@ -48,7 +56,10 @@ const { isBootstrapMaskVisible, isApplicationInteractive, markLockScreenReady, s
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,
@@ -67,22 +78,126 @@ const toaster = {
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 || isInitialized.value) return
if (initInProgress || isShellReady.value) return
initInProgress = true
initError.value = null
presentationWarning.value = false
try {
const result = await initializeAppRuntime({
capabilities: PLATFORM_CAPABILITIES,
initializeServices: () => initServices(),
initializePreferences: () => initPreferencesSync(),
initializeLocale: () => settingsStore.initLocale(),
initializeLlm: () => llmStore.init(),
loadSessions: () => sessionStore.loadSessions(),
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(() => {})
@@ -113,6 +228,20 @@ 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) => {
@@ -176,6 +305,8 @@ onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown)
unlistenPullResult?.()
unlistenPullResult = null
cancelNonCriticalUiPrefetch?.()
cancelNonCriticalUiPrefetch = null
})
</script>
@@ -189,7 +320,7 @@ onUnmounted(() => {
<TitleBar />
<div class="relative flex h-screen w-full overflow-hidden bg-page-bg dark:bg-page-dark">
<!-- 主内容区域 -->
<template v-if="!isInitialized">
<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" />
@@ -205,6 +336,13 @@ onUnmounted(() => {
<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" />
@@ -216,18 +354,19 @@ onUnmounted(() => {
</div>
</template>
<ScreenCaptureModal
v-if="layoutStore.showScreenCaptureModal || layoutStore.screenCaptureImage"
:open="layoutStore.showScreenCaptureModal"
:image-data="layoutStore.screenCaptureImage"
@update:open="(v) => (v ? null : layoutStore.closeScreenCaptureModal())"
/>
<!-- 全局设置弹窗 -->
<SettingsModal />
<SettingsModal v-if="settingsModalMounted" />
<!-- 全局聊天记录查看器 -->
<ChatRecordDrawer />
<ChatRecordDrawer v-if="layoutStore.showChatRecordDrawer || layoutStore.chatRecordQuery" />
<!-- 全局 AI 后台任务条:允许用户离开当前页面后仍然快速返回进行中的对话。 -->
<GlobalTaskBar />
<!-- Desktop 与 CLI Web 迁移后都提醒人工清理。 -->
<DataDirCleanupNotice v-if="!isLoginPage && isInitialized" />
<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">
+71 -1
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { BrowserCapabilityReport } from '@openchatlab/web-runtime'
import { initializeAppRuntime, UnsupportedBrowserCapabilitiesError } from './app-initialization'
import {
initializeAppRuntime,
initializeProgressiveAppRuntime,
UnsupportedBrowserCapabilitiesError,
} from './app-initialization'
const supportedBrowserCapabilities: BrowserCapabilityReport = {
supported: true,
@@ -108,3 +112,69 @@ test('does not open Web WASM session storage when required browser capabilities
assert.deepEqual(calls, ['services', 'capabilities'])
})
test('reveals the shell after presentation state and runs independent startup work concurrently', async () => {
const calls: string[] = []
const releaseTasks: Array<() => void> = []
const stop = () => undefined
const result = await initializeProgressiveAppRuntime({
initializeServices: async () => void calls.push('services'),
loadPresentation: async () => {
calls.push('load-presentation')
return { locale: 'zh-CN' }
},
applyPresentation: (presentation) => void calls.push(`apply-${presentation.locale}`),
applyPresentationFallback: () => void calls.push('fallback'),
initializeBackground: ['preferences', 'llm', 'sessions'].map((name) => ({
name,
run: async () => {
calls.push(`start-${name}`)
await new Promise<void>((resolve) => releaseTasks.push(resolve))
calls.push(`finish-${name}`)
},
})),
listenForPullResults: () => {
calls.push('pull-listener')
return stop
},
})
assert.deepEqual(calls, [
'services',
'load-presentation',
'apply-zh-CN',
'pull-listener',
'start-preferences',
'start-llm',
'start-sessions',
])
assert.equal(result.presentationError, null)
assert.equal(result.stopListeningForPullResults, stop)
releaseTasks.forEach((release) => release())
assert.deepEqual(await result.background, [])
})
test('uses the presentation fallback on timeout and reports background failures by task', async () => {
const failure = new Error('session catalog unavailable')
let applyLatePresentation = false
const result = await initializeProgressiveAppRuntime({
initializeServices: async () => undefined,
loadPresentation: async () => new Promise<{ locale: string }>(() => undefined),
applyPresentation: () => {
applyLatePresentation = true
},
applyPresentationFallback: async () => undefined,
presentationTimeoutMs: 5,
initializeBackground: [
{ name: 'preferences', run: async () => undefined },
{ name: 'sessions', run: async () => Promise.reject(failure) },
],
})
assert.match(String(result.presentationError), /timed out after 5ms/)
assert.equal(applyLatePresentation, false)
assert.deepEqual(await result.background, [{ name: 'sessions', error: failure }])
})
+73
View File
@@ -24,6 +24,79 @@ export interface AppInitializationResult {
stopListeningForPullResults: (() => void) | null
}
export interface ProgressiveInitializationPorts<TPresentation> {
initializeServices(): Promise<void>
loadPresentation(): Promise<TPresentation>
applyPresentation(presentation: TPresentation): Promise<void> | void
applyPresentationFallback(): Promise<void> | void
initializeBackground: Array<{
name: string
run(): Promise<void>
}>
listenForPullResults?: () => () => void
presentationTimeoutMs?: number
}
export interface BackgroundInitializationFailure {
name: string
error: unknown
}
export interface ProgressiveInitializationResult {
presentationError: unknown | null
background: Promise<BackgroundInitializationFailure[]>
stopListeningForPullResults: (() => void) | null
}
class PresentationTimeoutError extends Error {
constructor(timeoutMs: number) {
super(`Presentation bootstrap timed out after ${timeoutMs}ms`)
this.name = 'PresentationTimeoutError'
}
}
async function loadBeforeTimeout<T>(load: () => Promise<T>, timeoutMs: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
load(),
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new PresentationTimeoutError(timeoutMs)), timeoutMs)
}),
])
} finally {
if (timer) clearTimeout(timer)
}
}
/**
* Desktop 与 CLI Web 的渐进式启动:服务和展示态是壳层硬依赖,
* 其余任务并行执行并各自降级,避免一个慢服务阻塞整个界面。
*/
export async function initializeProgressiveAppRuntime<TPresentation>(
ports: ProgressiveInitializationPorts<TPresentation>
): Promise<ProgressiveInitializationResult> {
await ports.initializeServices()
let presentationError: unknown | null = null
try {
const presentation = await loadBeforeTimeout(ports.loadPresentation, ports.presentationTimeoutMs ?? 1_000)
await ports.applyPresentation(presentation)
} catch (error) {
presentationError = error
await ports.applyPresentationFallback()
}
const stopListeningForPullResults = ports.listenForPullResults?.() ?? null
const background = Promise.allSettled(ports.initializeBackground.map((task) => task.run())).then((results) =>
results.flatMap((result, index) =>
result.status === 'rejected' ? [{ name: ports.initializeBackground[index]!.name, error: result.reason }] : []
)
)
return { presentationError, background, stopListeningForPullResults }
}
export async function initializeAppRuntime(ports: AppInitializationPorts): Promise<AppInitializationResult> {
await ports.initializeServices()
+3
View File
@@ -7,6 +7,7 @@ import { router } from '@/routes'
import i18n from '@/i18n'
import { backendPersistPlugin } from '@/plugins/backendPersist'
import { installGlobalErrorReporting, reportError } from '@/services/log-report'
import { markStartupPhase } from '@/bootstrap/startup-performance'
import '@/assets/styles/main.css'
export interface MountChatLabAppOptions {
@@ -32,5 +33,7 @@ export async function mountChatLabApp(options: MountChatLabAppOptions = {}): Pro
app.use(router)
app.use(ui)
app.use(i18n)
markStartupPhase('vue-mount-start')
app.mount('#app')
markStartupPhase('vue-mounted')
}
+54
View File
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { getStartupPerformanceSnapshot, markStartupPhase, type StartupPerformanceSnapshot } from './startup-performance'
class FakePerformance {
private readonly entries: PerformanceEntry[] = []
private nowValue = 0
setNow(value: number): void {
this.nowValue = value
}
mark(name: string): PerformanceMark {
const entry = { name, entryType: 'mark', startTime: this.nowValue, duration: 0 } as PerformanceMark
this.entries.push(entry)
return entry
}
getEntriesByName(name: string, type?: string): PerformanceEntryList {
return this.entries.filter((entry) => entry.name === name && (!type || entry.entryType === type))
}
getEntriesByType(type: string): PerformanceEntryList {
return this.entries.filter((entry) => entry.entryType === type)
}
}
test('startup phases keep their first observation across retries', () => {
const performance = new FakePerformance()
performance.setNow(120.126)
markStartupPhase('services-ready', performance)
performance.setNow(900)
markStartupPhase('services-ready', performance)
const snapshot = getStartupPerformanceSnapshot(performance)
assert.equal(snapshot.phases['services-ready'], 120.13)
})
test('snapshot contains timings only and tolerates unavailable navigation data', () => {
const performance = new FakePerformance()
performance.setNow(42)
markStartupPhase('renderer-module-ready', performance)
const snapshot: StartupPerformanceSnapshot = getStartupPerformanceSnapshot(performance)
assert.deepEqual(snapshot, {
navigation: {
responseEnd: null,
domInteractive: null,
domContentLoaded: null,
loadEventEnd: null,
},
phases: { 'renderer-module-ready': 42 },
})
})
+106
View File
@@ -0,0 +1,106 @@
const STARTUP_MARK_PREFIX = 'chatlab:startup:'
export const STARTUP_PHASES = [
'renderer-module-ready',
'vue-mount-start',
'vue-mounted',
'services-ready',
'preferences-settled',
'locale-settled',
'llm-settled',
'sessions-settled',
'shell-interactive',
'startup-settled',
] as const
export type StartupPhase = (typeof STARTUP_PHASES)[number]
export interface StartupPerformanceSnapshot {
navigation: {
responseEnd: number | null
domInteractive: number | null
domContentLoaded: number | null
loadEventEnd: number | null
}
phases: Partial<Record<StartupPhase, number>>
}
export interface StartupPerformanceApi {
snapshot(): StartupPerformanceSnapshot
}
interface PerformanceEntriesReader {
getEntriesByName(name: string, type?: string): PerformanceEntryList
getEntriesByType(type: string): PerformanceEntryList
}
type PerformanceMarkRecorder = PerformanceEntriesReader & Pick<Performance, 'mark'>
declare global {
interface Window {
__CHATLAB_STARTUP_PERFORMANCE__?: StartupPerformanceApi
}
}
function roundMilliseconds(value: number): number {
return Math.round(value * 100) / 100
}
function firstMarkStartTime(performanceEntryList: PerformanceEntriesReader, phase: StartupPhase): number | undefined {
return performanceEntryList.getEntriesByName(`${STARTUP_MARK_PREFIX}${phase}`, 'mark')[0]?.startTime
}
function getNavigationTiming(performanceEntryList: PerformanceEntriesReader): PerformanceNavigationTiming | undefined {
return performanceEntryList.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
}
/** Record each startup phase once so retries cannot rewrite the initial startup result. */
export function markStartupPhase(
phase: StartupPhase,
performanceEntryList: PerformanceMarkRecorder = performance
): void {
const name = `${STARTUP_MARK_PREFIX}${phase}`
if (performanceEntryList.getEntriesByName(name, 'mark').length > 0) return
performanceEntryList.mark(name)
}
/** Mark after the current DOM update has had one opportunity to paint. */
export function markStartupPhaseAfterPaint(phase: StartupPhase): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
markStartupPhase(phase)
resolve()
})
})
})
}
export function getStartupPerformanceSnapshot(
performanceEntryList: PerformanceEntriesReader = performance
): StartupPerformanceSnapshot {
const navigation = getNavigationTiming(performanceEntryList)
const phases: Partial<Record<StartupPhase, number>> = {}
for (const phase of STARTUP_PHASES) {
const startTime = firstMarkStartTime(performanceEntryList, phase)
if (startTime !== undefined) phases[phase] = roundMilliseconds(startTime)
}
return {
navigation: {
responseEnd: navigation ? roundMilliseconds(navigation.responseEnd) : null,
domInteractive: navigation ? roundMilliseconds(navigation.domInteractive) : null,
domContentLoaded: navigation ? roundMilliseconds(navigation.domContentLoadedEventEnd) : null,
loadEventEnd: navigation?.loadEventEnd ? roundMilliseconds(navigation.loadEventEnd) : null,
},
phases,
}
}
/** Expose timings for local benchmarks and the debug console; no business data is included. */
export function installStartupPerformanceApi(target: Window = window): void {
target.__CHATLAB_STARTUP_PERFORMANCE__ = {
snapshot: () => getStartupPerformanceSnapshot(),
}
}
+5 -5
View File
@@ -2,17 +2,17 @@
import { ref, watch, computed, defineAsyncComponent, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import AISettingsTab from './Settings/AISettingsTab.vue'
import BasicSettingsTab from './Settings/BasicSettingsTab.vue'
import BatchManageTab from './Settings/BatchManageTab.vue'
import StorageTab from './Settings/StorageTab.vue'
import AboutTab from './Settings/AboutTab.vue'
import ApiSettingsTab from './Settings/ApiSettingsTab.vue'
import { usePromptStore } from '@/stores/prompt'
import { useLayoutStore } from '@/stores/layout'
import { IS_ELECTRON } from '@/utils/platform'
import SettingsDialogShell from './Settings/SettingsDialogShell.vue'
const AISettingsTab = defineAsyncComponent(() => import('./Settings/AISettingsTab.vue'))
const BatchManageTab = defineAsyncComponent(() => import('./Settings/BatchManageTab.vue'))
const StorageTab = defineAsyncComponent(() => import('./Settings/StorageTab.vue'))
const AboutTab = defineAsyncComponent(() => import('./Settings/AboutTab.vue'))
const ApiSettingsTab = defineAsyncComponent(() => import('./Settings/ApiSettingsTab.vue'))
const SecuritySettingsTab = IS_ELECTRON
? defineAsyncComponent(() => import('./Settings/SecuritySettingsTab.vue'))
: null
+14 -3
View File
@@ -43,7 +43,7 @@ const hasMacTitlebarInset =
const sessionStore = useSessionStore()
const layoutStore = useLayoutStore()
const { sessions, sortedSessions, filterType } = storeToRefs(sessionStore)
const { sessions, sortedSessions, filterType, loadState, loadError } = storeToRefs(sessionStore)
const { isSidebarCollapsed: isCollapsed } = storeToRefs(layoutStore)
const { toggleSidebar } = layoutStore
const router = useRouter()
@@ -132,7 +132,6 @@ function toggleSearch() {
let unlistenImportCompleted: (() => void) | null = null
onMounted(async () => {
sessionStore.loadSessions()
try {
version.value = await usePlatformService().getVersion()
if (props.backendFeatures) void checkUpdateNotice()
@@ -557,7 +556,19 @@ function getAvatarColorClass(session: AnalysisSession, isActive: boolean) {
<!-- 聊天记录列表 - 可滚动区域,滚动条贴边 -->
<div ref="sessionListRef" class="session-list flex-1 overflow-y-auto">
<div v-if="sessions.length === 0 && !isCollapsed" class="py-8 text-center text-sm text-gray-500">
<div v-if="loadState === 'loading' || loadState === 'idle'" class="space-y-2 px-3 py-2" aria-busy="true">
<USkeleton v-for="index in 6" :key="index" class="h-10 rounded-xl" :class="isCollapsed ? 'w-10' : 'w-full'" />
</div>
<div v-else-if="loadState === 'error' && !isCollapsed" class="px-4 py-8 text-center">
<p class="text-sm text-gray-600 dark:text-gray-300">{{ t('layout.sessionLoadFailed') }}</p>
<p v-if="loadError" class="mt-1 line-clamp-2 text-xs text-gray-400">{{ loadError }}</p>
<UButton size="xs" variant="soft" class="mt-3" @click="sessionStore.loadSessions()">
{{ t('common.retry') }}
</UButton>
</div>
<div v-else-if="sessions.length === 0 && !isCollapsed" class="py-8 text-center text-sm text-gray-500">
{{ t('layout.noRecords') }}
</div>
+24 -12
View File
@@ -12,33 +12,45 @@ import { watch } from 'vue'
import { usePreferencesService } from '@/services'
import { useSettingsStore } from '@/stores/settings'
import { getUiConfig, setUiConfig } from '@/composables/useUiConfig'
import { hydrateAllStores, initBackendPersist } from '@/plugins/backendPersist'
import { completeBackendPersistHydration, hydrateAllStores, initBackendPersist } from '@/plugins/backendPersist'
import type { PresentationPreferences } from '@/services/preferences/types'
let _synced = false
export async function initPreferencesSync(): Promise<void> {
export function loadPresentationPreferences(): Promise<PresentationPreferences> {
return usePreferencesService().getPresentationPreferences()
}
export function applyPresentationPreferences(presentation: PresentationPreferences): void {
setUiConfig(presentation.uiConfig)
const settingsStore = useSettingsStore()
if (presentation.uiConfig.default_session_tab) {
settingsStore.defaultSessionTab = presentation.uiConfig.default_session_tab
}
if (presentation.locale) {
settingsStore.$patch({ locale: presentation.locale as 'zh-CN' | 'en-US' | 'zh-TW' | 'ja-JP' })
}
}
export async function initPreferencesSync(options: { presentationInitialized?: boolean } = {}): Promise<void> {
if (_synced) return
_synced = true
const svc = usePreferencesService()
try {
const [prefs, uiConfig, locale] = await Promise.all([svc.getPreferences(), svc.getUiConfig(), svc.getLocale()])
const [prefs, presentation] = await Promise.all([
svc.getPreferences(),
options.presentationInitialized ? null : svc.getPresentationPreferences(),
])
// config.toml fields — not covered by the plugin
setUiConfig(uiConfig)
const settingsStore = useSettingsStore()
if (uiConfig.default_session_tab) {
settingsStore.defaultSessionTab = uiConfig.default_session_tab
}
if (locale) {
settingsStore.$patch({ locale: locale as 'zh-CN' | 'en-US' | 'zh-TW' | 'ja-JP' })
}
if (presentation) applyPresentationPreferences(presentation)
// preferences.json fields — plugin handles field-level hydration
hydrateAllStores(prefs)
} catch (err) {
console.warn('[PreferencesSync] Failed to load from backend, keeping default state:', err)
completeBackendPersistHydration()
}
// Activate write-back for preferences.json
+1
View File
@@ -11,6 +11,7 @@
"done": "Done",
"retry": "Retry",
"initFailed": "App initialization failed",
"presentationFallback": "Appearance preferences couldn't be loaded in time. System theme and the built-in language are in use for this session.",
"webWasmWorkspaceBusy": "The local workspace is temporarily in use by another tab. Wait a moment and retry.",
"loading": "Loading...",
"initializing": "Initializing...",
+1
View File
@@ -8,6 +8,7 @@
"chatHistory": "Chat History",
"manage": "Manage",
"noRecords": "No records",
"sessionLoadFailed": "Couldn't load conversations",
"searchPlaceholder": "Search conversation name",
"noSearchResult": "No matching chat records found",
"contextMenu": {
+1
View File
@@ -11,6 +11,7 @@
"done": "完了",
"retry": "リトライ",
"initFailed": "アプリの初期化に失敗しました",
"presentationFallback": "表示設定を時間内に読み込めなかったため、このセッションではシステムテーマと組み込み言語を使用します。",
"webWasmWorkspaceBusy": "ローカルワークスペースは一時的に別のタブで使用されています。しばらく待ってから再試行してください。",
"loading": "読み込み中...",
"initializing": "初期化中...",
+1
View File
@@ -8,6 +8,7 @@
"chatHistory": "チャット履歴",
"manage": "管理",
"noRecords": "記録がありません",
"sessionLoadFailed": "チャット履歴を読み込めませんでした",
"searchPlaceholder": "チャット名を検索",
"noSearchResult": "一致するチャット記録が見つかりません",
"contextMenu": {
+1
View File
@@ -11,6 +11,7 @@
"done": "完成",
"retry": "重试",
"initFailed": "应用初始化失败",
"presentationFallback": "外观偏好未能及时加载,本次启动已使用系统主题和内置语言。",
"webWasmWorkspaceBusy": "本地工作区暂时正由另一个标签页使用。请稍候后重试。",
"loading": "加载中...",
"initializing": "初始化中...",
+1
View File
@@ -8,6 +8,7 @@
"chatHistory": "聊天对话",
"manage": "管理",
"noRecords": "暂无记录",
"sessionLoadFailed": "会话列表加载失败",
"searchPlaceholder": "搜索对话名称",
"noSearchResult": "未找到匹配的聊天记录",
"contextMenu": {
+1
View File
@@ -11,6 +11,7 @@
"done": "完成",
"retry": "重試",
"initFailed": "應用程式初始化失敗",
"presentationFallback": "外觀偏好未能及時載入,本次啟動已使用系統主題和內建語言。",
"webWasmWorkspaceBusy": "本機工作區暫時正由另一個分頁使用。請稍候後重試。",
"loading": "載入中...",
"initializing": "初始化中...",
+1
View File
@@ -8,6 +8,7 @@
"chatHistory": "聊天紀錄",
"manage": "管理",
"noRecords": "暫無紀錄",
"sessionLoadFailed": "載入聊天紀錄失敗",
"searchPlaceholder": "搜尋聊天名稱",
"noSearchResult": "未找到匹配的聊天紀錄",
"contextMenu": {
+4
View File
@@ -2,6 +2,10 @@ import '@/icons/disable-iconify-api'
import 'virtual:nuxt-icon-bundle/register'
import { mountChatLabApp } from '@/bootstrap/mount-app'
import { installStartupPerformanceApi, markStartupPhase } from '@/bootstrap/startup-performance'
installStartupPerformanceApi()
markStartupPhase('renderer-module-ready')
void mountChatLabApp().catch((error) => {
console.error('ChatLab startup failed', error)
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createApp } from 'vue'
import { createPinia, defineStore, setActivePinia } from 'pinia'
import type { Preferences } from '@openchatlab/shared-types'
import { backendPersistPlugin, hydrateAllStores, initBackendPersist } from './backendPersist'
test('backend hydration preserves edits made while preferences are still loading', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const pinia = createPinia()
pinia.use(backendPersistPlugin)
createApp({ render: () => null }).use(pinia)
setActivePinia(pinia)
const useTestStore = defineStore('backend-persist-hydration-test', {
state: () => ({ localField: 'default', untouchedField: 'default' }),
backendPersist: { pick: ['localField', 'untouchedField'] },
})
const store = useTestStore()
const saved: Array<Record<string, unknown>> = []
store.localField = 'local edit'
hydrateAllStores({
localField: 'stale backend value',
untouchedField: 'backend value',
} as unknown as Preferences)
initBackendPersist(async (partial) => {
saved.push(partial as Record<string, unknown>)
})
assert.equal(store.localField, 'local edit')
assert.equal(store.untouchedField, 'backend value')
t.mock.timers.tick(500)
await Promise.resolve()
assert.deepEqual(saved, [{ localField: 'local edit', untouchedField: 'backend value' }])
})
+67 -9
View File
@@ -33,12 +33,17 @@ declare module 'pinia' {
type SaveFn = (partial: Partial<Preferences>) => Promise<unknown>
const stores: Map<string, { store: ReturnType<any>; config: BackendPersistConfig }> = new Map()
const dirtyFieldsByStore = new Map<string, Set<string>>()
let saveFn: SaveFn | null = null
let saveTimer: ReturnType<typeof setTimeout> | null = null
let pendingSave: Record<string, unknown> = {}
let hydrationSettled = false
let applyingHydration = false
let hydratedPreferences: Preferences | null = null
function flushSave() {
if (!saveFn || Object.keys(pendingSave).length === 0) return
saveTimer = null
if (!saveFn || !hydrationSettled || Object.keys(pendingSave).length === 0) return
const toSave = { ...pendingSave }
pendingSave = {}
saveFn(toSave as Partial<Preferences>).catch((err) => {
@@ -75,27 +80,67 @@ function collectAndQueue(store: Record<string, unknown>, config: BackendPersistC
*/
export function initBackendPersist(fn: SaveFn): void {
saveFn = fn
if (hydrationSettled && Object.keys(pendingSave).length > 0) scheduleSave()
}
/**
* Hydrate all registered stores from backend preferences data.
*/
export function hydrateAllStores(prefs: Preferences): void {
for (const [, { store, config }] of stores) {
function applyStoreHydration(
storeId: string,
store: ReturnType<any>,
config: BackendPersistConfig,
prefs: Preferences
): void {
const source = config.key
? (prefs as unknown as Record<string, unknown>)[config.key]
: (prefs as unknown as Record<string, unknown>)
if (!source || typeof source !== 'object') continue
if (!source || typeof source !== 'object') return
const dirtyFields = dirtyFieldsByStore.get(storeId)
const patch: Record<string, unknown> = {}
for (const field of config.pick) {
if (dirtyFields?.has(field)) continue
const val = (source as Record<string, unknown>)[field]
if (val !== undefined) patch[field] = val
}
if (Object.keys(patch).length > 0) {
store.$patch(patch)
}
}
function settleHydration(): void {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = null
pendingSave = {}
hydrationSettled = true
// Rebuild queued writes from the merged store state so a pre-hydration edit
// cannot save default values over unrelated fields loaded from the backend.
for (const storeId of dirtyFieldsByStore.keys()) {
const registered = stores.get(storeId)
if (registered) collectAndQueue(registered.store.$state, registered.config)
}
dirtyFieldsByStore.clear()
}
/**
* Hydrate all registered stores from backend preferences data.
* Fields changed locally while the request was pending keep their local value.
*/
export function hydrateAllStores(prefs: Preferences): void {
hydratedPreferences = prefs
applyingHydration = true
try {
for (const [storeId, { store, config }] of stores) {
applyStoreHydration(storeId, store, config, prefs)
}
} finally {
applyingHydration = false
}
settleHydration()
}
/** Mark hydration as complete when backend preferences could not be loaded. */
export function completeBackendPersistHydration(): void {
settleHydration()
}
export const backendPersistPlugin: PiniaPlugin = ({ store, options }) => {
@@ -104,11 +149,24 @@ export const backendPersistPlugin: PiniaPlugin = ({ store, options }) => {
stores.set(store.$id, { store, config })
// Stores created lazily after startup still need the already-loaded backend state.
if (hydrationSettled && hydratedPreferences) {
applyStoreHydration(store.$id, store, config, hydratedPreferences)
}
for (const field of config.pick) {
watch(
() => store.$state[field],
() => collectAndQueue(store.$state, config),
{ deep: true }
() => {
if (applyingHydration) return
if (!hydrationSettled) {
const dirtyFields = dirtyFieldsByStore.get(store.$id) ?? new Set<string>()
dirtyFields.add(field)
dirtyFieldsByStore.set(store.$id, dirtyFields)
}
collectAndQueue(store.$state, config)
},
{ deep: true, flush: 'sync' }
)
}
}
+1
View File
@@ -87,6 +87,7 @@ export { useSkillService } from './skill/service'
export type { SkillServiceAdapter } from './skill/types'
export type {
PreferencesAdapter,
PresentationPreferences,
Preferences,
UiConfig,
AIGlobalSettings,
+6 -1
View File
@@ -1,4 +1,4 @@
import type { Preferences, PreferencesAdapter, UiConfig } from './types'
import type { Preferences, PreferencesAdapter, PresentationPreferences, UiConfig } from './types'
const PREFERENCES_KEY = 'chatlab:web-wasm:preferences'
const UI_CONFIG_KEY = 'chatlab:web-wasm:ui-config'
@@ -21,6 +21,11 @@ export class BrowserPreferencesAdapter implements PreferencesAdapter {
this.storage = storage ?? globalThis.localStorage ?? createMemoryStorage()
}
async getPresentationPreferences(): Promise<PresentationPreferences> {
const [uiConfig, locale] = await Promise.all([this.getUiConfig(), this.getLocale()])
return { uiConfig, locale }
}
async getPreferences(): Promise<Preferences> {
return this.readJson(PREFERENCES_KEY, {} as Preferences)
}
+5 -1
View File
@@ -1,7 +1,11 @@
import { get, patch } from '../utils/http'
import type { PreferencesAdapter, Preferences, UiConfig } from './types'
import type { PreferencesAdapter, Preferences, PresentationPreferences, UiConfig } from './types'
export class FetchPreferencesAdapter implements PreferencesAdapter {
getPresentationPreferences(): Promise<PresentationPreferences> {
return get<PresentationPreferences>('/preferences/bootstrap')
}
getPreferences(): Promise<Preferences> {
return get<Preferences>('/preferences')
}
+6
View File
@@ -17,7 +17,13 @@ export type {
import type { Preferences, UiConfig } from '@openchatlab/shared-types'
export interface PresentationPreferences {
locale: string
uiConfig: UiConfig
}
export interface PreferencesAdapter {
getPresentationPreferences(): Promise<PresentationPreferences>
getPreferences(): Promise<Preferences>
savePreferences(partial: Partial<Preferences>): Promise<{ success: boolean; error?: string }>
getUiConfig(): Promise<UiConfig>
+56 -44
View File
@@ -81,82 +81,94 @@ export async function initializeWebWasmServices(initialize: InitServicesOptions[
* data/message/preferences/ai-streaming use Fetch/SSE; import stays on IPC.
*/
async function initElectronAdapters(): Promise<void> {
const { FetchDataAdapter } = await import('./data/fetch')
const [
{ FetchDataAdapter },
{ ElectronPlatformAdapter },
{ ElectronImportAdapter },
{ TelemetryImportAdapter },
{ FetchSessionIndexAdapter },
{ FetchMessageAdapter },
{ ElectronAIAdapter },
{ FetchPreferencesAdapter },
{ FetchLLMAdapter },
{ FetchAssistantAdapter },
{ FetchSkillAdapter },
{ FetchCacheAdapter },
] = await Promise.all([
import('./data/fetch'),
import('./platform/electron'),
import('./import/electron'),
import('./import/telemetry'),
import('./session-index/fetch'),
import('./message/fetch'),
import('./ai/electron'),
import('./preferences/fetch'),
import('./llm/fetch'),
import('./assistant/fetch'),
import('./skill/fetch'),
import('./cache/fetch'),
])
registerAdapter('data', new FetchDataAdapter())
const { ElectronPlatformAdapter } = await import('./platform/electron')
const platformAdapter = new ElectronPlatformAdapter()
registerAdapter('platform', platformAdapter)
const [{ ElectronImportAdapter }, { TelemetryImportAdapter }] = await Promise.all([
import('./import/electron'),
import('./import/telemetry'),
])
registerAdapter('import', new TelemetryImportAdapter(new ElectronImportAdapter(), platformAdapter))
const { FetchSessionIndexAdapter } = await import('./session-index/fetch')
registerAdapter('session-index', new FetchSessionIndexAdapter())
const { FetchMessageAdapter } = await import('./message/fetch')
registerAdapter('message', new FetchMessageAdapter())
const { ElectronAIAdapter } = await import('./ai/electron')
registerAdapter('ai', new ElectronAIAdapter())
const { FetchPreferencesAdapter } = await import('./preferences/fetch')
registerAdapter('preferences', new FetchPreferencesAdapter())
const { FetchLLMAdapter } = await import('./llm/fetch')
registerAdapter('llm', new FetchLLMAdapter())
const { FetchAssistantAdapter } = await import('./assistant/fetch')
registerAdapter('assistant-crud', new FetchAssistantAdapter())
const { FetchSkillAdapter } = await import('./skill/fetch')
registerAdapter('skill-crud', new FetchSkillAdapter())
const { FetchCacheAdapter } = await import('./cache/fetch')
registerAdapter('cache', new FetchCacheAdapter())
installMergeShims('electron')
}
async function initCliWebAdapters(): Promise<void> {
const { FetchDataAdapter } = await import('./data/fetch')
const [
{ FetchDataAdapter },
{ CliWebPlatformAdapter },
{ FetchImportAdapter },
{ TelemetryImportAdapter },
{ FetchSessionIndexAdapter },
{ FetchMessageAdapter },
{ FetchAIAdapter },
{ FetchPreferencesAdapter },
{ FetchLLMAdapter },
{ FetchAssistantAdapter },
{ FetchSkillAdapter },
{ FetchCacheAdapter },
] = await Promise.all([
import('./data/fetch'),
import('./platform/cli-web'),
import('./import/fetch'),
import('./import/telemetry'),
import('./session-index/fetch'),
import('./message/fetch'),
import('./ai/fetch'),
import('./preferences/fetch'),
import('./llm/fetch'),
import('./assistant/fetch'),
import('./skill/fetch'),
import('./cache/fetch'),
])
registerAdapter('data', new FetchDataAdapter())
const { CliWebPlatformAdapter } = await import('./platform/cli-web')
const platformAdapter = new CliWebPlatformAdapter()
registerAdapter('platform', platformAdapter)
const [{ FetchImportAdapter }, { TelemetryImportAdapter }] = await Promise.all([
import('./import/fetch'),
import('./import/telemetry'),
])
registerAdapter('import', new TelemetryImportAdapter(new FetchImportAdapter(), platformAdapter))
const { FetchSessionIndexAdapter } = await import('./session-index/fetch')
registerAdapter('session-index', new FetchSessionIndexAdapter())
const { FetchMessageAdapter } = await import('./message/fetch')
registerAdapter('message', new FetchMessageAdapter())
const { FetchAIAdapter } = await import('./ai/fetch')
registerAdapter('ai', new FetchAIAdapter())
const { FetchPreferencesAdapter } = await import('./preferences/fetch')
registerAdapter('preferences', new FetchPreferencesAdapter())
const { FetchLLMAdapter } = await import('./llm/fetch')
registerAdapter('llm', new FetchLLMAdapter())
const { FetchAssistantAdapter } = await import('./assistant/fetch')
registerAdapter('assistant-crud', new FetchAssistantAdapter())
const { FetchSkillAdapter } = await import('./skill/fetch')
registerAdapter('skill-crud', new FetchSkillAdapter())
const { FetchCacheAdapter } = await import('./cache/fetch')
registerAdapter('cache', new FetchCacheAdapter())
await installCliWebShims()
+38
View File
@@ -41,6 +41,8 @@ test('strict startup session loading preserves the retry state and rethrows data
(error: unknown) => error === failure
)
assert.equal(store.isInitialized, false)
assert.equal(store.loadState, 'error')
assert.equal(store.loadError, 'catalog unavailable')
})
test('ordinary session refresh remains initialized when data loading fails', async (t) => {
@@ -56,4 +58,40 @@ test('ordinary session refresh remains initialized when data loading fails', asy
await store.loadSessions()
assert.equal(store.isInitialized, true)
assert.equal(store.loadState, 'error')
})
test('session refreshes requested during an active load coalesce into one follow-up request', async () => {
installMemoryStorage()
let requestCount = 0
let releaseRequest: (() => void) | undefined
registerAdapter('data', {
getSessions: async () => {
const requestNumber = ++requestCount
if (requestNumber === 1) {
await new Promise<void>((resolve) => {
releaseRequest = resolve
})
}
return [{ id: requestNumber === 1 ? 'stale-session' : 'fresh-session' }]
},
} as unknown as DataAdapter)
setActivePinia(createPinia())
const { useSessionStore } = await import('./session')
const store = useSessionStore()
const first = store.loadSessions()
const second = store.loadSessions()
const third = store.loadSessions()
assert.equal(requestCount, 1)
assert.equal(store.loadState, 'loading')
releaseRequest?.()
await Promise.all([first, second, third])
assert.equal(requestCount, 2)
assert.equal(store.loadState, 'ready')
assert.deepEqual(
store.sessions.map((session) => session.id),
['fresh-session']
)
})
+49 -1
View File
@@ -14,6 +14,8 @@ export type SessionSortField = 'importedAt' | 'lastMessageTs' | 'messageCount'
/** 排序方向 */
export type SessionSortOrder = 'asc' | 'desc'
export type SessionLoadState = 'idle' | 'loading' | 'ready' | 'error'
/** 迁移信息 */
export interface MigrationInfo {
version: number
@@ -103,6 +105,10 @@ export const useSessionStore = defineStore(
const importProgress = ref<ImportProgress | null>(null)
// 是否初始化完成
const isInitialized = ref(false)
const loadState = ref<SessionLoadState>('idle')
const loadError = ref<string | null>(null)
let sessionLoadPromise: Promise<void> | null = null
let sessionRefreshQueued = false
// 批量导入状态
const isBatchImporting = ref(false)
@@ -169,7 +175,9 @@ export const useSessionStore = defineStore(
/**
* 从数据库加载会话列表
*/
async function loadSessions(options: { throwOnError?: boolean } = {}) {
async function requestSessionCatalog(): Promise<void> {
loadState.value = 'loading'
loadError.value = null
try {
const list = await useDataService().getSessions()
sessions.value = list
@@ -178,6 +186,44 @@ export const useSessionStore = defineStore(
currentSessionId.value = null
}
isInitialized.value = true
loadState.value = 'ready'
} catch (error) {
loadState.value = 'error'
loadError.value = error instanceof Error ? error.message : String(error)
throw error
}
}
async function drainSessionRefreshQueue(): Promise<void> {
let finalError: unknown = null
let finalRequestFailed = false
do {
// 活动请求期间的多次刷新只合并为一次后续请求,避免旧快照吞掉数据变更后的刷新。
sessionRefreshQueued = false
try {
await requestSessionCatalog()
finalError = null
finalRequestFailed = false
} catch (error) {
finalError = error
finalRequestFailed = true
}
} while (sessionRefreshQueued)
sessionLoadPromise = null
if (finalRequestFailed) throw finalError
}
async function loadSessions(options: { throwOnError?: boolean } = {}) {
if (sessionLoadPromise) {
sessionRefreshQueued = true
} else {
sessionLoadPromise = drainSessionRefreshQueue()
}
try {
await sessionLoadPromise
} catch (error) {
console.error('加载会话列表失败:', error)
if (options.throwOnError) {
@@ -894,6 +940,8 @@ export const useSessionStore = defineStore(
isImporting,
importProgress,
isInitialized,
loadState,
loadError,
currentSession,
// 迁移相关
migrationNeeded,
-2
View File
@@ -105,8 +105,6 @@ export const useSettingsStore = defineStore(
dayjs.locale(getDayjsLocale(locale.value))
if (PLATFORM_CAPABILITIES.initializesLlm) await ensureDesensitizeRules()
window.electron?.ipcRenderer.send('app:setDebugMode', debugMode.value)
}