diff --git a/src/main/index.ts b/src/main/index.ts index f9d213c..203a276 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -82,6 +82,7 @@ import { type DeepSeekHarnessFork } from './agent/deepseek-harness-utility-launcher' import { buildControlledHarnessEnvironment } from './agent/process-environment' +import { runStartupPrerequisites } from './startup-prerequisites' const shortcut = 'CommandOrControl+Shift+Space' const mainModuleDirectory = dirname(fileURLToPath(import.meta.url)) @@ -380,9 +381,11 @@ if (hasSingleInstanceLock) { join(app.getPath('userData'), 'runtime-settings.json'), secureCipher ) - const initialRuntimeSettings = - await settingsStore.getPublicSettings() - const initialSettings = await settingsStore.getResolvedSettings() + const [initialRuntimeSettings, initialResolvedSettings] = + await Promise.all([ + settingsStore.getPublicSettings(), + settingsStore.getResolvedSettings() + ]) globalTlsPolicy = new GlobalTlsPolicy(app) globalTlsPolicy.install() const capabilityService = new CapabilityService( @@ -444,10 +447,6 @@ if (hasSingleInstanceLock) { app.getPath('userData'), 'deepseek-harness' ) - await mkdir(deepSeekHarnessHome, { - recursive: true, - mode: 0o700 - }) const launchDeepSeekHarness = createDeepSeekHarnessUtilityLauncher({ bundledHostPath: bundledRuntimePaths.deepseekHarness, @@ -458,56 +457,29 @@ if (hasSingleInstanceLock) { fork: forkDeepSeekHarness, terminateProcess: terminateHarnessUtilityProcess }) - knowledgeService = new KnowledgeService({ + const startupKnowledgeService = new KnowledgeService({ databasePath: join(app.getPath('userData'), 'knowledge.sqlite'), managedRoot: join(app.getPath('userData'), 'knowledge'), extractStructured: createModelGraphExtractor(settingsStore), parseDocument: documentParsingService.parse }) - await knowledgeService.initialize() - const knowledgeRuntimeSettings = - await settingsStore.getResolvedSettings() - void knowledgeService - .setEmbeddingProvider( - createEmbeddingProvider(knowledgeRuntimeSettings) - ) - .catch(() => undefined) - void knowledgeService - .setRerankProvider( - createRerankProvider(knowledgeRuntimeSettings) - ) - .catch(() => undefined) - assistantDatabase = new AssistantDatabase( + knowledgeService = startupKnowledgeService + const startupAssistantDatabase = new AssistantDatabase( join(app.getPath('userData'), 'assistant.sqlite') ) - assistantDatabase.initialize(defaultWorkspace) - assistantDatabase.ensureChannelProjects( - defaultWorkspace, - initialRuntimeSettings.defaultModelProfileId - ) - channelSettingsStore.reportRuntimeSelectionRepairs( - assistantDatabase.repairConversationRuntimeSelections( - initialRuntimeSettings - ) - ) + assistantDatabase = startupAssistantDatabase const goodbuddyConfigService = new GoodBuddyConfigService( applicationSettingsStore, capabilityService ) - knowledgeGateway = new KnowledgeMcpGateway(knowledgeService, { - magicNotesDatabase: assistantDatabase, - configService: goodbuddyConfigService - }) - await knowledgeGateway.start() - const subagentService = new SubagentService( - createDefaultModelRuntime(defaultWorkspace, initialSettings), - assistantDatabase, - undefined, - createSubagentProfileRuntimes( - defaultWorkspace, - initialSettings - ) + const startupKnowledgeGateway = new KnowledgeMcpGateway( + startupKnowledgeService, + { + magicNotesDatabase: startupAssistantDatabase, + configService: goodbuddyConfigService + } ) + knowledgeGateway = startupKnowledgeGateway const createRuntimeWithCapabilities = async ( settings: ResolvedRuntimeSettings, target: SelectedRuntimeTarget @@ -547,7 +519,7 @@ if (hasSingleInstanceLock) { browserCapability?.enabled && browserCapability.supported ? browserService : undefined, - knowledgeGateway, + knowledgeGateway: startupKnowledgeGateway, webSearchEnabled: webSearchCapability?.enabled }) } @@ -576,9 +548,53 @@ if (hasSingleInstanceLock) { resolved.target ) } - runtime = new AgentRuntimeController( - await createConfiguredRuntime() + const configuredRuntime = await runStartupPrerequisites({ + prepareDeepSeekHome: async () => { + await mkdir(deepSeekHarnessHome, { + recursive: true, + mode: 0o700 + }) + }, + initializeKnowledgeAndGateway: async () => { + await startupKnowledgeService.initialize() + await Promise.all([ + startupKnowledgeService.setEmbeddingProvider( + createEmbeddingProvider(initialResolvedSettings) + ).catch(() => undefined), + startupKnowledgeService.setRerankProvider( + createRerankProvider(initialResolvedSettings) + ).catch(() => undefined) + ]) + await startupKnowledgeGateway.start() + }, + hydrateConfiguredRuntime: () => + createConfiguredRuntime(initialResolvedSettings), + initializeAssistant: () => { + startupAssistantDatabase.initialize(defaultWorkspace) + startupAssistantDatabase.ensureChannelProjects( + defaultWorkspace, + initialRuntimeSettings.defaultModelProfileId + ) + channelSettingsStore.reportRuntimeSelectionRepairs( + startupAssistantDatabase.repairConversationRuntimeSelections( + initialRuntimeSettings + ) + ) + } + }) + const subagentService = new SubagentService( + createDefaultModelRuntime( + defaultWorkspace, + initialResolvedSettings + ), + startupAssistantDatabase, + undefined, + createSubagentProfileRuntimes( + defaultWorkspace, + initialResolvedSettings + ) ) + runtime = new AgentRuntimeController(configuredRuntime) selectedRuntimeManager = new SelectedRuntimeManager( createSelectedRuntime ) diff --git a/src/main/knowledge/knowledge-database.test.ts b/src/main/knowledge/knowledge-database.test.ts index 8213334..7c59e57 100644 --- a/src/main/knowledge/knowledge-database.test.ts +++ b/src/main/knowledge/knowledge-database.test.ts @@ -120,6 +120,69 @@ describe('KnowledgeDatabase', () => { .toHaveLength(1) }) + it('repairs a mismatched user version without losing current-schema data', async () => { + const { database, path } = await createDatabase() + const knowledgeBase = database.createKnowledgeBase({ + name: 'Mismatch repair', + storageMode: 'reference' + }) + seedDocument(database, knowledgeBase.id, 'mismatch-repair') + database.close() + + const mismatch = new DatabaseSync(path) + mismatch.exec('PRAGMA user_version = 10') + mismatch.close() + + const repaired = new KnowledgeDatabase(path) + openDatabases.push(repaired) + repaired.initialize() + + expect(repaired.getKnowledgeBase(knowledgeBase.id)).toMatchObject({ + name: 'Mismatch repair' + }) + expect(repaired.listDocuments(knowledgeBase.id)).toHaveLength(1) + const inspection = new DatabaseSync(path) + expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ + user_version: 11 + }) + inspection.close() + }) + + it.each([ + ['migration version', 'INSERT INTO schema_migrations VALUES (12, ?)', true], + ['user version', 'PRAGMA user_version = 12', false] + ])('rejects a future %s without downgrading it', async ( + _label, + statement, + hasParameter + ) => { + const { database, path } = await createDatabase() + database.close() + const future = new DatabaseSync(path) + if (hasParameter) { + future.prepare(statement).run(new Date().toISOString()) + } else { + future.exec(statement) + } + future.close() + + const unsupported = new KnowledgeDatabase(path) + expect(() => unsupported.initialize()).toThrow( + 'newer than supported version 11' + ) + + const inspection = new DatabaseSync(path) + expect(inspection.prepare('PRAGMA user_version').get()).toEqual({ + user_version: hasParameter ? 11 : 12 + }) + expect( + inspection + .prepare('SELECT MAX(version) AS version FROM schema_migrations') + .get() + ).toEqual({ version: hasParameter ? 12 : 11 }) + inspection.close() + }) + it('keeps graph generation off unless explicitly enabled', async () => { const { database } = await createDatabase() const defaultLibrary = database.createKnowledgeBase({ diff --git a/src/main/knowledge/knowledge-database.ts b/src/main/knowledge/knowledge-database.ts index 50190db..39f1d42 100644 --- a/src/main/knowledge/knowledge-database.ts +++ b/src/main/knowledge/knowledge-database.ts @@ -4426,6 +4426,31 @@ export class KnowledgeDatabase { } private migrate(database: DatabaseSync): void { + const migrationTable = database + .prepare( + `SELECT 1 AS found FROM sqlite_schema + WHERE type = 'table' AND name = 'schema_migrations'` + ) + .get() + if (migrationTable) { + const versions = database + .prepare( + `SELECT + (SELECT COALESCE(MAX(version), 0) FROM schema_migrations) + AS migration_version, + user_version + FROM pragma_user_version` + ) + .get() + if ( + versions && + asNumber(versions, 'migration_version') === DATABASE_VERSION && + asNumber(versions, 'user_version') === DATABASE_VERSION + ) { + return + } + } + database.exec('BEGIN IMMEDIATE') try { database.exec(` @@ -4443,6 +4468,17 @@ export class KnowledgeDatabase { `Knowledge database version ${currentVersion} is newer than supported version ${DATABASE_VERSION}` ) } + const userVersionRow = database + .prepare('PRAGMA user_version') + .get() + const userVersion = userVersionRow + ? asNumber(userVersionRow, 'user_version') + : 0 + if (userVersion > DATABASE_VERSION) { + throw new Error( + `Knowledge database user version ${userVersion} is newer than supported version ${DATABASE_VERSION}` + ) + } if (currentVersion < 1) { this.migrateToVersion1(database) database diff --git a/src/main/startup-prerequisites.test.ts b/src/main/startup-prerequisites.test.ts new file mode 100644 index 0000000..e007d85 --- /dev/null +++ b/src/main/startup-prerequisites.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest' +import { runStartupPrerequisites } from './startup-prerequisites' + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +describe('runStartupPrerequisites', () => { + it('starts independent work before synchronous initialization and waits for every branch', async () => { + const order: string[] = [] + const deepSeekHome = deferred() + const knowledgeAndGateway = deferred() + const configuredRuntime = deferred<{ id: string }>() + + const result = runStartupPrerequisites({ + prepareDeepSeekHome: () => { + order.push('deepseek') + return deepSeekHome.promise + }, + initializeKnowledgeAndGateway: () => { + order.push('knowledge') + return knowledgeAndGateway.promise + }, + hydrateConfiguredRuntime: () => { + order.push('runtime') + return configuredRuntime.promise + }, + initializeAssistant: () => { + order.push('assistant') + } + }) + const completed = vi.fn() + void result.then(completed) + + expect(order).toEqual([ + 'deepseek', + 'knowledge', + 'runtime', + 'assistant' + ]) + + configuredRuntime.resolve({ id: 'configured' }) + knowledgeAndGateway.resolve() + await Promise.resolve() + expect(completed).not.toHaveBeenCalled() + + deepSeekHome.resolve() + await expect(result).resolves.toEqual({ id: 'configured' }) + expect(completed).toHaveBeenCalledOnce() + }) + + it('settles every started branch before propagating synchronous initialization failure', async () => { + const assistantError = new Error('assistant failed') + const deepSeekHome = deferred() + const knowledgeAndGateway = deferred() + const configuredRuntime = deferred<{ id: string }>() + + const result = runStartupPrerequisites({ + prepareDeepSeekHome: () => deepSeekHome.promise, + initializeKnowledgeAndGateway: () => + knowledgeAndGateway.promise, + hydrateConfiguredRuntime: () => configuredRuntime.promise, + initializeAssistant: () => { + throw assistantError + } + }) + const rejected = vi.fn() + void result.catch(rejected) + + deepSeekHome.resolve() + knowledgeAndGateway.resolve() + await Promise.resolve() + expect(rejected).not.toHaveBeenCalled() + + configuredRuntime.resolve({ id: 'unused' }) + await expect(result).rejects.toBe(assistantError) + expect(rejected).toHaveBeenCalledOnce() + }) + + it('does not publish an async branch failure until the other branches settle', async () => { + const runtimeError = new Error('runtime failed') + const deepSeekHome = deferred() + const knowledgeAndGateway = deferred() + + const result = runStartupPrerequisites({ + prepareDeepSeekHome: () => deepSeekHome.promise, + initializeKnowledgeAndGateway: () => + knowledgeAndGateway.promise, + hydrateConfiguredRuntime: () => + Promise.reject(runtimeError), + initializeAssistant: () => undefined + }) + const rejected = vi.fn() + void result.catch(rejected) + + await Promise.resolve() + expect(rejected).not.toHaveBeenCalled() + + deepSeekHome.resolve() + knowledgeAndGateway.resolve() + await expect(result).rejects.toBe(runtimeError) + expect(rejected).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/startup-prerequisites.ts b/src/main/startup-prerequisites.ts new file mode 100644 index 0000000..c21a4b2 --- /dev/null +++ b/src/main/startup-prerequisites.ts @@ -0,0 +1,61 @@ +export type StartupPrerequisiteDependencies = { + prepareDeepSeekHome: () => Promise + initializeKnowledgeAndGateway: () => Promise + hydrateConfiguredRuntime: () => Promise + initializeAssistant: () => void +} + +function startObserved(operation: () => Promise): Promise { + let started: Promise + try { + started = Promise.resolve(operation()) + } catch (error) { + started = Promise.reject(error) + } + void started.catch(() => undefined) + return started +} + +export async function runStartupPrerequisites( + dependencies: StartupPrerequisiteDependencies +): Promise { + const deepSeekHomeReady = startObserved( + dependencies.prepareDeepSeekHome + ) + const knowledgeAndGatewayReady = startObserved( + dependencies.initializeKnowledgeAndGateway + ) + const configuredRuntimeReady = startObserved( + dependencies.hydrateConfiguredRuntime + ) + + let assistantInitializationFailed = false + let assistantInitializationError: unknown + try { + dependencies.initializeAssistant() + } catch (error) { + assistantInitializationFailed = true + assistantInitializationError = error + } + + const [deepSeekHome, knowledgeAndGateway, configuredRuntime] = + await Promise.allSettled([ + deepSeekHomeReady, + knowledgeAndGatewayReady, + configuredRuntimeReady + ] as const) + + if (assistantInitializationFailed) { + throw assistantInitializationError + } + if (deepSeekHome.status === 'rejected') { + throw deepSeekHome.reason + } + if (knowledgeAndGateway.status === 'rejected') { + throw knowledgeAndGateway.reason + } + if (configuredRuntime.status === 'rejected') { + throw configuredRuntime.reason + } + return configuredRuntime.value +} diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 7887b8c..be5a982 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -692,13 +692,23 @@ function selectComposerOption( fireEvent.click(option) } +function deferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + describe('App', () => { beforeEach(() => { localStorage.clear() delete document.documentElement.dataset.theme document.documentElement.style.colorScheme = '' vi.clearAllMocks() - api.channels = undefined vi.mocked(api.conversations.list).mockReset().mockResolvedValue([]) vi.mocked(api.conversations.replace) .mockReset() @@ -712,6 +722,7 @@ describe('App', () => { vi.mocked(api.conversations.onChanged) .mockReset() .mockReturnValue(() => undefined) + api.channels = undefined newConversationListener = undefined beforeQuitListener = undefined browserListener = undefined @@ -744,6 +755,11 @@ describe('App', () => { supportsToolExecution: true, detail: 'Ready' }) + vi.stubGlobal( + 'requestIdleCallback', + vi.fn(() => 1) + ) + vi.stubGlobal('cancelIdleCallback', vi.fn()) Object.defineProperty(window, 'goodbuddy', { configurable: true, value: api @@ -752,6 +768,7 @@ describe('App', () => { afterEach(() => { cleanup() + vi.unstubAllGlobals() vi.restoreAllMocks() }) @@ -823,6 +840,39 @@ describe('App', () => { expect(screen.getByText('GOODBUDDY 工作台')).toBeInTheDocument() }) + it('schedules lazy workspace routes for idle preloading', () => { + render() + + expect(window.requestIdleCallback).toHaveBeenCalledWith( + expect.any(Function), + { timeout: 2000 } + ) + }) + + it('waits for project bootstrap before project-scoped startup loads', async () => { + const projects = deferred<(typeof project)[]>() + vi.mocked(api.projects.list).mockImplementationOnce( + () => projects.promise + ) + + render() + + expect(api.projects.list).toHaveBeenCalledOnce() + expect(api.memory.list).not.toHaveBeenCalled() + expect(api.schedules.list).not.toHaveBeenCalled() + expect(api.heartbeats.list).not.toHaveBeenCalled() + + await act(async () => projects.resolve([project])) + + await waitFor(() => { + expect(api.memory.list).toHaveBeenCalledOnce() + expect(api.memory.list).toHaveBeenCalledWith(projectId) + expect(api.schedules.list).toHaveBeenCalledOnce() + expect(api.schedules.list).toHaveBeenCalledWith(projectId) + expect(api.heartbeats.list).toHaveBeenCalledOnce() + }) + }) + it('shows an accessible fallback while a lazy route loads', async () => { lazyRouteMocks.suspendKnowledgeRoute() try { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 46809e8..bb41937 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -34,7 +34,6 @@ import { Sun, TerminalSquare, Trash2, - UserRound, X } from 'lucide-react' import { @@ -93,7 +92,6 @@ import type { ConversationSnapshot, ConversationAttachment, ConversationMessageBlock, - ConversationToolActivity, LocalConversationHeader, LocalConversationSaveBatch, ProjectCreateInput, @@ -109,7 +107,13 @@ import { projectChannelLabels } from '../../shared/assistant-contracts' import { ActivityPanel } from './ActivityPanel' -import { AgentQuestionCard } from './AgentQuestionCard' +import { + ChatTimeline, + type ImageViewerItem, + type Message, + type SubagentActivity, + type ToolActivity +} from './ChatTimeline' import { loadActivityRecords, reconcileActivityRecords, @@ -121,7 +125,6 @@ import { KnowledgeCitationDialog, type KnowledgeCitationContextView } from './KnowledgeCitationDialog' -import { MarkdownRenderer } from './MarkdownRenderer' import { DestructiveConfirmActions, EmptyState, @@ -161,24 +164,37 @@ import type { } from './notifications' import type { ReleaseNotesSnapshot } from '../../shared/release-notes-contracts' import { ReleaseNotesDialog } from './ReleaseNotesDialog' +import { scheduleIdleRoutePreload } from './idle-route-preload' +import { formatTime } from './time-format' + +const loadKnowledgeWorkspace = () => import('./KnowledgeWorkspace') +const loadHeartbeatCenter = () => import('./HeartbeatCenter') +const loadMagicNotesWorkspace = () => import('./MagicNotesWorkspace') +const loadSettingsPanel = () => import('./SettingsPanel') +const idleRouteModuleLoaders = [ + loadKnowledgeWorkspace, + loadHeartbeatCenter, + loadMagicNotesWorkspace, + loadSettingsPanel +] as const const KnowledgeWorkspace = lazy(async () => { - const module = await import('./KnowledgeWorkspace') + const module = await loadKnowledgeWorkspace() return { default: module.KnowledgeWorkspace } }) const HeartbeatCenter = lazy(async () => { - const module = await import('./HeartbeatCenter') + const module = await loadHeartbeatCenter() return { default: module.HeartbeatCenter } }) const MagicNotesWorkspace = lazy(async () => { - const module = await import('./MagicNotesWorkspace') + const module = await loadMagicNotesWorkspace() return { default: module.MagicNotesWorkspace } }) const SettingsPanel = lazy(async () => { - const module = await import('./SettingsPanel') + const module = await loadSettingsPanel() return { default: module.SettingsPanel } }) @@ -385,50 +401,6 @@ function supportsSubagentSmartRouting( return workMode === 'ask' } -type ToolActivity = ConversationToolActivity - -type SubagentActivity = { - childTaskId: string - expertId: string - expertName: string - routingMode: 'manual' | 'smart' - state: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' - reason?: string - error?: string -} - -type KnowledgeRetrievalStatus = Omit< - Extract, - 'requestId' | 'type' -> - -type Message = { - id: string - role: 'user' | 'assistant' - content: string - reasoning?: string - blocks?: ConversationMessageBlock[] - createdAt: number - state: 'streaming' | 'complete' | 'error' - status?: string - tools?: ToolActivity[] - subagents?: SubagentActivity[] - approval?: { - id: string - title: string - description: string - toolName?: string - argumentSummary?: string - allowPermanent?: boolean - } - question?: Extract - sources?: string[] - sourceReferences?: KnowledgeSearchReference[] - knowledgeRetrieval?: KnowledgeRetrievalStatus - artifactIds?: string[] - attachments?: ConversationAttachment[] -} - type Conversation = { id: string projectId?: string @@ -440,11 +412,6 @@ type Conversation = { messages: Message[] } -type ImageViewerItem = { - src: string - title: string -} - type ActiveRun = { conversationId: string messageId: string @@ -572,148 +539,8 @@ function isErrorRepresentedByFailedTool( ) } -type MessageBlockRenderItem = - | { - kind: 'block' - block: Exclude - } - | { - kind: 'tools' - id: string - tools: ToolActivity[] - } - -function groupMessageBlocks( - blocks: ConversationMessageBlock[] -): MessageBlockRenderItem[] { - const items: MessageBlockRenderItem[] = [] - for (const block of blocks) { - if (block.type !== 'tool') { - items.push({ kind: 'block', block }) - continue - } - const previous = items.at(-1) - if (previous?.kind === 'tools') { - previous.tools.push(block.tool) - } else { - items.push({ - kind: 'tools', - id: block.id, - tools: [block.tool] - }) - } - } - return items -} - const chatBottomProximity = 96 -function MessageReasoning({ - content, - streaming -}: { - content: string - streaming: boolean -}): React.JSX.Element { - const { t } = useTranslation('app') - const contentRef = useRef(null) - - useEffect(() => { - if (!streaming || !contentRef.current) { - return - } - contentRef.current.scrollTo({ - top: contentRef.current.scrollHeight, - behavior: 'auto' - }) - }, [content, streaming]) - - return ( -
- - {streaming - ? t('chat.reasoning.streaming') - : t('chat.reasoning.complete')} - -
- {content} -
-
- ) -} - -function ToolExecutionList({ - tools -}: { - tools: ToolActivity[] -}): React.JSX.Element { - const { t } = useTranslation('app') - - return ( -
-
-
-
    - {tools.map((tool) => { - const hasDetails = Boolean( - tool.input || tool.output || tool.error - ) - return ( -
  1. -
    - - - {tool.name} - {tool.summary} - - {t(`chat.tools.states.${tool.state}`)} - -
    - {tool.input && ( -
    - {t('chat.tools.input')} -
    {tool.input}
    -
    - )} - {tool.output && ( -
    - {t('chat.tools.output')} -
    {tool.output}
    -
    - )} - {tool.error && ( -
    - {t('chat.tools.error')} -
    {tool.error}
    -
    - )} - {!hasDetails &&

    {t('chat.tools.noDetails')}

    } -
    -
    -
  2. - ) - })} -
-
- ) -} - function createConversation( projectId?: string, runtimeSelection?: AgentRuntimeSelection, @@ -1098,13 +925,6 @@ function getConfiguredAgentRuntimeSource( } } -function formatTime(timestamp: number, locale: string): string { - return new Intl.DateTimeFormat(locale, { - hour: '2-digit', - minute: '2-digit' - }).format(timestamp) -} - function formatAttachmentSize(size: number): string { return `${Math.max(1, Math.ceil(size / 1024))} KB` } @@ -1769,6 +1589,30 @@ function App(): React.JSX.Element { } | undefined>(undefined) const finalRevealedMessageIdRef = useRef(undefined) const messageArticleRefs = useRef(new Map()) + const handleMessageArticleRef = useCallback( + (messageId: string, element: HTMLElement | null): void => { + if (element) { + messageArticleRefs.current.set(messageId, element) + } else { + messageArticleRefs.current.delete(messageId) + } + }, + [] + ) + const retryMessage = useCallback((content: string): void => { + setInput(content) + inputRef.current?.focus() + }, []) + useEffect( + () => + scheduleIdleRoutePreload( + idleRouteModuleLoaders, + () => + activeRuns.current.size === 0 && + preparingConversations.current.size === 0 + ), + [] + ) const [visibleMessageWindow, setVisibleMessageWindow] = useState(() => ({ conversationId: activeId, count: messageRenderBatchSize @@ -2683,29 +2527,43 @@ function App(): React.JSX.Element { return } - setAssistantTasks((current) => - current.map((task) => - task.id === event.requestId - ? { - ...task, - status: - event.type === 'approval' || event.type === 'question' - ? 'waiting_approval' - : event.type === 'done' - ? 'completed' - : event.type === 'error' - ? event.status - : 'running', - completedAt: - event.type === 'done' || event.type === 'error' - ? new Date().toISOString() - : task.completedAt, - error: - event.type === 'error' ? event.message : task.error - } - : task - ) - ) + setAssistantTasks((current) => { + let changed = false + const updated = current.map((task) => { + if (task.id !== event.requestId) { + return task + } + const status: AssistantTask['status'] = + event.type === 'approval' || event.type === 'question' + ? 'waiting_approval' + : event.type === 'done' + ? 'completed' + : event.type === 'error' + ? event.status + : 'running' + const completedAt = + event.type === 'done' || event.type === 'error' + ? new Date().toISOString() + : task.completedAt + const error = + event.type === 'error' ? event.message : task.error + if ( + task.status === status && + task.completedAt === completedAt && + task.error === error + ) { + return task + } + changed = true + return { + ...task, + status, + completedAt, + error + } + }) + return changed ? updated : current + }) if (event.type === 'done') { if ( run.projectId && @@ -3476,8 +3334,11 @@ function App(): React.JSX.Element { }, []) useEffect(() => { + if (!activeProjectId) { + return + } void window.goodbuddy.memory - .list(activeProjectId || undefined) + .list(activeProjectId) .then(setAssistantMemories) .catch(() => notify({ @@ -3560,8 +3421,11 @@ function App(): React.JSX.Element { }, []) useEffect(() => { + if (!activeProjectId) { + return + } void window.goodbuddy.schedules - .list(activeProjectId || undefined) + .list(activeProjectId) .then(setAssistantSchedules) .catch(() => notify({ @@ -3611,6 +3475,9 @@ function App(): React.JSX.Element { }, [loadHeartbeats]) useEffect(() => { + if (!activeProjectId) { + return + } const requestId = ++heartbeatLoadRequestRef.current const timeout = setTimeout(() => { if (requestId !== heartbeatLoadRequestRef.current) { @@ -3654,7 +3521,7 @@ function App(): React.JSX.Element { heartbeatLoadRequestRef.current += 1 } } - }, [loadHeartbeats]) + }, [activeProjectId, loadHeartbeats]) const refreshHeartbeatCenter = useCallback(async (): Promise => { const projectId = activeProjectId @@ -4424,17 +4291,20 @@ function App(): React.JSX.Element { notify({ tone: 'success', message: t('notices.conversationExported') }) } - const openImageViewer = ( + const openImageViewer = useCallback(( item: ImageViewerItem, trigger: HTMLElement ): void => { if (!imageDataUrlPattern.test(item.src)) { - notify({ tone: 'error', message: t('notices.imageUnavailable') }) + notify({ + tone: 'error', + message: tRef.current('notices.imageUnavailable') + }) return } imageViewerTriggerRef.current = trigger setImageViewerItem(item) - } + }, []) const closeImageViewer = (): void => { setImageViewerItem(undefined) @@ -4444,7 +4314,7 @@ function App(): React.JSX.Element { }) } - const openCitationContext = async ( + const openCitationContext = useCallback(async ( reference: KnowledgeSearchReference ): Promise => { setCitationDialog({ @@ -4455,7 +4325,7 @@ function App(): React.JSX.Element { setCitationDialog({ reference, loading: false, - error: t('chat.citations.contextUnavailable') + error: tRef.current('chat.citations.contextUnavailable') }) return } @@ -4486,14 +4356,40 @@ function App(): React.JSX.Element { error: reason instanceof Error ? reason.message - : t('chat.citations.contextUnavailable') + : tRef.current('chat.citations.contextUnavailable') }) } - } + }, []) - const downloadImage = (item: ImageViewerItem): void => { + const openCitationSource = useCallback(async ( + reference: KnowledgeSearchReference + ): Promise => { + if (!reference.chunkId) { + return + } + try { + await window.goodbuddy.knowledge.openReferenceSource({ + knowledgeBaseId: reference.libraryId, + documentId: reference.documentId, + chunkId: reference.chunkId + }) + } catch (reason) { + notify({ + tone: 'error', + message: + reason instanceof Error + ? reason.message + : tRef.current('chat.citations.openFailed') + }) + } + }, []) + + const downloadImage = useCallback((item: ImageViewerItem): void => { if (!imageDataUrlPattern.test(item.src)) { - notify({ tone: 'error', message: t('notices.imageUnavailable') }) + notify({ + tone: 'error', + message: tRef.current('notices.imageUnavailable') + }) return } const anchor = document.createElement('a') @@ -4501,12 +4397,15 @@ function App(): React.JSX.Element { anchor.download = getImageDownloadName( item.title, item.src, - t('chat.images.fallbackTitle') + tRef.current('chat.images.fallbackTitle') ) anchor.rel = 'noopener' anchor.click() - notify({ tone: 'info', message: t('notices.imageDownloadStarted') }) - } + notify({ + tone: 'info', + message: tRef.current('notices.imageDownloadStarted') + }) + }, []) const submit = async (): Promise => { const prompt = input.trim() @@ -4735,7 +4634,7 @@ function App(): React.JSX.Element { } } - const respondToApproval = async ( + const respondToApproval = useCallback(async ( conversationId: string, messageId: string, approvalId: string, @@ -4745,10 +4644,10 @@ function App(): React.JSX.Element { await window.goodbuddy.agent.respondApproval(approvalId, decision) const approved = decision !== 'deny' const decisionLabel = { - deny: t('chat.approval.decisionDeny'), - once: t('chat.approval.decisionOnce'), - session: t('chat.approval.decisionSession'), - permanent: t('chat.approval.decisionPermanent') + deny: tRef.current('chat.approval.decisionDeny'), + once: tRef.current('chat.approval.decisionOnce'), + session: tRef.current('chat.approval.decisionSession'), + permanent: tRef.current('chat.approval.decisionPermanent') }[decision] setActivityRecords((current) => { let updated = false @@ -4763,9 +4662,10 @@ function App(): React.JSX.Element { return { ...record, status: approved ? ('completed' as const) : ('denied' as const), - detail: `${record.detail}\n${t('notices.userDecision', { - decision: decisionLabel - })}` + detail: `${record.detail}\n${tRef.current( + 'notices.userDecision', + { decision: decisionLabel } + )}` } } return record @@ -4775,18 +4675,20 @@ function App(): React.JSX.Element { ...message, approval: undefined, status: approved - ? t('chat.approval.executing', { decision: decisionLabel }) - : t('chat.approval.denied') + ? tRef.current('chat.approval.executing', { + decision: decisionLabel + }) + : tRef.current('chat.approval.denied') })) } catch { updateMessage(conversationId, messageId, (message) => ({ ...message, - status: t('chat.approval.responseFailed') + status: tRef.current('chat.approval.responseFailed') })) } - } + }, [updateMessage]) - const respondToQuestion = async ( + const respondToQuestion = useCallback(async ( conversationId: string, messageId: string, questionId: string, @@ -4797,10 +4699,10 @@ function App(): React.JSX.Element { ...message, question: undefined, status: answers - ? t('chat.status.answerSubmitted') - : t('chat.status.questionSkipped') + ? tRef.current('chat.status.answerSubmitted') + : tRef.current('chat.status.questionSkipped') })) - } + }, [updateMessage]) const addContext = async ( action: () => Promise @@ -5818,584 +5720,35 @@ function App(): React.JSX.Element { )} -
- {hiddenMessageCount > 0 && ( - - )} - {activeConversation && - visibleMessages.map((message, visibleMessageIndex) => { - const messageIndex = - visibleMessageStartIndex + visibleMessageIndex - return ( -
{ - if (element) { - messageArticleRefs.current.set(message.id, element) - } else { - messageArticleRefs.current.delete(message.id) - } - }} - tabIndex={-1} - > -
- {message.role === 'assistant' ? ( - - ) : ( - - )} -
-
-
- - {message.role === 'assistant' - ? 'GoodBuddy' - : t('chat.user')} - - {formatTime(message.createdAt, locale)} -
- {message.attachments && - message.attachments.length > 0 && ( -
- {message.attachments.map((attachment) => { - const imageSource = - attachment.kind === 'image' - ? attachment.contentUrl ?? - attachment.thumbnailUrl - : undefined - const imageItem = imageSource - ? { - src: imageSource, - title: attachment.name - } - : undefined - return ( -
- {imageItem ? ( - - ) : ( - - )} - - {attachment.name} - - {formatAttachmentSize(attachment.size)} - - {imageItem && ( - - - - - )} - -
- ) - })} -
- )} - {message.blocks && message.blocks.length > 0 ? ( -
- {groupMessageBlocks(message.blocks).map((item) => - item.kind === 'tools' ? ( - - ) : item.block.type === 'reasoning' ? ( - - ) : ( -
- - {item.block.content} - -
- ) - )} -
- ) : ( - <> - {message.reasoning && ( - - )} - {message.content && ( -
- - {messageIndex === 0 && - activeConversation && - isUnusedConversation(activeConversation) - ? t('conversation.greeting') - : message.content} - -
- )} - - )} - {message.artifactIds?.map((artifactId) => { - const candidate = - assistantArtifactById.get(artifactId) - const artifact = - candidate?.kind === 'image' && - candidate.content && - /^data:image\/(?:png|jpeg|webp);base64,/u.test( - candidate.content - ) - ? candidate - : undefined - return artifact?.content ? ( -
- -
{artifact.title}
-
- - -
-
- ) : null - })} - {message.knowledgeRetrieval && ( -
-
- )} - {message.sources && message.sources.length > 0 && ( -
- - - {t('chat.sources', { - sources: [...new Set(message.sources)].join( - locale === 'zh-CN' ? '、' : ', ' - ) - })} - -
- )} - {message.sourceReferences && - message.sourceReferences.length > 0 && ( -
- - {t('chat.citations.view', { - count: message.sourceReferences.length - })} - -
    - {message.sourceReferences.map( - (reference, referenceIndex) => ( -
  1. - - [{referenceIndex + 1}]{' '} - {reference.documentName} - - {reference.locator && ( - {reference.locator} - )} -

    {reference.snippet}

    - {reference.retrievalChannels && ( - - {t('chat.citations.retrieval')} - {reference.retrievalChannels - .map((channel) => - channel === 'fts' - ? t('chat.citations.fullText') - : channel === 'cjk' - ? t('chat.citations.cjk') - : channel === 'vector' - ? t('chat.citations.vector') - : t('chat.citations.graph') - ) - .join(' + ')} - - )} - {reference.score !== undefined && ( - - {t('chat.citations.score', { - score: reference.score.toFixed(4) - })} - - )} -
    - - -
    -
  2. - ) - )} -
-
- )} - {(!message.blocks || message.blocks.length === 0) && - message.tools && - message.tools.length > 0 && ( - - )} - {message.subagents && message.subagents.length > 0 && ( -
- {message.subagents.slice(0, 3).map((subagent) => ( -
-
- ))} -
- )} - {message.approval && ( -
- -
- {message.approval.title} -

{message.approval.description}

- {message.approval.argumentSummary && ( - {message.approval.argumentSummary} - )} -
- - - - {message.approval.allowPermanent && ( - - )} -
- )} - {message.question && ( - - respondToQuestion( - activeConversation.id, - message.id, - message.question!.questionId - ) - } - onSubmit={(answers) => - respondToQuestion( - activeConversation.id, - message.id, - message.question!.questionId, - answers - ) - } - value={message.question} - /> - )} - {message.status && ( -
-
- )} - {message.state === 'error' && - messageIndex === - activeConversation.messages.length - 1 && ( - - )} -
-
- ) - })} -
- + + {showScrollToBottom && ( + ) : ( + + )} + + {attachment.name} + {formatAttachmentSize(attachment.size)} + {imageItem && ( + + + + + )} + + + ) + })} + + )} + {message.blocks && message.blocks.length > 0 ? ( +
+ {groupMessageBlocks(message.blocks).map((item) => + item.kind === 'tools' ? ( + + ) : item.block.type === 'reasoning' ? ( + + ) : ( +
+ {item.block.content} +
+ ) + )} +
+ ) : ( + <> + {message.reasoning && ( + + )} + {message.content && ( +
+ + {greeting ? t('conversation.greeting') : message.content} + +
+ )} + + )} + {message.artifactIds?.map((artifactId) => { + const candidate = artifactById.get(artifactId) + const artifact = + candidate?.kind === 'image' && + candidate.content && + /^data:image\/(?:png|jpeg|webp);base64,/u.test(candidate.content) + ? candidate + : undefined + return artifact?.content ? ( +
+ +
{artifact.title}
+
+ + +
+
+ ) : null + })} + {message.knowledgeRetrieval && ( +
+
+ )} + {message.sources && message.sources.length > 0 && ( +
+ + + {t('chat.sources', { + sources: [...new Set(message.sources)].join( + locale === 'zh-CN' ? '、' : ', ' + ) + })} + +
+ )} + {message.sourceReferences && + message.sourceReferences.length > 0 && ( +
+ + {t('chat.citations.view', { + count: message.sourceReferences.length + })} + +
    + {message.sourceReferences.map( + (reference, referenceIndex) => ( +
  1. + + [{referenceIndex + 1}] {reference.documentName} + + {reference.locator && ( + {reference.locator} + )} +

    {reference.snippet}

    + {reference.retrievalChannels && ( + + {t('chat.citations.retrieval')} + {reference.retrievalChannels + .map((channel) => + channel === 'fts' + ? t('chat.citations.fullText') + : channel === 'cjk' + ? t('chat.citations.cjk') + : channel === 'vector' + ? t('chat.citations.vector') + : t('chat.citations.graph') + ) + .join(' + ')} + + )} + {reference.score !== undefined && ( + + {t('chat.citations.score', { + score: reference.score.toFixed(4) + })} + + )} +
    + + +
    +
  2. + ) + )} +
+
+ )} + {(!message.blocks || message.blocks.length === 0) && + message.tools && + message.tools.length > 0 && ( + + )} + {message.subagents && message.subagents.length > 0 && ( +
+ {message.subagents.slice(0, 3).map((subagent) => ( +
+
+ ))} +
+ )} + {message.approval && ( +
+ +
+ {message.approval.title} +

{message.approval.description}

+ {message.approval.argumentSummary && ( + {message.approval.argumentSummary} + )} +
+ + + + {message.approval.allowPermanent && ( + + )} +
+ )} + {message.question && ( + + onRespondQuestion( + conversationId, + message.id, + message.question!.questionId + ) + } + onSubmit={(answers) => + onRespondQuestion( + conversationId, + message.id, + message.question!.questionId, + answers + ) + } + value={message.question} + /> + )} + {message.status && ( +
+
+ )} + {canRetry && ( + + )} + + + ) +} + +export const ChatMessageRow = memo(ChatMessageRowView) + +type ChatTimelineProps = { + artifactById: ReadonlyMap + conversationId: string + hiddenMessageCount: number + isUnusedConversation: boolean + locale: TimeFormatLocale + messageStartIndex: number + messages: Message[] + onArticleRef: (messageId: string, element: HTMLElement | null) => void + onDownloadImage: (item: ImageViewerItem) => void + onOpenCitationContext: ( + reference: KnowledgeSearchReference + ) => Promise + onOpenCitationSource: ( + reference: KnowledgeSearchReference + ) => Promise + onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void + onRespondApproval: ( + conversationId: string, + messageId: string, + approvalId: string, + decision: ApprovalDecision + ) => Promise + onRespondQuestion: ( + conversationId: string, + messageId: string, + questionId: string, + answers?: AgentQuestionAnswer[] + ) => Promise + onRetry: (content: string) => void + onRevealEarlier: () => void + retryContent?: string + totalMessageCount: number +} + +export function ChatTimeline({ + artifactById, + conversationId, + hiddenMessageCount, + isUnusedConversation, + locale, + messageStartIndex, + messages, + onArticleRef, + onDownloadImage, + onOpenCitationContext, + onOpenCitationSource, + onOpenImage, + onRespondApproval, + onRespondQuestion, + onRetry, + onRevealEarlier, + retryContent, + totalMessageCount +}: ChatTimelineProps): React.JSX.Element { + const { t } = useTranslation('app') + + return ( +
+ {hiddenMessageCount > 0 && ( + + )} + {messages.map((message, visibleMessageIndex) => { + const messageIndex = messageStartIndex + visibleMessageIndex + return ( + + ) + })} +
+ ) +} diff --git a/src/renderer/src/idle-route-preload.test.ts b/src/renderer/src/idle-route-preload.test.ts new file mode 100644 index 0000000..4dde240 --- /dev/null +++ b/src/renderer/src/idle-route-preload.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + preloadRouteModules, + scheduleIdleRoutePreload, + type RouteModuleLoader +} from './idle-route-preload' + +function idleDeadline(): IdleDeadline { + return { + didTimeout: false, + timeRemaining: () => 10 + } +} + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('idle route preload scheduling', () => { + it('waits for an idle callback and supplies the timeout option', async () => { + let idleCallback: IdleRequestCallback | undefined + const requestIdleCallback = vi.fn( + (callback: IdleRequestCallback): number => { + idleCallback = callback + return 17 + } + ) + const cancelIdleCallback = vi.fn() + const loader = vi.fn(() => Promise.resolve()) + vi.stubGlobal('requestIdleCallback', requestIdleCallback) + vi.stubGlobal('cancelIdleCallback', cancelIdleCallback) + + const cleanup = scheduleIdleRoutePreload([loader]) + + expect(loader).not.toHaveBeenCalled() + expect(requestIdleCallback).toHaveBeenCalledWith(expect.any(Function), { + timeout: 2000 + }) + + idleCallback?.(idleDeadline()) + await Promise.resolve() + + expect(loader).toHaveBeenCalledOnce() + cleanup() + expect(cancelIdleCallback).not.toHaveBeenCalled() + }) + + it('cancels pending idle work and ignores a stale callback', async () => { + let idleCallback: IdleRequestCallback | undefined + const requestIdleCallback = vi.fn( + (callback: IdleRequestCallback): number => { + idleCallback = callback + return 29 + } + ) + const cancelIdleCallback = vi.fn() + const loader = vi.fn() + vi.stubGlobal('requestIdleCallback', requestIdleCallback) + vi.stubGlobal('cancelIdleCallback', cancelIdleCallback) + + const cleanup = scheduleIdleRoutePreload([loader]) + cleanup() + idleCallback?.(idleDeadline()) + await Promise.resolve() + + expect(cancelIdleCallback).toHaveBeenCalledWith(29) + expect(loader).not.toHaveBeenCalled() + }) + + it('reschedules preloading while latency-sensitive work is active', async () => { + vi.useFakeTimers() + const idleCallbacks: IdleRequestCallback[] = [] + const requestIdleCallback = vi.fn( + (callback: IdleRequestCallback): number => { + idleCallbacks.push(callback) + return idleCallbacks.length + } + ) + const loader = vi.fn() + let latencySensitiveWorkActive = true + vi.stubGlobal('requestIdleCallback', requestIdleCallback) + vi.stubGlobal('cancelIdleCallback', vi.fn()) + + scheduleIdleRoutePreload( + [loader], + () => !latencySensitiveWorkActive + ) + idleCallbacks[0]?.(idleDeadline()) + await Promise.resolve() + + expect(loader).not.toHaveBeenCalled() + expect(requestIdleCallback).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(100) + + expect(requestIdleCallback).toHaveBeenCalledTimes(2) + + latencySensitiveWorkActive = false + idleCallbacks[1]?.(idleDeadline()) + await Promise.resolve() + + expect(loader).toHaveBeenCalledOnce() + }) + + it('uses a zero-delay timer fallback and cancels pending timer work', async () => { + vi.useFakeTimers() + vi.stubGlobal('requestIdleCallback', undefined) + vi.stubGlobal('cancelIdleCallback', undefined) + const startedLoader = vi.fn() + const cancelledLoader = vi.fn() + + scheduleIdleRoutePreload([startedLoader]) + const cleanup = scheduleIdleRoutePreload([cancelledLoader]) + cleanup() + + expect(startedLoader).not.toHaveBeenCalled() + await vi.runAllTimersAsync() + + expect(startedLoader).toHaveBeenCalledOnce() + expect(cancelledLoader).not.toHaveBeenCalled() + }) +}) + +describe('route module preloading', () => { + it('settles every resolved, rejected, and synchronously thrown loader', async () => { + const error = new Error('load failed') + const loaders: RouteModuleLoader[] = [ + vi.fn(() => 'loaded'), + vi.fn(() => Promise.reject(error)), + vi.fn(() => { + throw error + }), + vi.fn(() => Promise.resolve('also loaded')) + ] + + const preload = preloadRouteModules(loaders) + + expect(loaders.every((loader) => vi.mocked(loader).mock.calls.length === 0)) + .toBe(true) + await expect(preload).resolves.toEqual([ + { status: 'fulfilled', value: 'loaded' }, + { status: 'rejected', reason: error }, + { status: 'rejected', reason: error }, + { status: 'fulfilled', value: 'also loaded' } + ]) + expect(loaders.every((loader) => vi.mocked(loader).mock.calls.length === 1)) + .toBe(true) + }) +}) diff --git a/src/renderer/src/idle-route-preload.ts b/src/renderer/src/idle-route-preload.ts new file mode 100644 index 0000000..948b834 --- /dev/null +++ b/src/renderer/src/idle-route-preload.ts @@ -0,0 +1,69 @@ +export type RouteModuleLoader = () => unknown | PromiseLike + +export function preloadRouteModules( + loaders: readonly RouteModuleLoader[] +): Promise[]> { + return Promise.allSettled( + loaders.map((loader) => Promise.resolve().then(loader)) + ) +} + +export function scheduleIdleRoutePreload( + loaders: readonly RouteModuleLoader[], + canStart: () => boolean = () => true +): () => void { + let active = true + let started = false + let cancelPending: (() => void) | undefined + + const start = (): void => { + if (!active || started) { + return + } + cancelPending = undefined + if (!canStart()) { + schedule(true) + return + } + + started = true + void preloadRouteModules(loaders) + } + + const schedule = (retry = false): void => { + if (!active || started) { + return + } + if (retry) { + const timeoutId = window.setTimeout(() => { + cancelPending = undefined + schedule() + }, 100) + cancelPending = () => window.clearTimeout(timeoutId) + return + } + + if (typeof window.requestIdleCallback === 'function') { + const idleCallbackId = window.requestIdleCallback(start, { + timeout: 2000 + }) + cancelPending = () => window.cancelIdleCallback(idleCallbackId) + return + } + + const timeoutId = window.setTimeout(start, 0) + cancelPending = () => window.clearTimeout(timeoutId) + } + + schedule() + + return () => { + if (started || !active) { + return + } + + active = false + cancelPending?.() + cancelPending = undefined + } +} diff --git a/src/renderer/src/time-format.test.ts b/src/renderer/src/time-format.test.ts new file mode 100644 index 0000000..e2fb205 --- /dev/null +++ b/src/renderer/src/time-format.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { formatTime } from './time-format' + +describe('formatTime', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('reuses one formatter per supported locale', () => { + const NativeDateTimeFormat = Intl.DateTimeFormat + const formatter = vi + .spyOn(Intl, 'DateTimeFormat') + .mockImplementation( + function DateTimeFormat(locales, options) { + return new NativeDateTimeFormat(locales, options) + } + ) + + const firstEnglish = formatTime(1_775_000_000_000, 'en-US') + const secondEnglish = formatTime(1_775_000_060_000, 'en-US') + const firstChinese = formatTime(1_775_000_000_000, 'zh-CN') + const secondChinese = formatTime(1_775_000_060_000, 'zh-CN') + + expect(firstEnglish).not.toBe('') + expect(secondEnglish).not.toBe('') + expect(firstChinese).not.toBe('') + expect(secondChinese).not.toBe('') + expect(formatter).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/time-format.ts b/src/renderer/src/time-format.ts new file mode 100644 index 0000000..ccef369 --- /dev/null +++ b/src/renderer/src/time-format.ts @@ -0,0 +1,18 @@ +export type TimeFormatLocale = 'en-US' | 'zh-CN' + +const timeFormatters: Partial< + Record +> = {} + +export function formatTime( + timestamp: number, + locale: TimeFormatLocale +): string { + const formatter = + timeFormatters[locale] ?? + (timeFormatters[locale] = new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit' + })) + return formatter.format(timestamp) +}