mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
perf(startup): progressively initialize renderer shell
This commit is contained in:
1 parent
d734389f29
commit
3b123b15f2
32 files changed
+1096
-101
No files matched your search
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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 }])
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"done": "完了",
|
||||
"retry": "リトライ",
|
||||
"initFailed": "アプリの初期化に失敗しました",
|
||||
"presentationFallback": "表示設定を時間内に読み込めなかったため、このセッションではシステムテーマと組み込み言語を使用します。",
|
||||
"webWasmWorkspaceBusy": "ローカルワークスペースは一時的に別のタブで使用されています。しばらく待ってから再試行してください。",
|
||||
"loading": "読み込み中...",
|
||||
"initializing": "初期化中...",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"chatHistory": "チャット履歴",
|
||||
"manage": "管理",
|
||||
"noRecords": "記録がありません",
|
||||
"sessionLoadFailed": "チャット履歴を読み込めませんでした",
|
||||
"searchPlaceholder": "チャット名を検索",
|
||||
"noSearchResult": "一致するチャット記録が見つかりません",
|
||||
"contextMenu": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"done": "完成",
|
||||
"retry": "重试",
|
||||
"initFailed": "应用初始化失败",
|
||||
"presentationFallback": "外观偏好未能及时加载,本次启动已使用系统主题和内置语言。",
|
||||
"webWasmWorkspaceBusy": "本地工作区暂时正由另一个标签页使用。请稍候后重试。",
|
||||
"loading": "加载中...",
|
||||
"initializing": "初始化中...",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"chatHistory": "聊天对话",
|
||||
"manage": "管理",
|
||||
"noRecords": "暂无记录",
|
||||
"sessionLoadFailed": "会话列表加载失败",
|
||||
"searchPlaceholder": "搜索对话名称",
|
||||
"noSearchResult": "未找到匹配的聊天记录",
|
||||
"contextMenu": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"done": "完成",
|
||||
"retry": "重試",
|
||||
"initFailed": "應用程式初始化失敗",
|
||||
"presentationFallback": "外觀偏好未能及時載入,本次啟動已使用系統主題和內建語言。",
|
||||
"webWasmWorkspaceBusy": "本機工作區暫時正由另一個分頁使用。請稍候後重試。",
|
||||
"loading": "載入中...",
|
||||
"initializing": "初始化中...",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"chatHistory": "聊天紀錄",
|
||||
"manage": "管理",
|
||||
"noRecords": "暫無紀錄",
|
||||
"sessionLoadFailed": "載入聊天紀錄失敗",
|
||||
"searchPlaceholder": "搜尋聊天名稱",
|
||||
"noSearchResult": "未找到匹配的聊天紀錄",
|
||||
"contextMenu": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
@@ -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' }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ export { useSkillService } from './skill/service'
|
||||
export type { SkillServiceAdapter } from './skill/types'
|
||||
export type {
|
||||
PreferencesAdapter,
|
||||
PresentationPreferences,
|
||||
Preferences,
|
||||
UiConfig,
|
||||
AIGlobalSettings,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user