From 82b354425d47e1d3d225ba337c92c4006c2bdb94 Mon Sep 17 00:00:00 2001 From: colafornia Date: Tue, 4 Aug 2026 16:30:07 +0800 Subject: [PATCH 1/2] refactor(desktop): enforce a metadata-free renderer startup boundary Keep full model metadata behind the main-process and lazy-settings boundaries, and project only first-screen data through onboarding snapshots. Refs #2063 Relates to #2084 --- .../__tests__/model-catalog-choices.test.ts | 165 +++++++++--------- .../main/__tests__/onboarding-service.test.ts | 39 +++++ .../provider-firstscreen-contract.test.ts | 11 ++ .../__tests__/session-health-notice.test.ts | 142 ++++----------- .../__tests__/use-onboarding-snapshot.test.ts | 4 + apps/desktop/src/main/onboarding-service.ts | 43 ++++- apps/desktop/src/preload/bridge-contract.d.ts | 2 + apps/desktop/src/renderer/OnboardingHero.tsx | 4 +- apps/desktop/src/renderer/app-shell.tsx | 12 +- .../src/renderer/chat-model-selection.ts | 38 ---- apps/desktop/src/renderer/chat-workbar.tsx | 4 +- .../src/renderer/model-catalog-choices.ts | 93 +--------- .../src/renderer/onboarding-provider-types.ts | 8 + .../src/renderer/session-health-notice.ts | 32 +--- .../settings/general-settings-page.tsx | 4 +- .../renderer/settings/provider-add-form.tsx | 8 +- .../settings/provider-catalog-page.tsx | 4 +- .../settings/provider-connection-detail.tsx | 2 +- .../settings/provider-display-copy.ts | 7 +- .../renderer/settings/provider-display.tsx | 24 +-- .../settings/subagent-preset-presentation.ts | 2 +- .../settings/subagent-settings-page.tsx | 4 +- .../settings/use-connection-detail.ts | 23 +-- .../voice-recognition-connection-form.tsx | 3 +- .../renderer/shell-chat-model-selection.ts | 46 +++++ .../src/renderer/use-onboarding-snapshot.ts | 3 +- .../src/renderer/use-shell-chat-model.ts | 112 +++--------- apps/desktop/stories/app-shell.stories.tsx | 6 + ...model-metadata-firstscreen-optimization.md | 143 +++++++++++++++ packages/core/package.json | 5 + .../src/__tests__/chat-model-choice.test.ts | 52 ++++++ packages/core/src/chat-model-choice.ts | 74 ++++++++ .../core/src/codex-model-compatibility.ts | 1 + packages/core/src/index.ts | 2 + packages/core/src/llm-connections.ts | 4 +- packages/core/src/onboarding-milestone.ts | 13 ++ packages/core/src/onboarding.ts | 9 +- .../src/__tests__/session-store.test.ts | 46 +++++ packages/storage/src/session-store.ts | 10 +- .../src/__tests__/chat-model-helpers.test.ts | 16 +- .../ui/src/__tests__/model-picker.test.ts | 9 + packages/ui/src/chat-model-helpers.ts | 50 +----- packages/ui/stories/attachment.stories.tsx | 2 +- packages/ui/stories/model-picker.stories.tsx | 29 +-- 44 files changed, 761 insertions(+), 549 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/provider-firstscreen-contract.test.ts delete mode 100644 apps/desktop/src/renderer/chat-model-selection.ts create mode 100644 apps/desktop/src/renderer/onboarding-provider-types.ts create mode 100644 apps/desktop/src/renderer/shell-chat-model-selection.ts create mode 100644 docs/model-metadata-firstscreen-optimization.md create mode 100644 packages/core/src/__tests__/chat-model-choice.test.ts create mode 100644 packages/core/src/chat-model-choice.ts create mode 100644 packages/core/src/codex-model-compatibility.ts create mode 100644 packages/core/src/onboarding-milestone.ts diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index 4bbc83c619..1bf30d4927 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -5,48 +5,22 @@ import { dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { describe, it } from 'node:test'; import { build } from 'esbuild'; -import type { LlmConnection } from '@maka/core'; +import type { ChatModelChoice, LlmConnection, SessionSummary } from '@maka/core'; +import { buildChatModelChoices } from '@maka/core/chat-model-choice'; +import { buildConnectionModelCatalogEntries } from '@maka/core/model-catalog'; +import { + normalizeActiveChatModel, + pickNewChatModel, +} from '../../renderer/shell-chat-model-selection.js'; const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); type ModelCatalogChoicesModule = { buildCatalogRecommendedDefaultModel(providerType: LlmConnection['providerType']): string; - buildCatalogChatModelChoices(connections: readonly LlmConnection[]): Array<{ - connectionSlug: string; - providerType: string; - model: string; - label: string; - connectionName?: string; - }>; - pickCatalogDefaultChatModel(connection: LlmConnection): - | { llmConnectionSlug: string; model: string } - | undefined; - pickNewChatModel(input: { - pending: { llmConnectionSlug: string; model: string } | null; - activationCandidate?: { llmConnectionSlug: string; model: string }; - catalogDefault: { llmConnectionSlug: string; model: string } | undefined; - choices: Array<{ - connectionSlug: string; - providerType: LlmConnection['providerType']; - model: string; - label: string; - }>; - }): { llmConnectionSlug: string; model: string } | undefined; buildCatalogDailyReviewModelOptions( connections: readonly LlmConnection[], currentModelKey: string, ): Array; - buildCatalogModelChoices(connection: LlmConnection): Array<{ - id: string; - displayName?: string; - source: string; - recommendedRank?: number; - lifecycle: string; - docsUrl?: string; - availability: string; - unavailableReason: string; - isDefault: boolean; - }>; }; let modulePromise: Promise | undefined; @@ -92,8 +66,57 @@ function choiceIdentity(choice: { } describe('model catalog picker helpers', () => { - it('uses the first offered model when no user or workspace preference exists', async () => { - const { pickNewChatModel } = await importModelCatalogChoices(); + it('keeps the first offered Codex model when replacing an unsupported stored model', () => { + const choices: ChatModelChoice[] = [ + { + connectionSlug: 'codex-account', + providerType: 'openai-codex', + providerLabel: 'OpenAI OAuth', + model: 'first-offered', + label: 'First offered', + isDefault: false, + thinkingLevels: [], + }, + { + connectionSlug: 'codex-account', + providerType: 'openai-codex', + providerLabel: 'OpenAI OAuth', + model: 'later-default', + label: 'Later default', + isDefault: true, + thinkingLevels: [], + }, + ]; + const session: SessionSummary = { + id: 'session-1', + name: 'Legacy Codex session', + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'codex-account', + connectionLocked: true, + model: 'gpt-5-codex', + permissionMode: 'ask', + }; + + assert.equal( + normalizeActiveChatModel( + session, + connection({ + slug: 'codex-account', + providerType: 'openai-codex', + defaultModel: 'gpt-5-codex', + }), + choices, + ), + 'first-offered', + ); + }); + + it('uses the first offered model when no user or workspace preference exists', () => { assert.deepEqual( pickNewChatModel({ pending: null, @@ -102,8 +125,11 @@ describe('model catalog picker helpers', () => { { connectionSlug: 'opencode-free', providerType: 'opencode-free', + providerLabel: 'OpenCode Zen', model: 'mimo-v2.5-free', label: 'MiMo V2.5 Free', + isDefault: true, + thinkingLevels: [], }, ], }), @@ -111,8 +137,7 @@ describe('model catalog picker helpers', () => { ); }); - it('uses the readiness-checked activation candidate before an unverified first choice', async () => { - const { pickNewChatModel } = await importModelCatalogChoices(); + it('uses the readiness-checked activation candidate before an unverified first choice', () => { assert.deepEqual( pickNewChatModel({ pending: null, @@ -125,14 +150,20 @@ describe('model catalog picker helpers', () => { { connectionSlug: 'missing-key-first', providerType: 'anthropic', + providerLabel: 'Anthropic', model: 'unusable-model', label: 'Unusable', + isDefault: true, + thinkingLevels: [], }, { connectionSlug: 'ready-second', providerType: 'opencode-free', + providerLabel: 'OpenCode Zen', model: 'ready-model', label: 'Ready', + isDefault: true, + thinkingLevels: [], }, ], }), @@ -140,9 +171,8 @@ describe('model catalog picker helpers', () => { ); }); - it('offers the normalized fallback for legacy Codex-only inventory', async () => { - const { buildCatalogChatModelChoices } = await importModelCatalogChoices(); - const choices = buildCatalogChatModelChoices([ + it('offers the normalized fallback for legacy Codex-only inventory', () => { + const choices = buildChatModelChoices([ connection({ slug: 'codex-account', providerType: 'openai-codex', @@ -157,9 +187,7 @@ describe('model catalog picker helpers', () => { ]); }); - it('projects enabled models across wired providers without collapsing provider identities', async () => { - const { buildCatalogChatModelChoices, buildCatalogModelChoices } = - await importModelCatalogChoices(); + it('projects enabled models across wired providers without collapsing provider identities', () => { const openrouter = connection({ slug: 'openrouter-main', providerType: 'openrouter', @@ -173,15 +201,15 @@ describe('model catalog picker helpers', () => { modelSource: 'fetched', }); assert.deepEqual( - buildCatalogChatModelChoices([openrouter]).map((choice) => choice.model), + buildChatModelChoices([openrouter]).map((choice) => choice.model), ['openrouter/auto', 'anthropic/claude-sonnet-4.6'], ); assert.deepEqual( - buildCatalogModelChoices(openrouter).map((choice) => choice.id), + buildConnectionModelCatalogEntries({ connection: openrouter }).map((choice) => choice.id), ['openrouter/auto', 'anthropic/claude-sonnet-4.6', 'openai/gpt-5.5'], ); - const choices = buildCatalogChatModelChoices([ + const choices = buildChatModelChoices([ connection({ slug: 'ollama-cloud', providerType: 'ollama-cloud', @@ -231,9 +259,8 @@ describe('model catalog picker helpers', () => { ]); }); - it('keeps API connection labels while redacting OAuth account identities', async () => { - const { buildCatalogChatModelChoices } = await importModelCatalogChoices(); - const choices = buildCatalogChatModelChoices([ + it('keeps API connection labels while redacting OAuth account identities', () => { + const choices = buildChatModelChoices([ connection({ slug: 'openrouter', name: 'Openrouter', @@ -351,12 +378,7 @@ describe('model catalog picker helpers', () => { }); it('derives canonical defaults, exact provider ids, and missing-default state', async () => { - const { - buildCatalogChatModelChoices, - buildCatalogModelChoices, - buildCatalogRecommendedDefaultModel, - pickCatalogDefaultChatModel, - } = await importModelCatalogChoices(); + const { buildCatalogRecommendedDefaultModel } = await importModelCatalogChoices(); assert.deepEqual( [ 'deepseek', @@ -391,7 +413,7 @@ describe('model catalog picker helpers', () => { modelSource: 'fetched', }); assert.deepEqual( - buildCatalogModelChoices(zenmux).map(({ id, source, isDefault }) => ({ + buildConnectionModelCatalogEntries({ connection: zenmux }).map(({ id, source, isDefault }) => ({ id, source, isDefault, @@ -401,36 +423,19 @@ describe('model catalog picker helpers', () => { { id: 'moonshotai/kimi-k2.7-code', source: 'provider_api', isDefault: false }, ], ); - assert.deepEqual(buildCatalogChatModelChoices([zenmux]).map(choiceIdentity), [ + assert.deepEqual(buildChatModelChoices([zenmux]).map(choiceIdentity), [ 'zenmux:zenmux:moonshotai/kimi-k2.5', 'zenmux:zenmux:moonshotai/kimi-k2.7-code', ]); - assert.deepEqual(pickCatalogDefaultChatModel(zenmux), { - llmConnectionSlug: 'zenmux', - model: 'moonshotai/kimi-k2.5', - }); - assert.deepEqual( - pickCatalogDefaultChatModel( - connection({ - slug: 'openai-api', - providerType: 'openai', - defaultModel: ' gpt-4o-mini ', - models: [{ id: 'gpt-4o-mini' }], - modelSource: 'fetched', - }), - ), - { llmConnectionSlug: 'openai-api', model: 'gpt-4o-mini' }, - ); - - const missingDefault = buildCatalogModelChoices( - connection({ + const missingDefault = buildConnectionModelCatalogEntries({ + connection: connection({ slug: 'openai-api', providerType: 'openai', defaultModel: 'gpt-5', models: [{ id: 'gpt-4o-mini' }], modelSource: 'fetched', }), - ); + }); assert.deepEqual( missingDefault.map(({ id, availability, unavailableReason, isDefault }) => ({ id, @@ -454,14 +459,14 @@ describe('model catalog picker helpers', () => { ], ); assert.equal( - buildCatalogModelChoices( - connection({ + buildConnectionModelCatalogEntries({ + connection: connection({ slug: 'deepseek-api', providerType: 'deepseek', defaultModel: 'deepseek-v4-flash', modelSource: 'fallback', }), - ).filter((choice) => choice.source === 'static_catalog').length, + }).filter((choice) => choice.source === 'static_catalog').length, 4, ); }); diff --git a/apps/desktop/src/main/__tests__/onboarding-service.test.ts b/apps/desktop/src/main/__tests__/onboarding-service.test.ts index c384caf526..eeca1825a8 100644 --- a/apps/desktop/src/main/__tests__/onboarding-service.test.ts +++ b/apps/desktop/src/main/__tests__/onboarding-service.test.ts @@ -44,6 +44,24 @@ function realConnection(overrides: Partial = {}): LlmConnection { } as LlmConnection; } +function session(overrides: Partial = {}): SessionSummary { + return { + id: 's1', + name: 'Session', + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'a', + connectionLocked: true, + model: 'claude-sonnet-4-5-20250929', + permissionMode: 'ask', + ...overrides, + }; +} + function fakeDeps(overrides: Partial = {}): OnboardingServiceDeps { const milestones: OnboardingMilestone[] = []; return { @@ -86,6 +104,27 @@ describe('createOnboardingService.getSnapshot', () => { assert.deepEqual(snapshot.milestones, [ { id: 'first_chat_sent', completedAt: 1_700_000_000_000 }, ]); + assert.equal(snapshot.chatModelChoices[0]?.providerLabel, 'Anthropic'); + assert.equal(snapshot.chatModelChoices[0]?.isDefault, true); + }); + + it('projects session send readiness again after connection credentials change', async () => { + let hasCredential = true; + const service = createOnboardingService(fakeDeps({ + listConnections: async () => [realConnection({ slug: 'a' })], + getDefaultSlug: async () => 'a', + listSessions: async () => [session()], + getMilestones: async () => [{ id: 'initial_onboarding', completedAt: 1 }], + hasCredential: async () => hasCredential, + })); + + assert.deepEqual((await service.getSnapshot()).sessionSendOutcomes.s1, { kind: 'ready' }); + hasCredential = false; + assert.deepEqual((await service.getSnapshot()).sessionSendOutcomes.s1, { + kind: 'blocked', + reason: 'missing_api_key', + connectionLocked: true, + }); }); it('keeps physical revision summaries in the snapshot for version navigation', async () => { diff --git a/apps/desktop/src/main/__tests__/provider-firstscreen-contract.test.ts b/apps/desktop/src/main/__tests__/provider-firstscreen-contract.test.ts new file mode 100644 index 0000000000..de6ece4d41 --- /dev/null +++ b/apps/desktop/src/main/__tests__/provider-firstscreen-contract.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { ProviderType } from '@maka/core'; +import { providerDisplay } from '../../renderer/settings/provider-display-copy.js'; + +test('unknown providers use their persisted type and generic local copy', () => { + assert.deepEqual(providerDisplay('future-provider' as ProviderType, 'en'), { + name: 'future-provider', + description: 'This provider is not registered in the current build.', + }); +}); diff --git a/apps/desktop/src/main/__tests__/session-health-notice.test.ts b/apps/desktop/src/main/__tests__/session-health-notice.test.ts index 5b9226cb6a..bbb9d3b26a 100644 --- a/apps/desktop/src/main/__tests__/session-health-notice.test.ts +++ b/apps/desktop/src/main/__tests__/session-health-notice.test.ts @@ -1,13 +1,9 @@ /** * #1038 — session health notice derivation, aligned with send authority. * - * The notice sits above the composer and answers exactly one question: - * "will the next send fail for a recoverable connection/session reason, - * and where should the user go?" The answer comes from the same core - * projection (`projectSessionSendOutcome`) that the main-process send - * gate delegates to, fed with renderer-side facts (connection list, - * default slug, secret presence probe, `connectionLocked` on the - * session summary). Soft "will rebind on send" cases stay silent. + * Main projects send readiness and carries it in the onboarding snapshot. + * These tests cover only the renderer's outcome-to-copy mapping and reminder + * priority; send/rebind decision cases live in session-send-projection.test.ts. * * `lastTestStatus` is an intentional pre-send reminder (product contract * decided in #1038): it never claims send is blocked — E4 locks that it @@ -24,7 +20,7 @@ import { type SessionHealthNoticeInput, } from '../../renderer/session-health-notice.js'; -function connection(overrides: Partial = {}): LlmConnection { +function connection(): LlmConnection { return { slug: 'openai-live', name: 'OpenAI Live', @@ -35,7 +31,6 @@ function connection(overrides: Partial = {}): LlmConnection { modelSource: 'fetched', createdAt: 1, updatedAt: 1, - ...overrides, } as LlmConnection; } @@ -48,103 +43,56 @@ function input(partial: Partial = {}): SessionHealthNo model: 'gpt-4.1', connectionLocked: false, }, + outcome: { kind: 'ready' }, connections: [connection()], - defaultSlug: 'openai-live', - hasSecret: () => true, lastTestStatus: undefined, ...partial, }; } -function fakeSession(connectionLocked: boolean): SessionHealthNoticeInput['session'] { - return { backend: 'fake', llmConnectionSlug: 'fake', model: 'fake-model', connectionLocked }; -} - describe('deriveSessionHealthNotice', () => { it('returns undefined when no active session', () => { assert.equal(deriveSessionHealthNotice(input({ session: undefined })), undefined); }); + it('stays hidden when the snapshot outcome is unavailable', () => { + assert.equal(deriveSessionHealthNotice(input({ outcome: undefined })), undefined); + }); + it('returns undefined when the next send will succeed', () => { assert.equal(deriveSessionHealthNotice(input({})), undefined); }); - describe('locked sessions — the send cannot silently rebind', () => { - it('locked fake session warns even when a default connection is ready', () => { - // #1038 case 1: previously hidden behind "default looks ready". - const result = deriveSessionHealthNotice(input({ session: fakeSession(true) })); - assert.equal(result?.tone, 'destructive'); - assert.equal(result?.label, '会话已过期 · 请先配置真实模型'); - assert.equal(result?.onClickTarget, 'models'); - }); - - it('locked session with deleted connection warns even when a default is ready', () => { - const result = deriveSessionHealthNotice( + it('stays hidden when main projects a silent rebind', () => { + assert.equal( + deriveSessionHealthNotice( input({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: 'deleted-slug', - model: 'gpt-4.1', - connectionLocked: true, - }, + outcome: { kind: 'rebind', connectionSlug: 'openai-live', model: 'gpt-4.1' }, }), - ); - assert.equal(result?.tone, 'destructive'); - assert.equal(result?.label, '连接已删除'); - assert.equal(result?.onClickTarget, 'models'); - }); - - it('handles legacy backend (e.g. "claude") with missing connection — same notice', () => { - const result = deriveSessionHealthNotice( - input({ - session: { - backend: 'claude', - llmConnectionSlug: 'deleted-slug', - model: 'gpt-4.1', - connectionLocked: true, - }, - }), - ); - assert.equal(result?.label, '连接已删除'); - }); + ), + undefined, + ); }); - describe('unlocked sessions — silent when the send path can rebind', () => { - it('fake session stays silent when a default connection is ready', () => { - assert.equal(deriveSessionHealthNotice(input({ session: fakeSession(false) })), undefined); - }); - - it('missing connection stays silent when ANOTHER (non-default) connection is ready', () => { - // #1038 case 3: the send walk tries every persisted connection. - assert.equal( - deriveSessionHealthNotice( - input({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: 'deleted-slug', - model: 'gpt-4.1', - connectionLocked: false, - }, - defaultSlug: 'also-broken', - connections: [connection({ slug: 'second-ready' })], - }), - ), - undefined, - ); - }); - - it('fake session warns when the default is enabled but has no secret', () => { - // #1038 case 2: "exists && enabled" is not send readiness. - const result = deriveSessionHealthNotice( - input({ session: fakeSession(false), hasSecret: () => false }), - ); + describe('blocked outcomes', () => { + it('renders the fake-backend recovery copy without internal terminology', () => { + const result = deriveSessionHealthNotice(input({ + session: { + backend: 'fake', + llmConnectionSlug: 'fake', + model: 'fake-model', + connectionLocked: false, + }, + outcome: { kind: 'blocked', reason: 'fake_backend', connectionLocked: false }, + })); assert.equal(result?.tone, 'destructive'); assert.equal(result?.label, '会话已过期 · 请先配置真实模型'); + assert.equal(result?.onClickTarget, 'models'); assert.doesNotMatch(result?.label ?? '', /演示版|fake|FakeBackend/i); assert.doesNotMatch(result?.tooltip ?? '', /演示版|fake|FakeBackend/i); }); - it('missing connection with no ready rebind target → destructive models notice', () => { + it('renders the missing-connection recovery copy', () => { const result = deriveSessionHealthNotice( input({ session: { @@ -153,27 +101,22 @@ describe('deriveSessionHealthNotice', () => { model: 'gpt-4.1', connectionLocked: false, }, - hasSecret: () => false, + outcome: { kind: 'blocked', reason: 'connection_missing', connectionLocked: false }, }), ); assert.equal(result?.tone, 'destructive'); assert.equal(result?.label, '连接已删除'); - assert.equal(result?.onClickTarget, 'models'); assert.match(result?.tooltip ?? '', /设置.*模型/); }); - it('non-rebindable failure (missing key) blocks even when another connection is ready', () => { - // Mirrors the send path: missing_api_key never silently rebinds, - // so the notice must say the send will fail. + it('names the session connection when its API key is missing', () => { const result = deriveSessionHealthNotice( input({ - hasSecret: (slug) => slug !== 'openai-live', - connections: [connection(), connection({ slug: 'second-ready' })], + outcome: { kind: 'blocked', reason: 'missing_api_key', connectionLocked: false }, }), ); assert.equal(result?.tone, 'destructive'); assert.equal(result?.label, '连接缺少密钥'); - assert.equal(result?.onClickTarget, 'models'); assert.match(result?.tooltip ?? '', /OpenAI Live/); }); }); @@ -203,27 +146,18 @@ describe('deriveSessionHealthNotice', () => { it('a blocked send beats the reminder', () => { const result = deriveSessionHealthNotice( - input({ session: fakeSession(true), lastTestStatus: 'error' }), + input({ + outcome: { kind: 'blocked', reason: 'missing_api_key', connectionLocked: false }, + lastTestStatus: 'error', + }), ); - assert.equal(result?.label, '会话已过期 · 请先配置真实模型'); + assert.equal(result?.label, '连接缺少密钥'); }); it('the reminder stays silent when the send will rebind away from this connection', () => { - // The session's own connection is broken but rebindable; the next - // send moves the session to a healthy connection, so nudging the - // user about the abandoned connection's old test result is noise. const result = deriveSessionHealthNotice( input({ - session: { - backend: 'ai-sdk', - llmConnectionSlug: 'openai-live', - model: 'gpt-4.1', - connectionLocked: false, - }, - connections: [ - connection({ models: [], defaultModel: undefined }), - connection({ slug: 'second-ready' }), - ], + outcome: { kind: 'rebind', connectionSlug: 'second-ready', model: 'gpt-4.1' }, lastTestStatus: 'error', }), ); diff --git a/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts b/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts index c22db1da96..50337a9fae 100644 --- a/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts +++ b/apps/desktop/src/main/__tests__/use-onboarding-snapshot.test.ts @@ -31,6 +31,8 @@ const READY_SNAPSHOT: OnboardingSnapshot = { sessions: [], connections: [], defaultSlug: null, + chatModelChoices: [], + sessionSendOutcomes: {}, }; const NEEDS_CONNECTION_SNAPSHOT: OnboardingSnapshot = { @@ -39,6 +41,8 @@ const NEEDS_CONNECTION_SNAPSHOT: OnboardingSnapshot = { sessions: [], connections: [], defaultSlug: null, + chatModelChoices: [], + sessionSendOutcomes: {}, }; describe('getOnboardingActivationCandidate', () => { diff --git a/apps/desktop/src/main/onboarding-service.ts b/apps/desktop/src/main/onboarding-service.ts index 6401b7a461..59db3c1fc9 100644 --- a/apps/desktop/src/main/onboarding-service.ts +++ b/apps/desktop/src/main/onboarding-service.ts @@ -33,11 +33,15 @@ import { deriveOnboardingState, hasSettledInitialOnboarding, ONBOARDING_MILESTONE_IDS, + projectSessionSendOutcome, + type ChatModelChoice, type OnboardingMilestone, type OnboardingMilestoneId, type OnboardingState, type SessionSummary, + type SessionSendProjection, } from '@maka/core'; +import { buildChatModelChoices } from '@maka/core/chat-model-choice'; import type { LlmConnection } from '@maka/core/llm-connections'; export interface OnboardingSnapshot { @@ -51,6 +55,8 @@ export interface OnboardingSnapshot { /** Connection list — bundled to avoid a separate `connections:list` + `getDefault` IPC. */ connections: LlmConnection[]; defaultSlug: string | null; + chatModelChoices: ChatModelChoice[]; + sessionSendOutcomes: Record; } export interface OnboardingServiceDeps { @@ -135,10 +141,10 @@ export function createOnboardingService(deps: OnboardingServiceDeps): Onboarding // get auto-marked as completed so the hero never appears. if (logicalSessions.length > 0 && !hasSettledInitialOnboarding(milestones)) { const updated = await deps.upsertMilestone('initial_onboarding', 'completed'); - return { state, milestones: updated, sessions, connections, defaultSlug: defaultSlug ?? null }; + return buildSnapshot(state, updated, sessions, connections, defaultSlug, secrets); } - return { state, milestones, sessions, connections, defaultSlug: defaultSlug ?? null }; + return buildSnapshot(state, milestones, sessions, connections, defaultSlug, secrets); }, async setMilestone(id: unknown, status: unknown): Promise { @@ -178,7 +184,7 @@ export function createOnboardingService(deps: OnboardingServiceDeps): Onboarding sessions: logicalSessions, secrets, }); - return { state, milestones, sessions, connections, defaultSlug: defaultSlug ?? null }; + return buildSnapshot(state, milestones, sessions, connections, defaultSlug, secrets); }, async clearMilestone(id: unknown): Promise { @@ -208,11 +214,40 @@ export function createOnboardingService(deps: OnboardingServiceDeps): Onboarding sessions: logicalSessions, secrets, }); - return { state, milestones, sessions, connections, defaultSlug: defaultSlug ?? null }; + return buildSnapshot(state, milestones, sessions, connections, defaultSlug, secrets); }, }; } +function buildSnapshot( + state: OnboardingState, + milestones: OnboardingMilestone[], + sessions: SessionSummary[], + connections: LlmConnection[], + defaultSlug: string | null, + secrets: Readonly>, +): OnboardingSnapshot { + return { + state, + milestones, + sessions, + connections, + defaultSlug: defaultSlug ?? null, + chatModelChoices: buildChatModelChoices(connections), + sessionSendOutcomes: Object.fromEntries( + sessions.map((session) => [ + session.id, + projectSessionSendOutcome({ + session, + connections, + defaultSlug, + hasSecret: (slug) => secrets[slug] ?? false, + }), + ]), + ), + }; +} + function isOnboardingMilestoneId(value: string): value is OnboardingMilestoneId { return (ONBOARDING_MILESTONE_IDS as readonly string[]).includes(value); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 0e86f44e24..360c0c27c9 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -111,6 +111,8 @@ export interface OnboardingSnapshot { sessions: import('@maka/core').SessionSummary[]; connections: import('@maka/core').LlmConnection[]; defaultSlug: string | null; + chatModelChoices: import('@maka/core').ChatModelChoice[]; + sessionSendOutcomes: Record; } export type RendererIngestInput = diff --git a/apps/desktop/src/renderer/OnboardingHero.tsx b/apps/desktop/src/renderer/OnboardingHero.tsx index 7520dd817c..7d24af670d 100644 --- a/apps/desktop/src/renderer/OnboardingHero.tsx +++ b/apps/desktop/src/renderer/OnboardingHero.tsx @@ -6,7 +6,6 @@ // by Settings, and a ready workspace returns to the ordinary Composer. import { - RECOMMENDED_PROVIDER_TYPES, type LlmConnection, type OnboardingState, type ProviderType, @@ -30,8 +29,7 @@ import type { ReactNode } from 'react'; import { getOnboardingCopy } from './locales/onboarding-copy'; import { getOnboardingHeroCopy, type OnboardingHeroCopy } from './onboarding-hero-copy'; import { ProviderLogo, providerDisplay } from './settings/provider-display'; - -const FIRST_RUN_PROVIDER_TYPES = RECOMMENDED_PROVIDER_TYPES.slice(0, 4); +import { FIRST_RUN_PROVIDER_TYPES } from './onboarding-provider-types'; export interface OnboardingHeroProps { state: OnboardingState; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 916fe17dd6..406fe6e3ad 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -19,13 +19,13 @@ import type { import { collapseSessionRevisions, filterLinkedSessionTree, - hasSettledInitialOnboarding, isLinkedSubagentSession, parseGraphCommand, parseSwarmCommand, projectRevisionLinkedSessionTree, resolveUiLocale, } from '@maka/core'; +import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; import { AutomationsPage, DailyReviewPage, @@ -335,7 +335,6 @@ function AppShellContent({ const { memoryActive, refreshMemoryActive } = useShellMemoryPill({ toastApi, uiLocale }); const { connections, - connectionsRevision, defaultConnection, setConnections, setDefaultConnection, @@ -678,14 +677,13 @@ function AppShellContent({ } = useShellChatModel({ uiLocale, connections, - connectionsRevision, + snapshotChoices: onboarding.snapshot?.chatModelChoices, + sessionSendOutcome: activeSession + ? onboarding.snapshot?.sessionSendOutcomes[activeSession.id] + : undefined, defaultConnection, activationCandidate: onboardingActivationCandidate, activeSession, - // Only trust the loaded transcript once the active session's - // messages finished loading; during the load the list may still be - // empty or carry the previous session. - activeSessionHasUserMessage: !messageLoadPending && messages.some((message) => message.type === 'user'), persistedComposerDefaults, openSettingsSection, }); diff --git a/apps/desktop/src/renderer/chat-model-selection.ts b/apps/desktop/src/renderer/chat-model-selection.ts deleted file mode 100644 index 43aa2dfb82..0000000000 --- a/apps/desktop/src/renderer/chat-model-selection.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { LlmConnection, SessionSummary } from '@maka/core'; -import { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS } from '@maka/core'; -import type { ChatModelChoice } from '@maka/ui'; -import { buildCatalogChatModelChoices } from './model-catalog-choices'; - -export function buildChatModelChoices(connections: readonly LlmConnection[]): ChatModelChoice[] { - return buildCatalogChatModelChoices(connections); -} - -export function normalizeActiveChatModel( - session: SessionSummary | undefined, - connection: LlmConnection | undefined, - choices: readonly ChatModelChoice[], -): string | undefined { - if (!session || session.backend === 'fake') return undefined; - const requested = session.model || connection?.defaultModel; - const matchingChoice = choices.find( - (choice) => choice.connectionSlug === session.llmConnectionSlug && choice.model === requested, - ); - if (matchingChoice) return matchingChoice.model; - if ( - connection?.providerType === 'openai-codex' && - requested && - CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(requested) - ) { - return choices.find((choice) => choice.connectionSlug === session.llmConnectionSlug)?.model; - } - return requested; -} - -export function chatModelChoiceLabel( - choices: readonly ChatModelChoice[], - connectionSlug: string | undefined, - model: string | undefined, -): string | undefined { - if (!connectionSlug || !model) return model; - return choices.find((choice) => choice.connectionSlug === connectionSlug && choice.model === model)?.label ?? model; -} diff --git a/apps/desktop/src/renderer/chat-workbar.tsx b/apps/desktop/src/renderer/chat-workbar.tsx index c2aaa05d42..ee137c7075 100644 --- a/apps/desktop/src/renderer/chat-workbar.tsx +++ b/apps/desktop/src/renderer/chat-workbar.tsx @@ -1,8 +1,8 @@ import { lazy, Suspense, useState, type CSSProperties } from 'react'; import { Card } from '@astryxdesign/core/Card'; import { ResizeHandle, type ResizableProps } from '@astryxdesign/core/Resizable'; -import { useUiLocale, type ChatModelChoice } from '@maka/ui'; -import type { SessionSummary } from '@maka/core'; +import { useUiLocale } from '@maka/ui'; +import type { ChatModelChoice, SessionSummary } from '@maka/core'; import type { SessionWorkbarTab } from './session-workbar-layout'; import { getShellCopy } from './locales/shell-copy'; import type { diff --git a/apps/desktop/src/renderer/model-catalog-choices.ts b/apps/desktop/src/renderer/model-catalog-choices.ts index f3cc7c51da..5cdf86e92d 100644 --- a/apps/desktop/src/renderer/model-catalog-choices.ts +++ b/apps/desktop/src/renderer/model-catalog-choices.ts @@ -1,54 +1,19 @@ import { - CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, - PROVIDER_DEFAULTS, buildConnectionModelCatalogEntries, - connectionEnabledModelIds, - isWiredOAuthProvider, - normalizeOpenAiCodexConnection, - type LlmConnection, type ModelCatalogEntry, - type ProviderType, type SavedModelChoice, - type UiLocale, -} from '@maka/core'; -import type { ChatModelChoice } from '@maka/ui'; +} from '@maka/core/model-catalog'; +import { + CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, + PROVIDER_DEFAULTS, + connectionEnabledModelIds, +} from '@maka/core/llm-connections'; +import { isWiredOAuthProvider } from '@maka/core/provider-registry'; +import type { LlmConnection, ProviderType, UiLocale } from '@maka/core'; import { getShellRemainingCopy } from './locales/shell-remaining-copy.js'; const DAILY_REVIEW_MODEL_KEY_SEPARATOR = '::'; -export function pickNewChatModel(input: { - pending: { llmConnectionSlug: string; model: string } | null; - activationCandidate?: { llmConnectionSlug: string; model: string }; - catalogDefault: { llmConnectionSlug: string; model: string } | undefined; - choices: readonly ChatModelChoice[]; -}): { llmConnectionSlug: string; model: string } | undefined { - const pending = - input.pending && - input.choices.some( - (choice) => - choice.connectionSlug === input.pending?.llmConnectionSlug && - choice.model === input.pending.model, - ) - ? input.pending - : null; - if (pending) return pending; - const activationCandidate = - input.activationCandidate && - input.choices.some( - (choice) => - choice.connectionSlug === input.activationCandidate?.llmConnectionSlug && - choice.model === input.activationCandidate.model, - ) - ? input.activationCandidate - : undefined; - if (activationCandidate) return activationCandidate; - if (input.catalogDefault) return input.catalogDefault; - const first = input.choices[0]; - return first - ? { llmConnectionSlug: first.connectionSlug, model: first.model } - : undefined; -} - export function buildCatalogRecommendedDefaultModel(providerType: ProviderType): string { const entry = selectableCatalogEntries({ slug: providerType, @@ -58,48 +23,6 @@ export function buildCatalogRecommendedDefaultModel(providerType: ProviderType): return entry?.id ?? ''; } -export function pickCatalogDefaultChatModel(connection: Pick< - LlmConnection, - 'slug' | 'providerType' | 'defaultModel' | 'models' | 'modelSource' | 'modelsFetchedAt' ->): { llmConnectionSlug: string; model: string } | undefined { - const entry = selectableCatalogEntries(connection).find((choice) => choice.isDefault && choice.canUseAsChatDefault); - return entry ? { llmConnectionSlug: connection.slug, model: entry.id } : undefined; -} - -export function buildCatalogChatModelChoices(connections: readonly LlmConnection[]): ChatModelChoice[] { - const choices: ChatModelChoice[] = []; - for (const rawConnection of connections) { - const connection = normalizeOpenAiCodexConnection(rawConnection); - if (!isModelConsumerConnection(connection)) continue; - // Only non-OAuth connections get their user-chosen name surfaced in the - // menu heading — see `ChatModelChoice.connectionName`. OAuth providers' - // `connection.name` embeds the account email, so this stays undefined - // for them and the menu falls back to the provider label. - const connectionName = PROVIDER_DEFAULTS[connection.providerType].authKind === 'oauth_token' - ? undefined - : connection.name; - const enabledModelIds = new Set(connectionEnabledModelIds(connection)); - for (const entry of selectableCatalogEntries(connection)) { - if (!enabledModelIds.has(entry.id)) continue; - choices.push({ - connectionSlug: connection.slug, - providerType: connection.providerType, - model: entry.id, - label: modelDisplayLabel(entry), - connectionName, - }); - } - } - return choices; -} - -export function buildCatalogModelChoices(connection: Pick< - LlmConnection, - 'slug' | 'providerType' | 'defaultModel' | 'models' | 'modelSource' | 'modelsFetchedAt' ->): ModelCatalogEntry[] { - return buildConnectionModelCatalogEntries({ connection }); -} - export function buildCatalogDailyReviewModelOptions( connections: readonly LlmConnection[], currentModelKey: string, diff --git a/apps/desktop/src/renderer/onboarding-provider-types.ts b/apps/desktop/src/renderer/onboarding-provider-types.ts new file mode 100644 index 0000000000..8a7ae81848 --- /dev/null +++ b/apps/desktop/src/renderer/onboarding-provider-types.ts @@ -0,0 +1,8 @@ +import type { ProviderType } from '@maka/core'; + +export const FIRST_RUN_PROVIDER_TYPES = [ + 'opencode-free', + 'opencode-go', + 'openai', + 'anthropic', +] as const satisfies readonly ProviderType[]; diff --git a/apps/desktop/src/renderer/session-health-notice.ts b/apps/desktop/src/renderer/session-health-notice.ts index 9c4ffc2299..a98f533349 100644 --- a/apps/desktop/src/renderer/session-health-notice.ts +++ b/apps/desktop/src/renderer/session-health-notice.ts @@ -4,11 +4,9 @@ * #1038 — the notice answers exactly one question: "will the next send * fail for a recoverable connection/session reason, and where should the * user go?". The answer comes from `projectSessionSendOutcome` — the - * same core projection the main-process send gate delegates to — fed - * with renderer-side facts: the connection list, the default slug, a - * `connections:hasSecret` probe, and `connectionLocked` on the session - * summary. The notice and the send path cannot disagree, because they - * decide from the same code over the same facts: + * same core projection the main-process send gate delegates to, already + * resolved by main and carried in the onboarding snapshot. The renderer + * only maps that authoritative outcome to copy: * * - `ready` / `rebind` → no notice (silent rebind stays silent, #1032). * - `blocked` → destructive notice whose copy names the failing @@ -24,7 +22,6 @@ */ import { - projectSessionSendOutcome, type LlmConnection, type SessionSendProjection, type SessionSendProjectionSession, @@ -41,16 +38,10 @@ export interface SessionHealthNoticeInput { * exactly as stored. */ session: SessionSendProjectionSession | undefined; - /** Every persisted connection — the projection's rebind walk reads all of them. */ + /** Main-process projection from the latest onboarding snapshot. */ + outcome: SessionSendProjection | undefined; + /** Persisted connections are used only to name a blocked session's own connection. */ connections: readonly LlmConnection[]; - defaultSlug: string | null; - /** - * Secret presence per slug from the `connections:hasSecret` probe. - * Unknown (probe in flight) is treated as present so a destructive - * notice never flashes before the first probe lands; a genuine block - * simply appears one tick later. - */ - hasSecret(slug: string): boolean; /** * The session's own connection's most recent credential test result. * Advisory reminder only — never interpreted as a send block (E4). @@ -76,15 +67,8 @@ export interface SessionHealthNotice { export function deriveSessionHealthNotice( input: SessionHealthNoticeInput, ): SessionHealthNotice | undefined { - const { session } = input; - if (!session) return undefined; - - const outcome = projectSessionSendOutcome({ - session, - connections: input.connections, - defaultSlug: input.defaultSlug, - hasSecret: input.hasSecret, - }); + const { session, outcome } = input; + if (!session || !outcome) return undefined; if (outcome.kind === 'blocked') return blockedNotice(outcome, input); if (outcome.kind === 'rebind') return undefined; diff --git a/apps/desktop/src/renderer/settings/general-settings-page.tsx b/apps/desktop/src/renderer/settings/general-settings-page.tsx index 78fa30b7b5..b90e865e27 100644 --- a/apps/desktop/src/renderer/settings/general-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/general-settings-page.tsx @@ -15,6 +15,7 @@ import type { UpdateAppSettingsResult, } from "@maka/core"; import type { TestProxyInput } from "@maka/core/settings/network-settings"; +import { buildChatModelChoices } from "@maka/core/chat-model-choice"; import { Button, FormLayout, @@ -33,7 +34,6 @@ import { Banner, } from "@maka/ui"; import { ProviderBrandMark } from "./provider-brand-marks"; -import { buildCatalogChatModelChoices } from "../model-catalog-choices"; import { PasswordInput } from "./password-input"; import { settingsActionErrorMessage } from "./settings-error-copy"; import { useActionGuard, useKeyedActionGuard } from "./use-action-guard"; @@ -193,7 +193,7 @@ function GeneralDefaultsCard(props: { const [savingPermissionMode, setSavingPermissionMode] = useState(false); const modelChoices = useMemo( - () => buildCatalogChatModelChoices(props.connections), + () => buildChatModelChoices(props.connections), [props.connections], ); const modelGroups = useMemo( diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 66368ceb1b..e40718d0a7 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -1,11 +1,13 @@ import { useState, type FormEvent } from 'react'; +import { + type ProviderType, +} from '@maka/core'; import { PROVIDER_DEFAULTS, deriveConnectionSlug, - isWiredOAuthProvider, validateSlug, - type ProviderType, -} from '@maka/core'; +} from '@maka/core/llm-connections'; +import { isWiredOAuthProvider } from '@maka/core/provider-registry'; import { providerAuthRequiresSecret, providerAuthSupportsApiKey, diff --git a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx index 5136d039cb..b48d8e0116 100644 --- a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx +++ b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx @@ -10,11 +10,11 @@ import { import { ChevronRight, Search } from '@maka/ui/icons'; import { CATALOG_PROVIDER_TYPES, - PROVIDER_DEFAULTS, RECOMMENDED_PROVIDER_TYPES, type ProviderCatalogGroup, type ProviderType, -} from '@maka/core'; +} from '@maka/core/provider-registry'; +import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; import { TextInput, useUiLocale } from '@maka/ui'; import { AddProviderForm } from './provider-add-form'; import { ProviderLogo, providerDisplay } from './provider-display'; diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index f8cb7142c2..8c2269e5fb 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from 'react'; import { Banner, Divider, Grid, Heading, HStack, Link, Text, VStack } from '@astryxdesign/core'; -import { PROVIDER_DEFAULTS } from '@maka/core'; +import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; import { Button, RelativeTime, diff --git a/apps/desktop/src/renderer/settings/provider-display-copy.ts b/apps/desktop/src/renderer/settings/provider-display-copy.ts index d33e4e55d2..1deb0a6ec2 100644 --- a/apps/desktop/src/renderer/settings/provider-display-copy.ts +++ b/apps/desktop/src/renderer/settings/provider-display-copy.ts @@ -1,4 +1,4 @@ -import type { ProviderType, UiCatalog } from '@maka/core'; +import type { ProviderType, UiCatalog, UiLocale } from '@maka/core'; /** * Pure-data provider introduction copy, localized zh / en. @@ -275,3 +275,8 @@ export const PROVIDER_DISPLAY_COPY = { en: { name: 'Gemini CLI', description: 'Google account sign-in is not yet wired to chat.' }, }, } satisfies Record>; + +export function providerDisplay(type: ProviderType, locale: UiLocale): ProviderCopy { + const copy = (PROVIDER_DISPLAY_COPY as Partial>>)[type]?.[locale]; + return copy ?? { name: type, description: UNKNOWN_PROVIDER_DESCRIPTION[locale] }; +} diff --git a/apps/desktop/src/renderer/settings/provider-display.tsx b/apps/desktop/src/renderer/settings/provider-display.tsx index f6ef6765b2..8bf5b25023 100644 --- a/apps/desktop/src/renderer/settings/provider-display.tsx +++ b/apps/desktop/src/renderer/settings/provider-display.tsx @@ -1,6 +1,6 @@ -import { PROVIDER_DEFAULTS, type ProviderType, type UiLocale } from '@maka/core'; +import type { ProviderType } from '@maka/core'; import { ProviderBrandMark } from './provider-brand-marks'; -import { PROVIDER_DISPLAY_COPY, UNKNOWN_PROVIDER_DESCRIPTION, type ProviderCopy } from './provider-display-copy'; +export { providerDisplay } from './provider-display-copy'; // Kept as a thin wrapper so the many `ProviderLogo` call sites stay put. function ProviderLogoMark({ type }: { type: ProviderType }) { @@ -14,23 +14,3 @@ export function ProviderLogo(props: { type: ProviderType; compact?: boolean }) { ); } - -export function providerDisplay( - type: ProviderType, - locale: UiLocale, -): ProviderCopy { - // The copy map covers every registered ProviderType at compile time - // (`satisfies` in provider-display-copy.ts), but a connection persisted on - // a branch that registers a provider this build doesn't know reaches here - // with an unknown type at runtime — hence the widened view and the registry - // fallback below. Mirrors `isFakeBackend`. - const copyByType = PROVIDER_DISPLAY_COPY as Partial>>; - const copy = copyByType[type]?.[locale]; - if (copy) return copy; - const definition = PROVIDER_DEFAULTS[type]; - return { - name: definition?.label ?? type, - description: definition?.description ?? UNKNOWN_PROVIDER_DESCRIPTION[locale], - ...(definition?.catalogBadge ? { badge: definition.catalogBadge } : {}), - }; -} diff --git a/apps/desktop/src/renderer/settings/subagent-preset-presentation.ts b/apps/desktop/src/renderer/settings/subagent-preset-presentation.ts index 31f44c912e..50965f7867 100644 --- a/apps/desktop/src/renderer/settings/subagent-preset-presentation.ts +++ b/apps/desktop/src/renderer/settings/subagent-preset-presentation.ts @@ -1,8 +1,8 @@ import { - connectionEnabledModelIds, type LlmConnection, type SubagentPreset, } from '@maka/core'; +import { connectionEnabledModelIds } from '@maka/core/llm-connections'; import type { StatusTone } from './settings-status-badge.js'; /** diff --git a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx index 1eafdaf7c7..46aec1a40a 100644 --- a/apps/desktop/src/renderer/settings/subagent-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/subagent-settings-page.tsx @@ -14,13 +14,11 @@ import { useId, useMemo, useRef, useState } from 'react'; import { Banner, HStack, VStack } from '@astryxdesign/core'; import { - connectionEnabledModelIds, isSafeSubagentPresetId, MAX_SUBAGENT_PRESETS, SUBAGENT_PRESET_DESCRIPTION_MAX_CHARS, SUBAGENT_PRESET_ID_MAX_CHARS, SUBAGENT_PRESET_NAME_MAX_CHARS, - thinkingVariantsForModel, type AppSettings, type LlmConnection, type SubagentPreset, @@ -28,6 +26,8 @@ import { type ThinkingLevel, type UpdateAppSettingsResult, } from '@maka/core'; +import { connectionEnabledModelIds } from '@maka/core/llm-connections'; +import { thinkingVariantsForModel } from '@maka/core/model-thinking'; import { Badge, Button, diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts index 2ad581345d..7ea7a65194 100644 --- a/apps/desktop/src/renderer/settings/use-connection-detail.ts +++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts @@ -1,13 +1,13 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { - PROVIDER_DEFAULTS, - connectionEnabledModelIds, - isWiredOAuthProvider, type ConnectionTestResult, type LlmConnection, type ModelInfo, type ProviderType, } from '@maka/core'; +import { PROVIDER_DEFAULTS, connectionEnabledModelIds } from '@maka/core/llm-connections'; +import { buildConnectionModelCatalogEntries } from '@maka/core/model-catalog'; +import { isWiredOAuthProvider } from '@maka/core/provider-registry'; import { providerAuthRequiresSecret, providerAuthSupportsApiKey, @@ -15,7 +15,6 @@ import { } from '@maka/core/llm-connections'; import { useMountedRef, useToast, useUiLocale } from '@maka/ui'; import { getProviderSettingsCopy } from '../locales/settings-provider-copy'; -import { buildCatalogModelChoices } from '../model-catalog-choices'; import { connectionChipStatus } from './provider-connection-status'; import { useKeyedActionGuard } from './use-action-guard'; import type { OAuthLoginFlowBridge } from './use-oauth-login-flow'; @@ -225,13 +224,15 @@ export function useConnectionDetail(props: ConnectionDetailProps) { // Picker entries come from the same catalog merge path as Chat and Daily // Review, but use the local unsaved editor draft for model/default changes. - const modelChoices = buildCatalogModelChoices({ - slug: connection.slug, - providerType: connection.providerType, - defaultModel: connection.defaultModel, - models: modelSource === 'fetched' || models.length > 0 ? models : undefined, - modelSource, - modelsFetchedAt: connection.modelsFetchedAt, + const modelChoices = buildConnectionModelCatalogEntries({ + connection: { + slug: connection.slug, + providerType: connection.providerType, + defaultModel: connection.defaultModel, + models: modelSource === 'fetched' || models.length > 0 ? models : undefined, + modelSource, + modelsFetchedAt: connection.modelsFetchedAt, + }, }); /** diff --git a/apps/desktop/src/renderer/settings/voice-recognition-connection-form.tsx b/apps/desktop/src/renderer/settings/voice-recognition-connection-form.tsx index 71edef35b9..17e1b9645e 100644 --- a/apps/desktop/src/renderer/settings/voice-recognition-connection-form.tsx +++ b/apps/desktop/src/renderer/settings/voice-recognition-connection-form.tsx @@ -1,9 +1,8 @@ import { useState } from 'react'; import { - PROVIDER_DEFAULTS, - effectiveBaseUrl, type LlmConnection, } from '@maka/core'; +import { PROVIDER_DEFAULTS, effectiveBaseUrl } from '@maka/core/llm-connections'; import { providerAuthSupportsApiKey, } from '@maka/core/llm-connections'; diff --git a/apps/desktop/src/renderer/shell-chat-model-selection.ts b/apps/desktop/src/renderer/shell-chat-model-selection.ts new file mode 100644 index 0000000000..0dbccea1e8 --- /dev/null +++ b/apps/desktop/src/renderer/shell-chat-model-selection.ts @@ -0,0 +1,46 @@ +import type { ChatModelChoice, LlmConnection, SessionSummary } from '@maka/core'; +import { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS } from '@maka/core/codex-model-compatibility'; + +export type NewChatModel = { llmConnectionSlug: string; model: string }; + +export function pickNewChatModel(input: { + pending: NewChatModel | null; + activationCandidate?: NewChatModel; + catalogDefault: NewChatModel | undefined; + choices: readonly ChatModelChoice[]; +}): NewChatModel | undefined { + for (const candidate of [input.pending, input.activationCandidate, input.catalogDefault]) { + if (candidate && input.choices.some( + (choice) => choice.connectionSlug === candidate.llmConnectionSlug && choice.model === candidate.model, + )) return candidate; + } + const first = input.choices[0]; + return first ? { llmConnectionSlug: first.connectionSlug, model: first.model } : undefined; +} + +export function normalizeActiveChatModel( + session: SessionSummary | undefined, + connection: LlmConnection | undefined, + choices: readonly ChatModelChoice[], +): string | undefined { + if (!session || session.backend === 'fake') return undefined; + const requested = session.model || connection?.defaultModel; + if (choices.some( + (choice) => choice.connectionSlug === session.llmConnectionSlug && choice.model === requested, + )) return requested; + if ( + connection?.providerType !== 'openai-codex' || + !requested || + !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(requested) + ) return requested; + return choices.find((choice) => choice.connectionSlug === session.llmConnectionSlug)?.model; +} + +export function chatModelChoiceLabel( + choices: readonly ChatModelChoice[], + connectionSlug: string | undefined, + model: string | undefined, +): string | undefined { + if (!connectionSlug || !model) return model; + return choices.find((choice) => choice.connectionSlug === connectionSlug && choice.model === model)?.label ?? model; +} diff --git a/apps/desktop/src/renderer/use-onboarding-snapshot.ts b/apps/desktop/src/renderer/use-onboarding-snapshot.ts index 654c3247f2..cf843bf857 100644 --- a/apps/desktop/src/renderer/use-onboarding-snapshot.ts +++ b/apps/desktop/src/renderer/use-onboarding-snapshot.ts @@ -14,7 +14,8 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { generalizedErrorMessage, generalizedErrorMessageChinese, hasSettledInitialOnboarding, type LlmConnection, type OnboardingState, type SessionSummary, type UiLocale } from '@maka/core'; +import { generalizedErrorMessage, generalizedErrorMessageChinese, type LlmConnection, type OnboardingState, type SessionSummary, type UiLocale } from '@maka/core'; +import { hasSettledInitialOnboarding } from '@maka/core/onboarding-milestone'; import { useUiLocale } from '@maka/ui'; import type { OnboardingSnapshot } from '../preload/bridge-contract.js'; import { getOnboardingCopy } from './locales/onboarding-copy.js'; diff --git a/apps/desktop/src/renderer/use-shell-chat-model.ts b/apps/desktop/src/renderer/use-shell-chat-model.ts index abe1a351d1..547b6fe551 100644 --- a/apps/desktop/src/renderer/use-shell-chat-model.ts +++ b/apps/desktop/src/renderer/use-shell-chat-model.ts @@ -1,14 +1,16 @@ -import { useEffect, useMemo, useState } from 'react'; -import type { LlmConnection, SessionSummary, SettingsSection, ThinkingLevel, UiLocale } from '@maka/core'; -import { thinkingVariantsForModel } from '@maka/core'; -import type { ChatModelChoice } from '@maka/ui'; +import { useMemo, useState } from 'react'; +import type { ChatModelChoice, LlmConnection, SessionSendProjection, SessionSummary, SettingsSection, ThinkingLevel, UiLocale } from '@maka/core'; +import { + chatModelChoiceLabel, + normalizeActiveChatModel, + pickNewChatModel, + type NewChatModel, +} from './shell-chat-model-selection'; import { deriveSessionHealthNotice } from './session-health-notice'; -import { pickCatalogDefaultChatModel, pickNewChatModel } from './model-catalog-choices'; -import { buildChatModelChoices, chatModelChoiceLabel, normalizeActiveChatModel } from './chat-model-selection'; import type { ComposerDefaults } from './composer-defaults'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; -export type NewChatModel = { llmConnectionSlug: string; model: string }; +export type { NewChatModel } from './shell-chat-model-selection'; export type SessionHealthNoticeView = { tone: 'info' | 'warning' | 'destructive'; @@ -36,26 +38,11 @@ export type SessionHealthNoticeView = { export function useShellChatModel(options: { uiLocale: UiLocale; connections: LlmConnection[]; - /** - * Refresh counter from `useShellConnections`: bumps on every successful - * `refreshConnections`, including credential-only changes that keep the - * list identity (`updatedAt` unchanged). The secret probe below depends - * on it so those changes re-probe instead of serving stale presence - * (#1038 review). - */ - connectionsRevision: number; + snapshotChoices: ChatModelChoice[] | undefined; + sessionSendOutcome: SessionSendProjection | undefined; defaultConnection: string | null; activationCandidate?: NewChatModel; activeSession: SessionSummary | undefined; - /** - * True when the active session's loaded transcript already contains a - * user message. Storage self-heals `connectionLocked` only on - * `readHeader`/`readMessages`, so a just-opened legacy session's - * summary can still read unlocked; the loaded transcript is the same - * primary evidence storage uses, and the notice must not treat the - * session as rebindable in the meantime (#1038 review). - */ - activeSessionHasUserMessage: boolean; persistedComposerDefaults: ComposerDefaults | null; openSettingsSection: (section: SettingsSection) => void; }): { @@ -76,7 +63,7 @@ export function useShellChatModel(options: { setPendingNewChatThinkingLevel: (next: ThinkingLevel | null) => void; sessionHealthNotice: SessionHealthNoticeView | undefined; } { - const { uiLocale, connections, connectionsRevision, defaultConnection, activationCandidate, activeSession, activeSessionHasUserMessage, persistedComposerDefaults, openSettingsSection } = options; + const { uiLocale, connections, defaultConnection, activationCandidate, activeSession, persistedComposerDefaults, openSettingsSection } = options; const conversationCopy = getDesktopConversationCopy(uiLocale); // Persisted composer defaults seed the empty-state model so the home view is // populated before the async `app:info` round-trip completes on mount. @@ -86,13 +73,7 @@ export function useShellChatModel(options: { const activeConnection = activeSession ? connections.find((connection) => connection.slug === activeSession.llmConnectionSlug) : undefined; - const defaultConnectionEntry = defaultConnection - ? connections.find((connection) => connection.slug === defaultConnection) - : undefined; - const chatModelChoices = useMemo( - () => buildChatModelChoices(connections), - [connections], - ); + const chatModelChoices = options.snapshotChoices ?? []; // Home / empty-state composer: which model the next NEW chat starts with. // An explicit pick stays sticky; otherwise onboarding's readiness-checked // candidate wins before the legacy catalog default and first offered choice. @@ -101,8 +82,11 @@ export function useShellChatModel(options: { // A pick only stays in effect while it is still an offered choice. If the user // later disables/removes that connection or model, fall through to another // offered candidate so the home chip never shows — nor sends — a stale model. - const catalogDefaultNewChatModel = defaultConnectionEntry - ? pickCatalogDefaultChatModel(defaultConnectionEntry) + const catalogDefaultChoice = chatModelChoices.find( + (choice) => choice.connectionSlug === defaultConnection && choice.isDefault, + ); + const catalogDefaultNewChatModel = catalogDefaultChoice + ? { llmConnectionSlug: catalogDefaultChoice.connectionSlug, model: catalogDefaultChoice.model } : undefined; const newChatModel = pickNewChatModel({ pending: pendingNewChatModel, @@ -120,8 +104,10 @@ export function useShellChatModel(options: { ? undefined : chatModelChoiceLabel(chatModelChoices, activeSession?.llmConnectionSlug, activeModel); const activeThinkingLevels = useMemo( - () => (activeConnection && activeModel) ? thinkingVariantsForModel(activeConnection.providerType, activeModel) : [], - [activeConnection, activeModel], + () => chatModelChoices.find( + (choice) => choice.connectionSlug === activeSession?.llmConnectionSlug && choice.model === activeModel, + )?.thinkingLevels ?? [], + [activeSession?.llmConnectionSlug, activeModel, chatModelChoices], ); // Only surface a stored level when the current model still supports it; // if the model changed (setModel clears it) or the catalog reconfigured so @@ -135,61 +121,25 @@ export function useShellChatModel(options: { const newChatThinkingLevels = useMemo( () => { if (!newChatModel) return []; - const c = connections.find((entry) => entry.slug === newChatModel.llmConnectionSlug); - return c ? thinkingVariantsForModel(c.providerType, newChatModel.model) : []; + return chatModelChoices.find( + (choice) => choice.connectionSlug === newChatModel.llmConnectionSlug && choice.model === newChatModel.model, + )?.thinkingLevels ?? []; }, - [newChatModel, connections], + [newChatModel, chatModelChoices], ); const newChatThinkingLevel = pendingNewChatThinkingLevel && newChatThinkingLevels.includes(pendingNewChatThinkingLevel) ? pendingNewChatThinkingLevel : undefined; const newChatModelLabel = chatModelChoiceLabel(chatModelChoices, newChatModel?.llmConnectionSlug, newChatModel?.model); - // #1038: the notice decides from the same facts as the send gate, so - // the renderer needs real secret presence, not the old - // "default exists && enabled" proxy. Probe every connection's secret - // via IPC whenever the connection list changes (the list refreshes on - // every connection mutation, including API-key saves). Until the - // probe lands, presence is treated optimistically so a destructive - // notice never flashes on first paint. - const [secretPresence, setSecretPresence] = useState>>({}); - useEffect(() => { - let cancelled = false; - void Promise.all( - connections.map(async (connection) => { - try { - return [connection.slug, await window.maka.connections.hasSecret(connection.slug)] as const; - } catch { - return [connection.slug, true] as const; - } - }), - ).then((entries) => { - if (!cancelled) setSecretPresence(Object.fromEntries(entries)); - }); - return () => { - cancelled = true; - }; - }, [connections, connectionsRevision]); - // Notice derivation is a pure function (see `session-health-notice.ts`); we // wrap the returned `onClickTarget` here with the Settings-jump action. const sessionHealthNotice = useMemo(() => { const derived = deriveSessionHealthNotice({ locale: uiLocale, - session: activeSession - ? { - backend: activeSession.backend, - llmConnectionSlug: activeSession.llmConnectionSlug, - model: activeSession.model, - // Effective lock: the healed summary bit OR the same primary - // evidence storage heals from (a user message in the loaded - // transcript). See the option doc above. - connectionLocked: activeSession.connectionLocked || activeSessionHasUserMessage, - } - : undefined, + session: activeSession, + outcome: options.sessionSendOutcome, connections, - defaultSlug: defaultConnection, - hasSecret: (slug) => secretPresence[slug] ?? true, lastTestStatus: activeConnection?.lastTestStatus, }); if (!derived) return undefined; @@ -207,14 +157,10 @@ export function useShellChatModel(options: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ activeSession?.id, - activeSession?.backend, activeSession?.llmConnectionSlug, activeSession?.model, - activeSession?.connectionLocked, - activeSessionHasUserMessage, + options.sessionSendOutcome, connections, - defaultConnection, - secretPresence, activeConnection?.lastTestStatus, uiLocale, ]); diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 3cf1c7ee31..98b3ca5cb6 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -38,14 +38,20 @@ const modelChoices: ChatModelChoice[] = [ { connectionSlug: 'anthropic-main', providerType: 'anthropic', + providerLabel: 'Anthropic', model: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', + isDefault: true, + thinkingLevels: [], }, { connectionSlug: 'openai-main', providerType: 'openai', + providerLabel: 'OpenAI', model: 'gpt-5.1', label: 'GPT-5.1', + isDefault: true, + thinkingLevels: [], }, ]; diff --git a/docs/model-metadata-firstscreen-optimization.md b/docs/model-metadata-firstscreen-optimization.md new file mode 100644 index 0000000000..83392e632c --- /dev/null +++ b/docs/model-metadata-firstscreen-optimization.md @@ -0,0 +1,143 @@ +# perf(desktop): remove models.dev metadata from the renderer startup path + +
+English + +## Problem + +Most users configure only a few providers, but Maka currently loads metadata for every provider and hundreds of models on startup. This data should remain behind the main-process authority boundary, with the renderer receiving only the lightweight projection needed for the current UI. + +The Desktop AppShell startup path statically loads `packages/core/src/model-metadata.generated.ts`. This models.dev snapshot is currently about 520 KB / 13,988 lines and contains full metadata for roughly 44 providers and hundreds of models. + +Measured from the 2026-08-04 renderer build: + +| Artifact | Size | +|---|---:| +| `model-metadata.generated.ts` source | 520 KB | +| `EmptyState-*.js` shared chunk containing the metadata | 644 KB | +| `model-catalog-choices-*.js` chunk | 98 KB | +| `index-*.js` entry | 253 KB | +| 29 modulepreload chunks combined | 1,769 KB | + +The `EmptyState-*.js` chunk contains 312 references matching model names such as `claude-opus`, `gpt-5.`, and `gemini-2.`, confirming that the snapshot is part of the startup artifact. Electron reads these files locally, so the main cost is renderer-main-thread parsing and evaluation rather than network I/O. + +Five independent runtime import paths make the metadata reachable at startup: + +1. `thinkingVariantsForModel` → `model-thinking.ts` → `model-metadata.ts` +2. `buildChatModelChoices` → `model-catalog-choices.ts` → `model-catalog.ts` +3. `@maka/ui` `modelMenuGroups` → `PROVIDER_DEFAULTS` +4. `provider-display.tsx` → `PROVIDER_DEFAULTS` +5. `OnboardingHero` → `RECOMMENDED_PROVIDER_TYPES` + +Each path eventually reaches `model-metadata.generated.ts`. Removing only one path, or assigning the metadata to a Vite `manualChunks` entry, does not remove the static startup dependency. + +The first screen needs only model choices, their thinking levels, provider heading labels, and local display copy for four onboarding providers. Rich metadata such as pricing, context windows, full capabilities, and lifecycle information is used only by the lazy-loaded SettingsModal. + +## Desired outcome + +Reuse the existing `onboarding:getSnapshot` path. The main process already loads the metadata and should provide the renderer with the lightweight startup projection: + +- available chat model choices; +- thinking levels for each connection/model; +- provider fallback labels used by model-menu headings. + +The renderer consumes this projection instead of reading the model catalog or metadata at startup. Connection changes continue to use the existing `connections:event → onboarding snapshot refresh` flow; no new IPC channel is needed. + +Remove the remaining provider-registry dependencies from the startup path: + +- `modelMenuGroups` receives the required label from the startup projection instead of reading `PROVIDER_DEFAULTS`. +- `providerDisplay` uses the existing exhaustive `PROVIDER_DISPLAY_COPY`; an unknown cross-version type falls back to the type string and generic local description instead of `PROVIDER_DEFAULTS`. +- OnboardingHero gets its four first-run provider types from a small metadata-free product constant or equivalent lightweight projection instead of importing `RECOMMENDED_PROVIDER_TYPES` at runtime. + +Full metadata remains available to the main process and lazy-loaded SettingsModal. The metadata code-generation flow remains unchanged. + +Acceptance criteria: + +- The startup entry and all of its static transitive dependencies exclude `model-metadata.generated.ts`, `model-metadata.ts`, `provider-registry.ts`, `model-catalog.ts`, and `model-thinking.ts`. +- The startup path no longer statically depends on the renderer's `model-catalog-choices.ts` or `chat-model-selection.ts`. +- Searching startup chunks for `claude-opus|gpt-5\.|gemini-2\.` returns zero; full metadata exists only on lazy Settings paths. +- Model choices, headings, provider logos, and active/new-chat thinking levels remain correct. +- OnboardingHero still shows the four recommended providers with their names, descriptions, and logos. +- Adding, changing, or removing a connection refreshes model choices and thinking levels through the snapshot flow. +- Model management, Daily Review, and provider catalog behavior in SettingsModal does not regress. +- Before/after measurements record the median of ten cold starts and startup JavaScript parse/evaluation time to verify a real improvement. + +## Alternatives or workarounds + +- **Vite `manualChunks`:** changes file placement but does not break a static import path, so the metadata chunk would still load and execute at startup. +- **A new `connections:listModelChoices` IPC channel:** duplicates the existing prefetched and connection-invalidated onboarding snapshot flow. +- **Reducing or changing the models.dev code-generated snapshot:** the main process and Settings still need the full data; its consumption path, not its generation, is the problem. +- **Sending provider descriptions and badges in the snapshot:** the renderer already has compile-time-complete localized display copy for every `ProviderType`. + +
+ +
+简体中文 + +## 问题 + +大多数用户只配置少数几个 provider,但 Maka 当前会在启动时加载全部 provider 和数百个模型的元数据。完整目录应留在 main process 的权威边界内,renderer 只接收当前界面所需的轻量投影。 + +桌面端 AppShell 的首屏静态依赖会加载 `packages/core/src/model-metadata.generated.ts`。该文件由 `scripts/sync-model-metadata.mjs` 从 models.dev 生成,当前约 520 KB、13,988 行,包含约 44 个 provider 和数百个模型的完整元数据。 + +2026-08-04 的 renderer 构建实测: + +| 产物 | 大小 | +|---|---:| +| `model-metadata.generated.ts` 源文件 | 520 KB | +| 含元数据的 `EmptyState-*.js` 共享 chunk | 644 KB | +| `model-catalog-choices-*.js` chunk | 98 KB | +| `index-*.js` 入口 | 253 KB | +| 29 个 modulepreload chunk 合计 | 1,769 KB | + +`EmptyState-*.js` 中可检出 312 处 `claude-opus`、`gpt-5.`、`gemini-2.` 等模型名引用,说明完整快照已进入首屏产物。Electron 从本地磁盘读取这些文件,主要问题不是网络请求,而是 renderer 主线程需要同步解析和执行这批首屏并不需要的数据。 + +目前有五条独立的首屏运行时依赖链可以触达完整元数据: + +1. `thinkingVariantsForModel` → `model-thinking.ts` → `model-metadata.ts` +2. `buildChatModelChoices` → `model-catalog-choices.ts` → `model-catalog.ts` +3. `@maka/ui` 的 `modelMenuGroups` → `PROVIDER_DEFAULTS` +4. `provider-display.tsx` → `PROVIDER_DEFAULTS` +5. `OnboardingHero` → `RECOMMENDED_PROVIDER_TYPES` + +这些链最终都会进入 `model-metadata.generated.ts`。只处理其中一条或使用 Vite `manualChunks` 都不会解除首屏静态依赖。 + +首屏实际只需要模型选项、对应的 thinking levels、provider heading label,以及 4 个首次引导 provider 的本地展示信息。pricing、context window、完整 capabilities、lifecycle 等富元数据只在懒加载的 SettingsModal 中使用。 + +## 期望结果 + +复用现有 `onboarding:getSnapshot`,由已经加载元数据的 main process 向 renderer 提供首屏所需的轻量投影: + +- 可用的 chat model choices; +- 各 connection/model 对应的 thinking levels; +- model menu heading 所需的 provider fallback label。 + +Renderer 使用 snapshot 数据渲染首屏,不再自行读取 model catalog 或 model metadata。connection 发生变化时,继续复用现有 `connections:event → onboarding snapshot refresh` 更新投影,不新增 IPC channel。 + +同时切断其余 provider registry 依赖: + +- `modelMenuGroups` 从首屏投影获取所需 label,不再直接读取 `PROVIDER_DEFAULTS`。 +- `providerDisplay` 使用已有且类型完整的 `PROVIDER_DISPLAY_COPY`;遇到跨版本未知 type 时直接显示 type 和通用本地描述,不再 fallback 到 `PROVIDER_DEFAULTS`。 +- OnboardingHero 的 4 个首次引导 provider 使用不依赖 provider registry 的小型产品常量或等价轻量投影,不再运行时引用 `RECOMMENDED_PROVIDER_TYPES`。 + +完整元数据继续保留在 main process 和懒加载的 SettingsModal 中,codegen 流程保持不变。 + +验收标准: + +- 首屏入口及其所有静态传递依赖不包含 `model-metadata.generated.ts`、`model-metadata.ts`、`provider-registry.ts`、`model-catalog.ts` 或 `model-thinking.ts`。 +- 首屏不再静态依赖 renderer 的 `model-catalog-choices.ts` 和 `chat-model-selection.ts`。 +- 构建产物的首屏 chunk 中检索 `claude-opus|gpt-5\.|gemini-2\.` 为 0;完整元数据只存在于设置页懒加载路径。 +- model picker 的模型、heading、provider logo,以及 active/new-chat thinking level 选项保持正确。 +- OnboardingHero 正常显示 4 个推荐 provider 的名称、描述和 logo。 +- connection 增删改后,model choices 和 thinking levels 随 snapshot 刷新。 +- SettingsModal 中的模型管理、Daily Review 和 provider catalog 功能不回归。 +- 记录改动前后 10 次冷启动中位数,以及首屏 JavaScript 解析/执行时间,验证优化是否产生实际收益。 + +## 备选方案或变通方法 + +- **Vite `manualChunks`**:只能改变模块所属文件,不能切断静态 import;首屏仍会加载并执行元数据 chunk。 +- **新增 `connections:listModelChoices` IPC**:现有 onboarding snapshot 已经在首屏预取,并监听 connection 变更;新 channel 会重复现有机制。 +- **修改或缩减 models.dev codegen 快照**:设置页和 main process 仍需要完整元数据;问题在消费位置,不在生成方式。 +- **把 provider description/badge 放入 snapshot**:renderer 已有编译时覆盖全部 `ProviderType` 的本地文案,重复传输没有必要。 + +
diff --git a/packages/core/package.json b/packages/core/package.json index 1484d3e51c..9839899dfe 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,6 +4,7 @@ "license": "Apache-2.0", "description": "Pure types for Maka — events, session, permission, connections.", "type": "module", + "sideEffects": false, "private": true, "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -78,16 +79,20 @@ "./web-search": "./dist/web-search.js", "./incognito": "./dist/incognito.js", "./backend-types": "./dist/backend-types.js", + "./codex-model-compatibility": "./dist/codex-model-compatibility.js", "./llm-connections": "./dist/llm-connections.js", + "./provider-registry": "./dist/provider-registry.js", "./provider-contract-matrix": "./dist/provider-contract-matrix.js", "./model-catalog": "./dist/model-catalog.js", "./model-metadata": "./dist/model-metadata.js", "./model-web-search": "./dist/model-web-search.js", "./model-thinking": "./dist/model-thinking.js", + "./chat-model-choice": "./dist/chat-model-choice.js", "./connection-readiness": "./dist/connection-readiness.js", "./provider-auth": "./dist/provider-auth.js", "./oauth-subscription": "./dist/oauth-subscription.js", "./onboarding": "./dist/onboarding.js", + "./onboarding-milestone": "./dist/onboarding-milestone.js", "./text-file-import": "./dist/text-file-import.js", "./redaction": "./dist/redaction.js", "./display-redaction": "./dist/display-redaction.js", diff --git a/packages/core/src/__tests__/chat-model-choice.test.ts b/packages/core/src/__tests__/chat-model-choice.test.ts new file mode 100644 index 0000000000..59271c72ea --- /dev/null +++ b/packages/core/src/__tests__/chat-model-choice.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { buildChatModelChoices } from '../chat-model-choice.js'; +import type { LlmConnection } from '../llm-connections.js'; + +function connection(overrides: Partial = {}): LlmConnection { + return { + slug: 'openai-main', + name: 'Work API', + providerType: 'openai', + defaultModel: 'gpt-5.5', + enabled: true, + enabledModelIds: ['gpt-5.5'], + models: [{ id: 'gpt-5.5' }, { id: 'gpt-4o' }], + modelSource: 'fetched', + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +test('projects only enabled chat models with display, default, and thinking metadata', () => { + assert.deepEqual(buildChatModelChoices([connection()]), [ + { + connectionSlug: 'openai-main', + providerType: 'openai', + providerLabel: 'OpenAI', + model: 'gpt-5.5', + label: 'GPT-5.5', + connectionName: 'Work API', + isDefault: true, + thinkingLevels: ['off', 'low', 'medium', 'high', 'xhigh'], + }, + ]); +}); + +test('redacts OAuth names and normalizes legacy Codex inventory', () => { + const [choice] = buildChatModelChoices([ + connection({ + slug: 'codex-account', + name: 'private@example.com', + providerType: 'openai-codex', + defaultModel: 'gpt-5-codex', + enabledModelIds: ['gpt-5-codex'], + models: [{ id: 'gpt-5-codex' }], + }), + ]); + assert.equal(choice?.model, 'gpt-5.6-sol'); + assert.equal(choice?.providerLabel, 'OpenAI OAuth'); + assert.equal(choice?.connectionName, undefined); + assert.equal(choice?.isDefault, true); +}); diff --git a/packages/core/src/chat-model-choice.ts b/packages/core/src/chat-model-choice.ts new file mode 100644 index 0000000000..488b16dd38 --- /dev/null +++ b/packages/core/src/chat-model-choice.ts @@ -0,0 +1,74 @@ +import { normalizeOpenAiCodexConnection } from './connection-readiness.js'; +import { buildConnectionModelCatalogEntries } from './model-catalog.js'; +import { thinkingVariantsForModel, type ThinkingLevel } from './model-thinking.js'; +import { + CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, + PROVIDER_DEFAULTS, + connectionEnabledModelIds, + isWiredOAuthProvider, + type LlmConnection, + type ProviderType, +} from './llm-connections.js'; + +const MODEL_MENU_PROVIDER_LABELS: Partial> = { + anthropic: 'Anthropic', + openai: 'OpenAI', + google: 'Google', + deepseek: 'DeepSeek', + moonshot: 'Moonshot', + ollama: 'Ollama', + 'kimi-coding-plan': 'Kimi', + 'zai-coding-plan': 'Z.AI', + MiniMax: 'MiniMax', + 'openai-codex': 'OpenAI OAuth', + 'gemini-cli': 'Gemini CLI', +}; + +export interface ChatModelChoice { + connectionSlug: string; + providerType: ProviderType; + providerLabel: string; + model: string; + label: string; + connectionName?: string; + isDefault: boolean; + thinkingLevels: readonly ThinkingLevel[]; +} + +export function buildChatModelChoices(connections: readonly LlmConnection[]): ChatModelChoice[] { + const choices: ChatModelChoice[] = []; + for (const rawConnection of connections) { + const connection = normalizeOpenAiCodexConnection(rawConnection); + const provider = PROVIDER_DEFAULTS[connection.providerType]; + if ( + !connection.enabled || + !provider || + provider.backendKind !== 'ai-sdk' || + (provider.authKind === 'oauth_token' && !isWiredOAuthProvider(connection.providerType)) + ) { + continue; + } + const enabledModelIds = new Set(connectionEnabledModelIds(connection)); + for (const entry of buildConnectionModelCatalogEntries({ connection })) { + if ( + !entry.canUseAsChatDefault || + !enabledModelIds.has(entry.id) || + (connection.providerType === 'openai-codex' && + CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(entry.id.trim())) + ) { + continue; + } + choices.push({ + connectionSlug: connection.slug, + providerType: connection.providerType, + providerLabel: MODEL_MENU_PROVIDER_LABELS[connection.providerType] ?? provider.label, + model: entry.id, + label: entry.displayName?.trim() || entry.id, + ...(provider.authKind === 'oauth_token' ? {} : { connectionName: connection.name }), + isDefault: entry.isDefault, + thinkingLevels: thinkingVariantsForModel(connection.providerType, entry.id), + }); + } + } + return choices; +} diff --git a/packages/core/src/codex-model-compatibility.ts b/packages/core/src/codex-model-compatibility.ts new file mode 100644 index 0000000000..a8cc13164d --- /dev/null +++ b/packages/core/src/codex-model-compatibility.ts @@ -0,0 +1 @@ +export const CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS = new Set(['gpt-5-codex']); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b2af1bfd2f..0c179e36ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -400,6 +400,8 @@ export { thinkingVariantsForModel, } from './model-thinking.js'; +export type { ChatModelChoice } from './chat-model-choice.js'; + // agent-run.ts export type { AgentRunEvent, diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 390146a023..fe243c64a4 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -6,6 +6,7 @@ */ import type { BackendKind } from './session.js'; +import { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS } from './codex-model-compatibility.js'; import { CATALOG_PROVIDER_TYPES, PROVIDER_REGISTRY, @@ -21,6 +22,7 @@ import { } from './provider-registry.js'; export type { BackendKind } from './session.js'; +export { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS }; export { CATALOG_PROVIDER_TYPES, PROVIDER_REGISTRY, @@ -284,8 +286,6 @@ export interface ConnectionTestResult { errorClass?: ConnectionTestErrorClass; } -export const CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS = new Set(['gpt-5-codex']); - export const PROVIDER_DEFAULTS = PROVIDER_REGISTRY; export function providerAuthRequiresSecret(providerType: ProviderType): boolean { diff --git a/packages/core/src/onboarding-milestone.ts b/packages/core/src/onboarding-milestone.ts new file mode 100644 index 0000000000..35beb0cdbe --- /dev/null +++ b/packages/core/src/onboarding-milestone.ts @@ -0,0 +1,13 @@ +export function hasSettledInitialOnboarding( + milestones: ReadonlyArray<{ + id: string; + completedAt?: number; + skippedAt?: number; + }>, +): boolean { + return milestones.some( + (milestone) => + milestone.id === 'initial_onboarding' && + (milestone.completedAt !== undefined || milestone.skippedAt !== undefined), + ); +} diff --git a/packages/core/src/onboarding.ts b/packages/core/src/onboarding.ts index d691f79940..b971120631 100644 --- a/packages/core/src/onboarding.ts +++ b/packages/core/src/onboarding.ts @@ -32,6 +32,7 @@ import { } from './connection-readiness.js'; import { connectionEnabledModelIds, type LlmConnection } from './llm-connections.js'; import type { SessionSummary } from './session.js'; +export { hasSettledInitialOnboarding } from './onboarding-milestone.js'; // ============================================================================ // OnboardingState (derived; never persisted) @@ -326,11 +327,3 @@ function isValidTimestamp(value: unknown): value is number { * onboarding is a one-time guide, not a gate that revives when * the user deletes all sessions. */ -export function hasSettledInitialOnboarding( - milestones: ReadonlyArray, -): boolean { - return milestones.some( - (m) => - m.id === 'initial_onboarding' && (m.completedAt !== undefined || m.skippedAt !== undefined), - ); -} diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index b902a51d91..2b6f4b500d 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -42,6 +42,52 @@ describe('SQLite SessionStore', () => { } }); + test('list self-heals a legacy unlocked session after its first user message', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-lock-heal-')); + const store = createSessionStore(root); + try { + const session = await store.create(makeInput()); + await store.appendMessage(session.id, { + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 10, + text: 'legacy message', + }); + await store.appendMessages( + session.id, + Array.from({ length: 10 }, (_, index) => ({ + type: 'assistant', + id: `legacy-assistant-${index}`, + turnId: `legacy-turn-${index}`, + ts: 11 + index, + text: 'reply', + modelId: 'fake-model', + })), + ); + for (let index = 0; index < 3; index += 1) { + const newer = await store.create(makeInput({ name: `Newer ${index}` })); + await store.appendMessage(newer.id, { + type: 'assistant', + id: `newer-assistant-${index}`, + turnId: `newer-turn-${index}`, + ts: 30 + index, + text: 'newer reply', + modelId: 'fake-model', + }); + } + + assert.equal( + (await store.list()).find((listed) => listed.id === session.id)?.connectionLocked, + true, + ); + assert.equal((await store.readHeaderSnapshot(session.id)).connectionLocked, true); + } finally { + await store.close?.(); + await rm(root, { recursive: true, force: true }); + } + }); + test('commits message and catalog projection atomically', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-session-atomic-')); const store = createSessionStore(root); diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index cd6e7eaf1b..8568e97b9f 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -481,10 +481,14 @@ class SqliteSessionStore implements SessionAuthorityStore { const summaries: SessionSummary[] = []; for (let index = 0; index < withPreviews.length; index += 1) { const { record, previewMessages } = withPreviews[index]!; - const { header } = record; + let { header } = record; let messages = previewMessages.slice(-10); - if (index < 3) { - messages = (await this.metadata.readMessages(header.id)).slice(-10); + if (index < 3 || (!header.connectionLocked && previewMessages.length > 0)) { + const storedMessages = await this.metadata.readMessages(header.id); + if (!header.connectionLocked) { + header = await this.lockConnectionAfterFirstUserMessage(header, storedMessages); + } + if (index < 3) messages = storedMessages.slice(-10); } summaries.push(toSummary(header, messages)); } diff --git a/packages/ui/src/__tests__/chat-model-helpers.test.ts b/packages/ui/src/__tests__/chat-model-helpers.test.ts index 743bac702b..54d1335592 100644 --- a/packages/ui/src/__tests__/chat-model-helpers.test.ts +++ b/packages/ui/src/__tests__/chat-model-helpers.test.ts @@ -4,7 +4,14 @@ import type { ProviderType } from '@maka/core'; import { modelMenuGroups, type ChatModelChoice } from '../chat-model-helpers.js'; function choice(connectionSlug: string, providerType: ProviderType, model: string, label = model): ChatModelChoice { - return { connectionSlug, providerType, model, label }; + const labels: Partial> = { + 'openai-codex': 'OpenAI OAuth', + 'openai-compatible': '自定义', + openai: 'OpenAI', + deepseek: 'DeepSeek', + }; + const providerLabel = labels[providerType] ?? providerType[0]!.toUpperCase() + providerType.slice(1); + return { connectionSlug, providerType, providerLabel, model, label, isDefault: true, thinkingLevels: [] }; } test('single connection per provider: heading is just the short label', () => { @@ -89,6 +96,13 @@ test('blank connectionName falls back to the provider label', () => { assert.equal(groups[0]?.heading, '自定义'); }); +test('localized provider headings override the snapshot fallback label', () => { + const groups = modelMenuGroups([ + { ...choice('claude', 'claude-subscription', 'claude-opus-4-8'), providerLabel: 'Claude Subscription' }, + ], 'zh'); + assert.equal(groups[0]?.heading, 'Claude 订阅'); +}); + test('unnamed connection keeps slug disambiguation even when a sibling is named', () => { // Two openai-compatible connections, only one named: the named one uses its // name, the unnamed one still needs the slug suffix to stay distinguishable. diff --git a/packages/ui/src/__tests__/model-picker.test.ts b/packages/ui/src/__tests__/model-picker.test.ts index 292e7c6638..cd7dc5c038 100644 --- a/packages/ui/src/__tests__/model-picker.test.ts +++ b/packages/ui/src/__tests__/model-picker.test.ts @@ -15,8 +15,11 @@ const groups: ModelMenuGroup[] = [ { connectionSlug: 'anthropic-team', providerType: 'anthropic', + providerLabel: 'Anthropic', model: 'claude-sonnet-4', label: 'Claude Sonnet 4', + isDefault: true, + thinkingLevels: [], }, ], }, @@ -28,14 +31,20 @@ const groups: ModelMenuGroup[] = [ { connectionSlug: 'openai-main', providerType: 'openai', + providerLabel: 'OpenAI', model: 'gpt-5', label: 'GPT-5', + isDefault: true, + thinkingLevels: [], }, { connectionSlug: 'openai-main', providerType: 'openai', + providerLabel: 'OpenAI', model: 'o3-mini', label: 'o3-mini', + isDefault: false, + thinkingLevels: [], }, ], }, diff --git a/packages/ui/src/chat-model-helpers.ts b/packages/ui/src/chat-model-helpers.ts index bd7ad2cad0..d9a78d351f 100644 --- a/packages/ui/src/chat-model-helpers.ts +++ b/packages/ui/src/chat-model-helpers.ts @@ -20,47 +20,9 @@ * harness (URI-encoded delimiters, malformed input fall-through). */ -import { PROVIDER_DEFAULTS, type ProviderType, type UiLocale } from '@maka/core'; +import type { ChatModelChoice, ProviderType, UiLocale } from '@maka/core'; import { getSharedUiCopy } from './shared-ui-copy.js'; - -export interface ChatModelChoice { - connectionSlug: string; - providerType: ProviderType; - model: string; - label: string; - /** - * User-chosen connection label — ONLY for non-OAuth providers (`api_key` / - * `none` auth), where `connection.name` is a plain label the user typed in - * Settings when adding the connection (e.g. "OpenRouter", "My Together AI - * key"). Must stay `undefined` for `claude-subscription` / - * `openai-codex` / `gemini-cli`, whose `connection.name` embeds the - * OAuth account email (PR-CHAT-CHROME-FIX-0) — those three keep falling - * back to the leak-safe provider label in `modelMenuGroups`. Callers - * populate this field; `@maka/ui` doesn't know about `LlmConnection` and - * can't enforce the guard itself. - */ - connectionName?: string; -} - -/** - * Short, leak-safe provider labels for menu headings. UI display copy lives in - * the UI layer (not `@maka/core`). `satisfies` keeps it exhaustive over - * `ProviderType`. models.dev-backed providers fall through to the shared - * registry label so this UI does not become another provider fact table. - */ -const STATIC_PROVIDER_SHORT_LABEL: Partial> = { - anthropic: 'Anthropic', - openai: 'OpenAI', - google: 'Google', - deepseek: 'DeepSeek', - moonshot: 'Moonshot', - ollama: 'Ollama', - 'kimi-coding-plan': 'Kimi', - 'zai-coding-plan': 'Z.AI', - MiniMax: 'MiniMax', - 'openai-codex': 'OpenAI OAuth', - 'gemini-cli': 'Gemini CLI', -}; +export type { ChatModelChoice } from '@maka/core'; export interface ModelMenuGroup { connectionSlug: string; @@ -89,13 +51,12 @@ export interface ModelMenuGroup { */ export function modelMenuGroups(choices: ChatModelChoice[], locale: UiLocale = 'zh'): ModelMenuGroup[] { const copy = getSharedUiCopy(locale).providers; - const providerShortLabel: Partial> = { - ...STATIC_PROVIDER_SHORT_LABEL, + const localizedLabels: Partial> = { 'MiniMax-cn': copy.minimaxChina, 'openai-compatible': copy.custom, 'claude-subscription': copy.claudeSubscription, }; - const bySlug = new Map(); + const bySlug = new Map(); for (const choice of choices) { const group = bySlug.get(choice.connectionSlug); if (group) { @@ -104,6 +65,7 @@ export function modelMenuGroups(choices: ChatModelChoice[], locale: UiLocale = ' bySlug.set(choice.connectionSlug, { connectionSlug: choice.connectionSlug, providerType: choice.providerType, + providerLabel: choice.providerLabel, connectionName: choice.connectionName, choices: [choice], }); @@ -131,7 +93,7 @@ export function modelMenuGroups(choices: ChatModelChoice[], locale: UiLocale = ' choices: group.choices, }; } - const label = providerShortLabel[group.providerType] ?? PROVIDER_DEFAULTS[group.providerType].label; + const label = localizedLabels[group.providerType] ?? group.providerLabel; const ambiguous = (connectionsPerType.get(group.providerType) ?? 0) > 1; return { connectionSlug: group.connectionSlug, diff --git a/packages/ui/stories/attachment.stories.tsx b/packages/ui/stories/attachment.stories.tsx index 05d9a5e493..1a61d9aa57 100644 --- a/packages/ui/stories/attachment.stories.tsx +++ b/packages/ui/stories/attachment.stories.tsx @@ -37,7 +37,7 @@ type ComposerProps = ComponentProps; type ChatViewProps = ComponentProps; const modelChoices: ChatModelChoice[] = [ - { connectionSlug: 'anthropic-main', providerType: 'anthropic', model: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, + { connectionSlug: 'anthropic-main', providerType: 'anthropic', providerLabel: 'Anthropic', model: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5', isDefault: true, thinkingLevels: [] }, ]; function noop() { diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx index a9696caaa9..0754d4558c 100644 --- a/packages/ui/stories/model-picker.stories.tsx +++ b/packages/ui/stories/model-picker.stories.tsx @@ -21,19 +21,24 @@ export default meta; type Story = StoryObj; +function choice( + connectionSlug: string, + providerType: ChatModelChoice['providerType'], + providerLabel: string, + model: string, + label: string, +): ChatModelChoice { + return { connectionSlug, providerType, providerLabel, model, label, isDefault: false, thinkingLevels: [] }; +} + const CHOICES: ChatModelChoice[] = [ - { connectionSlug: 'openai-main', providerType: 'openai', model: 'gpt-5', label: 'GPT-5' }, - { connectionSlug: 'openai-main', providerType: 'openai', model: 'gpt-5-mini', label: 'GPT-5 mini' }, - { connectionSlug: 'openai-main', providerType: 'openai', model: 'o3', label: 'o3' }, - { connectionSlug: 'anthropic-team', providerType: 'anthropic', model: 'claude-opus-4-1', label: 'Claude Opus 4.1' }, - { connectionSlug: 'anthropic-team', providerType: 'anthropic', model: 'claude-sonnet-4', label: 'Claude Sonnet 4' }, - { connectionSlug: 'google-lab', providerType: 'google', model: 'gemini-3-pro', label: 'Gemini 3 Pro' }, - { - connectionSlug: 'openrouter', - providerType: 'openai-compatible', - model: 'vendor/a-very-long-model-name-with-reasoning-and-tools-preview', - label: 'A very long model name with reasoning and tools preview', - }, + choice('openai-main', 'openai', 'OpenAI', 'gpt-5', 'GPT-5'), + choice('openai-main', 'openai', 'OpenAI', 'gpt-5-mini', 'GPT-5 mini'), + choice('openai-main', 'openai', 'OpenAI', 'o3', 'o3'), + choice('anthropic-team', 'anthropic', 'Anthropic', 'claude-opus-4-1', 'Claude Opus 4.1'), + choice('anthropic-team', 'anthropic', 'Anthropic', 'claude-sonnet-4', 'Claude Sonnet 4'), + choice('google-lab', 'google', 'Google Gemini', 'gemini-3-pro', 'Gemini 3 Pro'), + choice('openrouter', 'openai-compatible', 'Custom relay', 'vendor/a-very-long-model-name-with-reasoning-and-tools-preview', 'A very long model name with reasoning and tools preview'), ]; // Canonical user-facing ladder when a model offers the common set. From 30c68c9bb3a69336694017630198414f7fcc1e78 Mon Sep 17 00:00:00 2001 From: colafornia Date: Thu, 6 Aug 2026 10:43:47 +0800 Subject: [PATCH 2/2] fix: close first-screen boundary gaps Lock sessions when user messages are appended and migrate legacy unlocked sessions once, keeping read paths pure. --- .../main/__tests__/onboarding-service.test.ts | 42 ++++++++++++ .../provider-firstscreen-contract.test.ts | 63 ++++++++++++++++- apps/desktop/src/renderer/app-shell.tsx | 10 +-- .../src/renderer/use-shell-connections.ts | 12 ---- ...model-metadata-firstscreen-optimization.md | 4 ++ .../src/__tests__/session-store.test.ts | 29 +------- .../sqlite-session-metadata-store.test.ts | 68 +++++++++++++++++++ packages/storage/src/session-store.ts | 28 ++------ .../src/sqlite-session-metadata-schema.ts | 24 ++++++- .../src/sqlite-session-metadata-store.ts | 9 ++- .../__tests__/chat-model-switcher.test.tsx | 30 +++++++- .../__tests__/composer-quiet-chrome.test.tsx | 10 ++- 12 files changed, 253 insertions(+), 76 deletions(-) diff --git a/apps/desktop/src/main/__tests__/onboarding-service.test.ts b/apps/desktop/src/main/__tests__/onboarding-service.test.ts index eeca1825a8..8dca6aa44e 100644 --- a/apps/desktop/src/main/__tests__/onboarding-service.test.ts +++ b/apps/desktop/src/main/__tests__/onboarding-service.test.ts @@ -90,6 +90,48 @@ function fakeDeps(overrides: Partial = {}): OnboardingSer } describe('createOnboardingService.getSnapshot', () => { + it('pins the complete renderer projection for every physical session', async () => { + const service = createOnboardingService(fakeDeps({ + listConnections: async () => [realConnection({ slug: 'a' })], + getDefaultSlug: async () => 'a', + listSessions: async () => [ + session(), + session({ id: 's2', llmConnectionSlug: 'removed' }), + ], + getMilestones: async () => [{ id: 'initial_onboarding', completedAt: 1 }], + hasCredential: async () => true, + })); + + const snapshot = await service.getSnapshot(); + assert.deepEqual({ + chatModelChoices: snapshot.chatModelChoices, + sessionSendOutcomes: snapshot.sessionSendOutcomes, + }, { + chatModelChoices: [{ + connectionSlug: 'a', + providerType: 'anthropic', + providerLabel: 'Anthropic', + model: 'claude-sonnet-4-5-20250929', + label: 'Claude Sonnet 4.5', + connectionName: 'Anthropic Live', + isDefault: true, + thinkingLevels: ['off'], + }], + sessionSendOutcomes: { + s1: { kind: 'ready' }, + s2: { + kind: 'blocked', + reason: 'connection_missing', + connectionLocked: true, + }, + }, + }); + assert.deepEqual( + Object.keys(snapshot.sessionSendOutcomes).sort(), + snapshot.sessions.map((item) => item.id).sort(), + ); + }); + it('returns derived OnboardingState + sanitized milestones together', async () => { const service = createOnboardingService( fakeDeps({ diff --git a/apps/desktop/src/main/__tests__/provider-firstscreen-contract.test.ts b/apps/desktop/src/main/__tests__/provider-firstscreen-contract.test.ts index de6ece4d41..545d95762c 100644 --- a/apps/desktop/src/main/__tests__/provider-firstscreen-contract.test.ts +++ b/apps/desktop/src/main/__tests__/provider-firstscreen-contract.test.ts @@ -1,11 +1,72 @@ import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; import { test } from 'node:test'; -import type { ProviderType } from '@maka/core'; +import { RECOMMENDED_PROVIDER_TYPES, type ProviderType } from '@maka/core'; +import { build } from 'vite'; +import { FIRST_RUN_PROVIDER_TYPES } from '../../renderer/onboarding-provider-types.js'; import { providerDisplay } from '../../renderer/settings/provider-display-copy.js'; +type RendererChunk = { + type: 'chunk'; + fileName: string; + isEntry: boolean; + imports: string[]; + modules: Record; + code: string; +}; + +const FORBIDDEN_STARTUP_MODULES = [ + 'model-metadata.generated', + 'model-metadata', + 'provider-registry', + 'model-catalog', + 'model-thinking', +] as const; + test('unknown providers use their persisted type and generic local copy', () => { assert.deepEqual(providerDisplay('future-provider' as ProviderType, 'en'), { name: 'future-provider', description: 'This provider is not registered in the current build.', }); }); + +test('first-run providers stay aligned with the recommended provider order', () => { + assert.deepEqual(FIRST_RUN_PROVIDER_TYPES, RECOMMENDED_PROVIDER_TYPES.slice(0, 4)); +}); + +test('renderer startup chunks exclude full model metadata', async () => { + const output = await build({ + configFile: resolve(process.cwd(), 'vite.config.ts'), + logLevel: 'silent', + build: { write: false }, + }); + if (Array.isArray(output) || !('output' in output)) { + assert.fail('desktop Vite config must produce one renderer output'); + } + const chunks = output.output.filter((entry) => entry.type === 'chunk') as RendererChunk[]; + const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk])); + const pending = chunks.filter((chunk) => chunk.isEntry); + const startupChunks: RendererChunk[] = []; + const visited = new Set(); + while (pending.length > 0) { + const chunk = pending.pop()!; + if (visited.has(chunk.fileName)) continue; + visited.add(chunk.fileName); + startupChunks.push(chunk); + for (const imported of chunk.imports) { + const dependency = byFileName.get(imported); + if (dependency) pending.push(dependency); + } + } + + const forbiddenModules = startupChunks + .flatMap((chunk) => Object.keys(chunk.modules)) + .filter((moduleId) => FORBIDDEN_STARTUP_MODULES.some((name) => + new RegExp(`(?:^|[/\\\\])${name}\\.[cm]?[jt]sx?$`).test(moduleId) + )); + assert.deepEqual(forbiddenModules, []); + assert.doesNotMatch( + startupChunks.map((chunk) => chunk.code).join('\n'), + /claude-opus|gpt-5\.|gemini-2\./, + ); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 406fe6e3ad..ddd37abbc4 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -651,11 +651,11 @@ function AppShellContent({ }); // Surface a credential-lifecycle alert directly in the chat header when // the active session's connection is in `needs_reauth` / `error` or has - // been deleted entirely with no usable default. We skip the async hasSecret - // fetch here — the composer-adjacent notice is a hard-block surface; - // AccountSettingsPage remains the authoritative detailed view. Model / - // thinking selection + the hard-only health notice live in useShellChatModel - // (pure derivation of the connection list + active session); + // been deleted entirely with no usable default. Main resolves credential + // presence into the onboarding snapshot; a connection event starts an async + // snapshot pull, so the notice keeps the previous outcome only until that + // pull completes. Model / thinking selection + the hard-only health notice + // live in useShellChatModel (pure derivation of the snapshot + active session); // openSettingsSection is injected so the notice can wrap the derived click // target. const { diff --git a/apps/desktop/src/renderer/use-shell-connections.ts b/apps/desktop/src/renderer/use-shell-connections.ts index 34d2100185..ec972ca0d8 100644 --- a/apps/desktop/src/renderer/use-shell-connections.ts +++ b/apps/desktop/src/renderer/use-shell-connections.ts @@ -23,18 +23,9 @@ function connectionsEqual(a: LlmConnection[], b: LlmConnection[]): boolean { * before the first `connections:list` round-trip. `refreshConnections` * dedups via `connectionsEqual` so an unchanged list never churns the * dozen derived model/thinking selectors that read `connections`. - * - * `connectionsRevision` bumps on EVERY successful refresh — even when - * `connectionsEqual` keeps the list identity. A `connection_list_changed` - * event means *something* changed, but not every change bumps `updatedAt` - * (an external credentials.json edit only changes the credential store), - * so the list identity alone cannot tell cheap derived probes (the - * session-health-notice secret probe, #1038 review) that they must - * re-run. The revision can. */ export function useShellConnections(options: { toastApi: ToastApi; uiLocale: UiLocale }): { connections: LlmConnection[]; - connectionsRevision: number; defaultConnection: string | null; setConnections: (updater: LlmConnection[] | ((prev: LlmConnection[]) => LlmConnection[])) => void; setDefaultConnection: (next: string | null) => void; @@ -44,7 +35,6 @@ export function useShellConnections(options: { toastApi: ToastApi; uiLocale: UiL const { toastApi, uiLocale } = options; const copy = getShellRemainingCopy(uiLocale).connections; const [connections, setConnections] = useState([]); - const [connectionsRevision, setConnectionsRevision] = useState(0); const [defaultConnection, setDefaultConnection] = useState(null); async function refreshConnections() { @@ -55,7 +45,6 @@ export function useShellConnections(options: { toastApi: ToastApi; uiLocale: UiL ]); setConnections((prev) => connectionsEqual(prev, next) ? prev : next); setDefaultConnection(nextDefault); - setConnectionsRevision((revision) => revision + 1); } catch (error) { toastApi.error(copy.refreshFailed, localizedShellErrorMessage(error, copy.refreshFallback, uiLocale)); } @@ -71,7 +60,6 @@ export function useShellConnections(options: { toastApi: ToastApi; uiLocale: UiL return { connections, - connectionsRevision, defaultConnection, setConnections, setDefaultConnection, diff --git a/docs/model-metadata-firstscreen-optimization.md b/docs/model-metadata-firstscreen-optimization.md index 83392e632c..9043ae02c2 100644 --- a/docs/model-metadata-firstscreen-optimization.md +++ b/docs/model-metadata-firstscreen-optimization.md @@ -43,6 +43,8 @@ Reuse the existing `onboarding:getSnapshot` path. The main process already loads The renderer consumes this projection instead of reading the model catalog or metadata at startup. Connection changes continue to use the existing `connections:event → onboarding snapshot refresh` flow; no new IPC channel is needed. +The session health notice uses the last completed snapshot while an event-triggered refresh is in flight, then updates from that pull; it does not wait for another invalidation cycle. Credential lookup failures are projected conservatively as `hasSecret: false`. This replaces the renderer's former optimistic `true` fallback on probe errors, so an unreadable credential surfaces the existing repair path instead of hiding a likely send failure. + Remove the remaining provider-registry dependencies from the startup path: - `modelMenuGroups` receives the required label from the startup projection instead of reading `PROVIDER_DEFAULTS`. @@ -114,6 +116,8 @@ Acceptance criteria: Renderer 使用 snapshot 数据渲染首屏,不再自行读取 model catalog 或 model metadata。connection 发生变化时,继续复用现有 `connections:event → onboarding snapshot refresh` 更新投影,不新增 IPC channel。 +Session health notice 在 event 触发的异步刷新完成前继续使用上一份 snapshot,当前 pull 返回后立即更新,不需要再等下一轮 invalidation。Credential lookup 失败时会保守投影为 `hasSecret: false`;这取代了 renderer 旧逻辑在 probe 报错时乐观返回 `true` 的行为,使凭据无法读取时进入已有修复路径,而不是隐藏一次很可能失败的发送。 + 同时切断其余 provider registry 依赖: - `modelMenuGroups` 从首屏投影获取所需 label,不再直接读取 `PROVIDER_DEFAULTS`。 diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index 2b6f4b500d..b500730d7d 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -42,7 +42,7 @@ describe('SQLite SessionStore', () => { } }); - test('list self-heals a legacy unlocked session after its first user message', async () => { + test('appending the first user message locks the session before any read', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-session-lock-heal-')); const store = createSessionStore(root); try { @@ -54,33 +54,6 @@ describe('SQLite SessionStore', () => { ts: 10, text: 'legacy message', }); - await store.appendMessages( - session.id, - Array.from({ length: 10 }, (_, index) => ({ - type: 'assistant', - id: `legacy-assistant-${index}`, - turnId: `legacy-turn-${index}`, - ts: 11 + index, - text: 'reply', - modelId: 'fake-model', - })), - ); - for (let index = 0; index < 3; index += 1) { - const newer = await store.create(makeInput({ name: `Newer ${index}` })); - await store.appendMessage(newer.id, { - type: 'assistant', - id: `newer-assistant-${index}`, - turnId: `newer-turn-${index}`, - ts: 30 + index, - text: 'newer reply', - modelId: 'fake-model', - }); - } - - assert.equal( - (await store.list()).find((listed) => listed.id === session.id)?.connectionLocked, - true, - ); assert.equal((await store.readHeaderSnapshot(session.id)).connectionLocked, true); } finally { await store.close?.(); diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 5f5938badf..ea97ee4f77 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -29,6 +29,74 @@ import { import { SQLITE_AGENT_GRAPH_CONTROL_TABLES } from '../sqlite-session-metadata-schema.js'; describe('SqliteSessionMetadataStore', () => { + test('migration locks only legacy sessions that already contain a user message', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-lock-migration-')); + const path = join(root, 'state.sqlite'); + try { + const store = createSqliteSessionMetadataStore(path); + await store.create(fullHeader({ id: 'with-user', connectionLocked: false })); + await store.create(fullHeader({ id: 'assistant-only', connectionLocked: false })); + store.close(); + + const legacy = new DatabaseSync(path); + legacy + .prepare(` + INSERT INTO session_messages( + session_id, sequence, message_id, message_type, message_ts, record_json + ) VALUES (?, 0, ?, ?, 1, ?) + `) + .run( + 'with-user', + 'user-1', + 'user', + JSON.stringify({ + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 1, + text: 'hello', + }), + ); + legacy + .prepare(` + INSERT INTO session_messages( + session_id, sequence, message_id, message_type, message_ts, record_json + ) VALUES (?, 0, ?, ?, 1, ?) + `) + .run( + 'assistant-only', + 'assistant-1', + 'assistant', + JSON.stringify({ + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 1, + text: 'preview', + modelId: 'fake-model', + }), + ); + legacy + .prepare(` + UPDATE session_metadata_schema + SET version = ? + WHERE scope = 'session_metadata' + `) + .run(21); + legacy.close(); + + const migrated = createSqliteSessionMetadataStore(path); + try { + assert.equal((await migrated.read('with-user')).header.connectionLocked, true); + assert.equal((await migrated.read('assistant-only')).header.connectionLocked, false); + } finally { + migrated.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test('round-trips every SessionHeader field and reopens the same schema', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-session-metadata-')); const path = join(root, 'state.sqlite'); diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 8568e97b9f..98e785e8c0 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -481,14 +481,10 @@ class SqliteSessionStore implements SessionAuthorityStore { const summaries: SessionSummary[] = []; for (let index = 0; index < withPreviews.length; index += 1) { const { record, previewMessages } = withPreviews[index]!; - let { header } = record; + const { header } = record; let messages = previewMessages.slice(-10); - if (index < 3 || (!header.connectionLocked && previewMessages.length > 0)) { - const storedMessages = await this.metadata.readMessages(header.id); - if (!header.connectionLocked) { - header = await this.lockConnectionAfterFirstUserMessage(header, storedMessages); - } - if (index < 3) messages = storedMessages.slice(-10); + if (index < 3) { + messages = (await this.metadata.readMessages(header.id)).slice(-10); } summaries.push(toSummary(header, messages)); } @@ -576,15 +572,11 @@ class SqliteSessionStore implements SessionAuthorityStore { } async readHeader(sessionId: string): Promise { - const header = await this.readHeaderSnapshot(sessionId); - return this.lockConnectionAfterFirstUserMessage(header); + return this.readHeaderSnapshot(sessionId); } async readMessages(sessionId: string): Promise { - const messages = await this.readMessagesSnapshot(sessionId); - const header = (await this.metadata.read(sessionId)).header; - await this.lockConnectionAfterFirstUserMessage(header, messages); - return messages; + return this.readMessagesSnapshot(sessionId); } async listTurns(sessionId: string): Promise { @@ -773,16 +765,6 @@ class SqliteSessionStore implements SessionAuthorityStore { this.metadata.close(); } - private async lockConnectionAfterFirstUserMessage( - header: SessionHeader, - knownMessages?: StoredMessage[], - ): Promise { - if (header.connectionLocked) return header; - const messages = knownMessages ?? (await this.metadata.readMessages(header.id)); - if (!messages.some((message) => message.type === 'user')) return header; - return this.updateHeader(header.id, { connectionLocked: true }); - } - private async ensureReady(): Promise {} private async ensureCatalogProjectionReadable(): Promise { diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index a9b9282bcf..ff520c66c5 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 21; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 22; export const SQLITE_AGENT_GRAPH_CONTROL_TABLES = [ 'agent_graph_intent_claims', @@ -828,6 +828,28 @@ const MIGRATIONS: ReadonlyMap = new Map([ ON project_aliases(project_id, alias); `, ], + [ + 22, + ` + UPDATE session_metadata + SET + payload_json = json_set(payload_json, '$.connectionLocked', json('true')), + metadata_version = metadata_version + 1, + committed_at = MAX( + committed_at, + CAST(strftime('%s', 'now') AS INTEGER) * 1000 + ) + WHERE + json_extract(payload_json, '$.connectionLocked') = 0 + AND EXISTS ( + SELECT 1 + FROM session_messages messages + WHERE + messages.session_id = session_metadata.session_id + AND messages.message_type = 'user' + ); + `, + ], ]); export function configureSqliteSessionMetadataDatabase(db: DatabaseSync): void { diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index fa05e88d35..6402e4e41b 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -1269,7 +1269,10 @@ export class SqliteSessionMetadataStore { return { message: canonical, json }; }); this.transaction(() => { - if (!this.readRecordSync(sessionId)) throw new SessionNotFoundError(sessionId); + const record = this.readRecordSync(sessionId); + if (!record) throw new SessionNotFoundError(sessionId); + const lockConnection = + !record.header.connectionLocked && encoded.some(({ message }) => message.type === 'user'); const row = this.db .prepare( 'SELECT COALESCE(MAX(sequence), -1) AS last_sequence FROM session_messages WHERE session_id = ?', @@ -1299,7 +1302,7 @@ export class SqliteSessionMetadataStore { ); sequence += 1; } - this.updateCatalogProjectionSync(sessionId, projection, false); + this.updateCatalogProjectionSync(sessionId, projection, false, lockConnection); }); } @@ -3111,6 +3114,7 @@ export class SqliteSessionMetadataStore { sessionId: string, projection: SessionCatalogMessageProjection, replacePreview: boolean, + lockConnection = false, ): void { const current = this.readRecordSync(sessionId); if (!current) throw new SessionNotFoundError(sessionId); @@ -3118,6 +3122,7 @@ export class SqliteSessionMetadataStore { this.updateHeaderSync( sessionId, { + ...(lockConnection ? { connectionLocked: true } : {}), ...(lastMessageAt === undefined ? {} : { lastMessageAt }), }, { diff --git a/packages/ui/src/__tests__/chat-model-switcher.test.tsx b/packages/ui/src/__tests__/chat-model-switcher.test.tsx index 188dd3c889..579c9bddee 100644 --- a/packages/ui/src/__tests__/chat-model-switcher.test.tsx +++ b/packages/ui/src/__tests__/chat-model-switcher.test.tsx @@ -27,9 +27,33 @@ const SESSION: SessionSummary = { }; const CHOICES: ChatModelChoice[] = [ - { connectionSlug: 'anthropic-main', providerType: 'anthropic', model: 'claude-opus-4-1', label: 'Claude Opus 4.1' }, - { connectionSlug: 'anthropic-main', providerType: 'anthropic', model: 'claude-sonnet-4', label: 'Claude Sonnet 4' }, - { connectionSlug: 'openai-main', providerType: 'openai', model: 'gpt-5', label: 'GPT-5' }, + { + connectionSlug: 'anthropic-main', + providerType: 'anthropic', + providerLabel: 'Anthropic', + model: 'claude-opus-4-1', + label: 'Claude Opus 4.1', + isDefault: false, + thinkingLevels: [], + }, + { + connectionSlug: 'anthropic-main', + providerType: 'anthropic', + providerLabel: 'Anthropic', + model: 'claude-sonnet-4', + label: 'Claude Sonnet 4', + isDefault: true, + thinkingLevels: ['off', 'high'], + }, + { + connectionSlug: 'openai-main', + providerType: 'openai', + providerLabel: 'OpenAI', + model: 'gpt-5', + label: 'GPT-5', + isDefault: true, + thinkingLevels: [], + }, ]; describe('ChatModelSwitcher menu', () => { diff --git a/packages/ui/src/__tests__/composer-quiet-chrome.test.tsx b/packages/ui/src/__tests__/composer-quiet-chrome.test.tsx index 77d0ee7acf..7eee834ab6 100644 --- a/packages/ui/src/__tests__/composer-quiet-chrome.test.tsx +++ b/packages/ui/src/__tests__/composer-quiet-chrome.test.tsx @@ -131,7 +131,15 @@ describe('composer quiet chrome', () => { permissionMode: 'ask' as const, }; const choices = [ - { connectionSlug: 'fake', providerType: 'anthropic' as const, model: 'fake', label: 'fake' }, + { + connectionSlug: 'fake', + providerType: 'anthropic' as const, + providerLabel: 'Anthropic', + model: 'fake', + label: 'fake', + isDefault: true, + thinkingLevels: ['off', 'high'] as const, + }, ]; const thinkingProps = { activeThinkingLevels: ['off', 'high'] as ('off' | 'high')[],