diff --git a/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts b/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts index e99e0107f..7977372f2 100644 --- a/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts +++ b/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts @@ -25,6 +25,8 @@ export interface AccountSessionRepository { activateByCloudUuid(cloudUuid: string, accountChannel?: AccountChannel): boolean; upsert(input: UpsertAccountSessionInput): AccountSessionView; clear(): void; + /** Clears only when the expected cloud credential still belongs to the active session. */ + clearIfCloudUuid(cloudUuid: string): boolean; getLastCodeSentAt(key: string): string | null; markCodeSent(key: string, at: string): void; } @@ -216,6 +218,12 @@ export function createAccountSessionRepository(db: DatabaseSync, secretStore: Se setActiveAccountUuid(db, null); }, + clearIfCloudUuid(cloudUuid) { + if (getCloudUuidFromRow(secretStore, getActiveAccountRow(db)) !== cloudUuid) return false; + setActiveAccountUuid(db, null); + return true; + }, + getLastCodeSentAt(key) { const throttleKey = toThrottleKey(key); const row = db diff --git a/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts b/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts index e8eec9def..3f8587b3b 100644 --- a/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts +++ b/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts @@ -269,6 +269,33 @@ describe("account session repository", () => { expect(fabricatedAccount).toBeUndefined(); }); + it("clears only the session whose cloud uuid is still active", () => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-account-session-")); + const store = createAppStateStore({ databasePath: join(tempDir, "app.sqlite") }); + store.repositories.accountSession.upsert({ + profile: { + userId: "user-1", + email: "hello@example.com", + phoneNumber: null, + nickname: "hello", + avatarUrl: null, + planType: "free", + hasFinishedGuide: false, + region: null, + registeredAt: null, + rawProfile: { id: "user-1" } + }, + uuid: "cloud-account-a", + cloudUuid: "cloud.login.uuid.a" + }); + + expect(store.repositories.accountSession.clearIfCloudUuid("cloud.login.uuid.b")).toBe(false); + expect(store.repositories.accountSession.get()).toMatchObject({ authenticated: true }); + expect(store.repositories.accountSession.clearIfCloudUuid("cloud.login.uuid.a")).toBe(true); + expect(store.repositories.accountSession.get()).toEqual({ authenticated: false }); + store.close(); + }); + it("infers a legacy login channel only from one unambiguous bound contact", () => { tempDir = mkdtempSync(join(tmpdir(), "memmy-account-session-")); const store = createAppStateStore({ databasePath: join(tempDir, "app.sqlite") }); diff --git a/App/backend/src/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index 8cc5b5495..43a410866 100644 --- a/App/backend/src/infrastructure/memmy-config/index.ts +++ b/App/backend/src/infrastructure/memmy-config/index.ts @@ -36,6 +36,7 @@ import { normalizeTimeZoneOffset } from "../../utils/time-zone.js"; const MEMMY_ACCOUNT_PROVIDER = "memmy_account"; const MEMMY_ACCOUNT_MODEL = "agent_chat"; const MEMMY_ACCOUNT_IMAGE_MODEL = "image_gen"; +const LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE = "accountByokLocalSelectionBaseline"; const ACCOUNT_MODELS = { agent: MEMMY_ACCOUNT_MODEL, memory_summary: "memory_summary", @@ -130,7 +131,12 @@ export interface MemmyConfigWriter { /** * Clear the account-mode runtime login projection. */ - clearAccountModelProjection?(input?: { ownerAccountId?: string; force?: boolean }): Promise; + clearAccountModelProjection?(input?: { + ownerAccountId?: string; + force?: boolean; + syncSelectedByokToLocal?: boolean; + expectedCloudUuid?: string; + }): Promise; /** * Patch a single memmy-agent channel config. @@ -519,6 +525,7 @@ export async function writeAccountModelProjectionToMemmyConfig( const appConfig = isRecord(config.app) ? { ...config.app } : {}; if (normalizedCloudUuid) appConfig.cloudUuid = normalizedCloudUuid; if (normalizedUserId) appConfig.userId = normalizedUserId; + delete appConfig[LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE]; setAppConfig(config, appConfig); delete config.uuid; delete config.identity; @@ -594,9 +601,15 @@ export async function writeAccountModelProjectionToMemmyConfig( */ export async function clearAccountModelProjectionFromMemmyConfig( configPath = resolveDefaultMemmyConfigPath(), - input: { ownerAccountId?: string; force?: boolean } = {} + input: { + ownerAccountId?: string; + force?: boolean; + syncSelectedByokToLocal?: boolean; + expectedCloudUuid?: string; + } = {} ): Promise { const requestedOwnerAccountId = input.ownerAccountId?.trim(); + const expectedCloudUuid = input.expectedCloudUuid?.trim(); const result = await mutateRuntimeConfig(configPath, (config) => { const appConfig = isRecord(config.app) ? { ...config.app } : {}; const providers = isRecord(config.providers) ? { ...config.providers } : {}; @@ -604,6 +617,7 @@ export async function clearAccountModelProjectionFromMemmyConfig( if (input.force) { delete appConfig.cloudUuid; delete appConfig.userId; + delete appConfig[LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE]; setAppConfig(config, appConfig); delete config.uuid; delete config.identity; @@ -620,10 +634,19 @@ export async function clearAccountModelProjectionFromMemmyConfig( config.modelAssignments = assignments; return { memoryConfigAffected: false }; } + const currentCloudUuid = existingString(appConfig.cloudUuid) ?? existingString(accountProvider?.apiKey); + if (expectedCloudUuid && currentCloudUuid !== expectedCloudUuid) { + return { memoryConfigAffected: false }; + } const ownerAccountId = requestedOwnerAccountId ?? existingString(appConfig.userId) ?? existingString(accountProvider?.ownerAccountId); if (!ownerAccountId) return { memoryConfigAffected: false }; + delete appConfig[LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE]; + + if (input.syncSelectedByokToLocal) { + syncSelectedAccountByokCandidatesToLocal(config, ownerAccountId); + } if (!existingString(appConfig.userId) || appConfig.userId === ownerAccountId) { delete appConfig.cloudUuid; @@ -732,10 +755,17 @@ function updateAccountAssignment( const currentCandidates = Array.isArray(agent.candidates) ? agent.candidates.filter((value): value is string => typeof value === "string") : []; - const candidates = currentCandidates.filter((presetId) => assignmentPresetIsUsable( - presets, presetId, "agent", ownerAccountId - )); - if (!candidates.includes(presetIds.agent)) candidates.push(presetIds.agent); + const platformCandidates = [...new Set(currentCandidates.filter((presetId) => { + const preset = asRecord(presets[presetId]); + return preset?.source === "account" + && assignmentPresetIsUsable(presets, presetId, "agent", ownerAccountId); + }))]; + if (!platformCandidates.includes(presetIds.agent)) platformCandidates.push(presetIds.agent); + + const byok = isRecord(assignments.byok) ? assignments.byok : {}; + const byokAgent = isRecord(byok.agent) ? byok.agent : {}; + const localCandidates = selectedUsableByokAgentCandidates(byokAgent, presets, ownerAccountId); + const candidates = [...platformCandidates, ...localCandidates]; const currentDefault = existingString(agent.default); agent.candidates = candidates; agent.default = currentDefault && candidates.includes(currentDefault) ? currentDefault : presetIds.agent; @@ -758,6 +788,43 @@ function updateAccountAssignment( config.modelAssignments = assignments; } +function syncSelectedAccountByokCandidatesToLocal( + config: Record, + ownerAccountId: string +): void { + const assignments = isRecord(config.modelAssignments) ? { ...config.modelAssignments } : {}; + const account = isRecord(assignments.account) ? assignments.account : {}; + if (existingString(account.ownerAccountId) !== ownerAccountId) return; + + const presets = isRecord(config.modelPresets) ? config.modelPresets : {}; + const accountAgent = isRecord(account.agent) ? account.agent : {}; + const selectedByokCandidates = selectedUsableByokAgentCandidates(accountAgent, presets, ownerAccountId); + + const byok = isRecord(assignments.byok) ? { ...assignments.byok } : {}; + const byokAgent = isRecord(byok.agent) ? { ...byok.agent } : {}; + if (selectedByokCandidates.length === 0) return; + + const localDefault = existingString(byokAgent.default); + byokAgent.candidates = selectedByokCandidates; + byokAgent.default = localDefault && selectedByokCandidates.includes(localDefault) + ? localDefault + : selectedByokCandidates[0]; + byok.agent = byokAgent; + assignments.byok = byok; + config.modelAssignments = assignments; +} + +function selectedUsableByokAgentCandidates( + agent: Record, + presets: Record, + ownerAccountId: string +): string[] { + return [...new Set((Array.isArray(agent.candidates) ? agent.candidates : []) + .filter((value): value is string => typeof value === "string") + .filter((presetId) => asRecord(presets[presetId])?.source === "byok" + && assignmentPresetIsUsable(presets, presetId, "agent", ownerAccountId)))]; +} + function assignmentPresetIsUsable( presets: Record, presetId: string, diff --git a/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts b/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts index 24b892435..77158b8a9 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts @@ -6,6 +6,8 @@ import YAML from "yaml"; import { afterEach, describe, expect, it } from "vitest"; import { clearAccountModelProjectionFromMemmyConfig, + readModelConfigCatalog, + writeModelConfigCatalog, writeAccountModelProjectionToMemmyConfig } from "../index.js"; @@ -48,6 +50,12 @@ function currentByokCatalog(): Record { byokAgent: { provider: "openai", endpoint: "chat", model: "gpt-5", source: "byok", capabilities: ["agent"] }, + byokAgent2: { + provider: "openai", endpoint: "chat", model: "gpt-5.1", source: "byok", capabilities: ["agent"] + }, + byokUnchecked: { + provider: "openai", endpoint: "chat", model: "gpt-4.1", source: "byok", capabilities: ["agent"] + }, byokSummary: { provider: "openai", endpoint: "chat", model: "gpt-5-mini", source: "byok", capabilities: ["memory_summary"] } @@ -112,7 +120,7 @@ describe("account model projection current catalog", () => { expect(saved.modelAssignments.account).toMatchObject({ ownerAccountId: "owner-a", agent: { - candidates: ["byokAgent", accountId("owner-a", "agent")], + candidates: [accountId("owner-a", "agent"), "byokAgent"], default: "byokAgent" }, memorySummary: "byokSummary", @@ -123,6 +131,47 @@ describe("account model projection current catalog", () => { expect(saved.providers.openai.futureProviderField).toBe("keep-provider"); }); + it("synchronizes the account BYOK candidates from the current local selection on every login", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent2", "byokAgent2", "byokAgent"], + default: "byokAgent2" + }; + initial.modelAssignments.account.agent = { + candidates: ["byokUnchecked", "byokAgent"], + default: "byokUnchecked" + }; + const file = await configFile(initial); + const beforeByok = (await readConfig(file)).modelAssignments.byok; + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const afterFirstLogin = await readConfig(file); + expect(afterFirstLogin.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokAgent2", "byokAgent"], + default: accountId("owner-a", "agent") + }); + expect(afterFirstLogin.modelAssignments.byok).toEqual(beforeByok); + + afterFirstLogin.modelAssignments.byok.agent = { + candidates: ["byokUnchecked"], + default: "byokUnchecked" + }; + await writeFile(file, YAML.stringify(afterFirstLogin), "utf8"); + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const afterSecondLogin = await readConfig(file); + expect(afterSecondLogin.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokUnchecked"], + default: accountId("owner-a", "agent") + }); + expect(afterSecondLogin.modelAssignments.byok.agent).toEqual({ + candidates: ["byokUnchecked"], + default: "byokUnchecked" + }); + }); + it("switches owners without reviving the previous owner's platform definitions", async () => { const file = await configFile(currentByokCatalog()); await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); @@ -157,6 +206,245 @@ describe("account model projection current catalog", () => { expect(after.app?.userId).toBeUndefined(); }); + it("manual logout synchronizes every selected account BYOK candidate back to local mode", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent"], + default: "byokAgent" + }; + initial.app = { + accountByokLocalSelectionBaseline: { + ownerAccountId: "owner-a", + candidates: ["byokAgent"] + } + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [ + accountId("owner-a", "agent"), + "byokAgent2", + "byokAgent2", + "byokAgent" + ], + default: "byokAgent2" + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + + const loggedOut = await readConfig(file); + expect(loggedOut.modelAssignments.byok.agent).toEqual({ + candidates: ["byokAgent2", "byokAgent"], + default: "byokAgent" + }); + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + const loggedInAgain = await readConfig(file); + expect(loggedInAgain.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokAgent2", "byokAgent"], + default: "byokAgent2" + }); + }); + + it("synchronizes the logout fallback into account mode after all account BYOK models were cleared", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent"], + default: "byokAgent" + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [accountId("owner-a", "agent")], + default: accountId("owner-a", "agent") + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + + const loggedOut = await readConfig(file); + expect(loggedOut.modelAssignments.byok.agent).toEqual({ + candidates: ["byokAgent"], + default: "byokAgent" + }); + expect(loggedOut.app?.accountByokLocalSelectionBaseline).toBeUndefined(); + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + const loggedInAgain = await readConfig(file); + expect(loggedInAgain.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokAgent"], + default: accountId("owner-a", "agent") + }); + + await expect(writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file)) + .resolves.toEqual({ changed: false, memoryConfigAffected: false }); + expect((await readConfig(file)).modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokAgent"], + default: accountId("owner-a", "agent") + }); + }); + + it("synchronizes a local selection changed after an account selected no BYOK model", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent2", "byokAgent"], + default: "byokAgent2" + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [accountId("owner-a", "agent")], + default: accountId("owner-a", "agent") + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + const localView = await readModelConfigCatalog(file); + const locallyChangedAssignments = structuredClone(localView.modelAssignments); + locallyChangedAssignments.byok.agent = { + candidates: ["byokUnchecked"], + default: "byokUnchecked" + }; + await writeModelConfigCatalog(file, { + configRevision: localView.configRevision, + providers: localView.providers, + modelAssignments: locallyChangedAssignments + }); + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedInAgain = await readConfig(file); + expect(loggedInAgain.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent"), "byokUnchecked"], + default: accountId("owner-a", "agent") + }); + }); + + it("synchronizes a local selection cleared after an account selected no BYOK model", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent2", "byokAgent"], + default: "byokAgent2" + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [accountId("owner-a", "agent")], + default: accountId("owner-a", "agent") + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + + const localView = await readModelConfigCatalog(file); + const locallyChangedAssignments = structuredClone(localView.modelAssignments); + locallyChangedAssignments.byok.agent = { candidates: [], default: null }; + await writeModelConfigCatalog(file, { + configRevision: localView.configRevision, + providers: localView.providers, + modelAssignments: locallyChangedAssignments + }); + + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedInAgain = await readConfig(file); + expect(loggedInAgain.modelAssignments.byok.agent).toEqual({ candidates: [], default: null }); + expect(loggedInAgain.modelAssignments.account.agent).toEqual({ + candidates: [accountId("owner-a", "agent")], + default: accountId("owner-a", "agent") + }); + }); + + it("manual logout keeps a still-selected local default when the account default is platform", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokAgent", "byokUnchecked"], + default: "byokAgent" + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [accountId("owner-a", "agent"), "byokAgent2", "byokAgent"], + default: accountId("owner-a", "agent") + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + + expect((await readConfig(file)).modelAssignments.byok.agent).toEqual({ + candidates: ["byokAgent2", "byokAgent"], + default: "byokAgent" + }); + }); + + it("manual logout falls back to the first selected local model when neither prior default remains", async () => { + const initial = currentByokCatalog() as any; + initial.modelAssignments.byok.agent = { + candidates: ["byokUnchecked"], + default: "byokUnchecked" + }; + const file = await configFile(initial); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-a", userId: "owner-a" }, file); + + const loggedIn = await readConfig(file); + loggedIn.modelAssignments.account.agent = { + candidates: [accountId("owner-a", "agent"), "byokAgent2", "byokAgent"], + default: accountId("owner-a", "agent") + }; + await writeFile(file, YAML.stringify(loggedIn), "utf8"); + + await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true + }); + + expect((await readConfig(file)).modelAssignments.byok.agent).toEqual({ + candidates: ["byokAgent2", "byokAgent"], + default: "byokAgent2" + }); + }); + + it("does not clear or synchronize a newer projection for the same account", async () => { + const file = await configFile(currentByokCatalog()); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-old", userId: "owner-a" }, file); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "token-new", userId: "owner-a" }, file); + const beforeLateLogout = await readConfig(file); + + const result = await clearAccountModelProjectionFromMemmyConfig(file, { + ownerAccountId: "owner-a", + syncSelectedByokToLocal: true, + expectedCloudUuid: "token-old" + }); + + expect(result).toEqual({ changed: false, memoryConfigAffected: false }); + expect(await readConfig(file)).toEqual(beforeLateLogout); + }); + it("does not expose the account identifier in deterministic preset IDs", async () => { const file = await configFile({}); await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "secret-token", userId: "person@example.test" }, file); diff --git a/App/backend/src/services/account-service.ts b/App/backend/src/services/account-service.ts index 61d8f4bf2..553ae28f3 100644 --- a/App/backend/src/services/account-service.ts +++ b/App/backend/src/services/account-service.ts @@ -185,20 +185,26 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco await clearLocalAccountState( options, - session.authenticated ? session.profile.userId : undefined + session.authenticated ? session.profile.userId : undefined, + true, + uuid ?? undefined ); return { ok: true }; }, async getSession() { const session = AccountSessionViewSchema.parse(options.accountSessionRepository.get()); + const cloudUuid = session.authenticated ? options.accountSessionRepository.getCloudUuid() : null; return refreshCloudGuideState({ cloudClient: options.cloudClient, accountSessionRepository: options.accountSessionRepository, session, + cloudUuid: cloudUuid ?? undefined, onAuthenticationInvalid: () => clearLocalAccountState( options, - session.authenticated ? session.profile.userId : undefined + session.authenticated ? session.profile.userId : undefined, + false, + cloudUuid ?? undefined ) }); } @@ -232,10 +238,20 @@ async function reloadMemoryConfigIfNeeded( async function clearLocalAccountState( options: CreateAccountServiceOptions, - ownerAccountId?: string + ownerAccountId?: string, + syncSelectedByokToLocal = false, + expectedCloudUuid?: string ): Promise { - const projection = await options.memmyConfigWriter?.clearAccountModelProjection?.({ ownerAccountId }); - options.accountSessionRepository.clear(); + const projection = await options.memmyConfigWriter?.clearAccountModelProjection?.({ + ownerAccountId, + syncSelectedByokToLocal, + expectedCloudUuid + }); + if (expectedCloudUuid) { + options.accountSessionRepository.clearIfCloudUuid(expectedCloudUuid); + } else { + options.accountSessionRepository.clear(); + } await reloadMemoryConfigIfNeeded(projection, options); } diff --git a/App/backend/src/services/runtime-config-sync-service.ts b/App/backend/src/services/runtime-config-sync-service.ts index 233236e7c..a329cb311 100644 --- a/App/backend/src/services/runtime-config-sync-service.ts +++ b/App/backend/src/services/runtime-config-sync-service.ts @@ -263,6 +263,10 @@ async function hydrateAccountRuntimeConfig( reason: "account_projection_has_no_matching_local_session" }; } + const projection = await writeAccountModelProjectionToMemmyConfig({ + cloudUuid: state.cloudUuid, + userId: session.profile.userId + }, options.memmyConfigPath); appStateStore.repositories.bootstrap.updateAppSettings({ userMode: "account" }); return { source: "runtime_config", @@ -270,8 +274,10 @@ async function hydrateAccountRuntimeConfig( provider: "memmy_account", model: "agent_chat", hydratedAppState: true, - wroteConfig: false, - reason: "hydrated_account_from_runtime_config" + wroteConfig: projection.changed, + reason: projection.changed + ? "refreshed_account_projection_and_hydrated_account" + : "hydrated_account_from_runtime_config" }; } diff --git a/App/backend/src/services/tests/account-service.test.ts b/App/backend/src/services/tests/account-service.test.ts index 3465bf814..60a78bd56 100644 --- a/App/backend/src/services/tests/account-service.test.ts +++ b/App/backend/src/services/tests/account-service.test.ts @@ -573,6 +573,10 @@ describe("AccountService", () => { }, clear() { calls.push("clear"); + }, + clearIfCloudUuid(cloudUuid) { + calls.push(`clear-if:${cloudUuid}`); + return true; } }, memmyConfigWriter: { @@ -580,8 +584,10 @@ describe("AccountService", () => { calls.push("write-account"); return projectionResult(); }, - async clearAccountModelProjection() { - calls.push("clear-account-config"); + async clearAccountModelProjection(input) { + calls.push( + `clear-account-config:${input.syncSelectedByokToLocal ?? false}:${input.expectedCloudUuid ?? "none"}` + ); return projectionResult(); }, async writeByokModelProjection() { @@ -599,7 +605,72 @@ describe("AccountService", () => { }); await expect(service.logout()).resolves.toEqual({ ok: true }); - expect(calls).toEqual(["cloud-logout:cloud.login.uuid", "clear-account-config", "clear"]); + expect(calls).toEqual([ + "cloud-logout:cloud.login.uuid", + "clear-account-config:true:cloud.login.uuid", + "clear-if:cloud.login.uuid" + ]); + }); + + it("does not clear a newer account session when an older manual logout finishes late", async () => { + const calls: string[] = []; + let activeCloudUuid: string | null = "cloud.login.uuid"; + let releaseLogout: () => void = () => undefined; + const logoutGate = new Promise((resolve) => { + releaseLogout = resolve; + }); + const service = createAccountService({ + cloudClient: { + ...createCloudClientStub(), + async logout() { + calls.push("cloud-logout"); + await logoutGate; + } + }, + accountSessionRepository: { + ...createAccountSessionRepositoryStub(), + getCloudUuid() { + return activeCloudUuid; + }, + clearIfCloudUuid(cloudUuid) { + calls.push(`clear-if:${cloudUuid}`); + if (activeCloudUuid !== cloudUuid) return false; + activeCloudUuid = null; + return true; + } + }, + memmyConfigWriter: { + async writeAccountModelProjection() { + return projectionResult(); + }, + async clearAccountModelProjection(input) { + calls.push(`clear-account-config:${input.expectedCloudUuid ?? "none"}`); + return projectionResult(); + }, + async writeByokModelProjection() { + return projectionResult(); + }, + async writeActiveMemoryProfile() { + return projectionResult(); + }, + async patchChannelConfig() { + return undefined; + } + } + }); + + const logout = service.logout(); + await new Promise((resolve) => setImmediate(resolve)); + activeCloudUuid = "cloud.new.uuid"; + releaseLogout(); + await expect(logout).resolves.toEqual({ ok: true }); + + expect(activeCloudUuid).toBe("cloud.new.uuid"); + expect(calls).toEqual([ + "cloud-logout", + "clear-account-config:cloud.login.uuid", + "clear-if:cloud.login.uuid" + ]); }); it("clears the owner-scoped account projection when cloud authentication expires", async () => { @@ -635,6 +706,10 @@ describe("AccountService", () => { }, clear() { calls.push("clear-session"); + }, + clearIfCloudUuid(cloudUuid) { + calls.push(`clear-session-if:${cloudUuid}`); + return true; } }, memmyConfigWriter: { @@ -642,7 +717,10 @@ describe("AccountService", () => { return projectionResult(); }, async clearAccountModelProjection(input) { - calls.push(`clear-account-config:${input.ownerAccountId ?? "none"}`); + calls.push( + `clear-account-config:${input.ownerAccountId ?? "none"}:${input.syncSelectedByokToLocal ?? false}` + + `:${input.expectedCloudUuid ?? "none"}` + ); return projectionResult(); }, async writeByokModelProjection() { @@ -661,7 +739,10 @@ describe("AccountService", () => { message: "session expired", code: "unauthorized" }); - expect(calls).toEqual(["clear-account-config:user-1", "clear-session"]); + expect(calls).toEqual([ + "clear-account-config:user-1:false:cloud.login.uuid", + "clear-session-if:cloud.login.uuid" + ]); }); }); @@ -727,6 +808,9 @@ function createAccountSessionRepositoryStub() { clear() { return undefined; }, + clearIfCloudUuid() { + return true; + }, getLastCodeSentAt() { return null; }, diff --git a/App/backend/src/services/tests/runtime-config-sync-service.test.ts b/App/backend/src/services/tests/runtime-config-sync-service.test.ts index 283233aaf..2f47590d9 100644 --- a/App/backend/src/services/tests/runtime-config-sync-service.test.ts +++ b/App/backend/src/services/tests/runtime-config-sync-service.test.ts @@ -5,7 +5,10 @@ import { dirname, join } from "node:path"; import YAML from "yaml"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createAppStateStore, type AppStateStore } from "../../infrastructure/app-state-store/index.js"; -import { createMemmyConfigWriter } from "../../infrastructure/memmy-config/index.js"; +import { + createMemmyConfigWriter, + writeAccountModelProjectionToMemmyConfig +} from "../../infrastructure/memmy-config/index.js"; import { createAppConfigService } from "../app-config-service.js"; import { syncRuntimeConfigWithAppState } from "../runtime-config-sync-service.js"; @@ -76,7 +79,7 @@ describe("syncRuntimeConfigWithAppState", () => { ...context, accountChannel: "email" })).resolves.toMatchObject({ - source: "runtime_config", mode: "account", hydratedAppState: true, wroteConfig: false + source: "runtime_config", mode: "account", hydratedAppState: true, wroteConfig: true }); expect(context.store.repositories.bootstrap.getAppSettings().userMode).toBe("account"); expect(context.store.repositories.accountSession.get()).toMatchObject({ @@ -86,6 +89,42 @@ describe("syncRuntimeConfigWithAppState", () => { expect(context.store.db.prepare("SELECT uuid FROM cloud_accounts WHERE uuid = ?").get("cloud-token-a")).toBeUndefined(); }); + it("refreshes local BYOK Agent candidates into an already authenticated account during startup", async () => { + const context = createContext(); + seedAccountSession(context); + context.writeConfig(currentByokCatalog()); + await writeAccountModelProjectionToMemmyConfig({ + cloudUuid: "cloud-token-a", + userId: "owner-a" + }, context.memmyConfigPath); + + const stale = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + stale.app.userMode = "account"; + stale.app.accountByokLocalSelectionBaseline = { + ownerAccountId: "owner-a", + candidates: ["agent"] + }; + stale.modelAssignments.account.agent.candidates = stale.modelAssignments.account.agent.candidates + .filter((presetId: string) => stale.modelPresets[presetId]?.source === "account"); + stale.modelAssignments.account.agent.default = stale.modelAssignments.account.agent.candidates[0]; + context.writeConfig(stale); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "email" + })).resolves.toMatchObject({ + source: "runtime_config", + mode: "account", + hydratedAppState: true, + wroteConfig: true, + reason: "refreshed_account_projection_and_hydrated_account" + }); + + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.modelAssignments.account.agent.candidates).toContain("agent"); + expect(saved.app.accountByokLocalSelectionBaseline).toBeUndefined(); + }); + it("keeps an unmarked legacy email session when the INTL package starts", async () => { const context = createContext(); context.store.repositories.accountSession.upsert({ @@ -103,7 +142,7 @@ describe("syncRuntimeConfigWithAppState", () => { ...context, accountChannel: "email" })).resolves.toMatchObject({ - source: "runtime_config", mode: "account", hydratedAppState: true, wroteConfig: false + source: "runtime_config", mode: "account", hydratedAppState: true, wroteConfig: true }); expect(context.store.repositories.accountSession.get()).toMatchObject({ authenticated: true, diff --git a/App/frontend/desktop/src/app/login-mode.ts b/App/frontend/desktop/src/app/login-mode.ts index 2634598e2..eb83a1247 100644 --- a/App/frontend/desktop/src/app/login-mode.ts +++ b/App/frontend/desktop/src/app/login-mode.ts @@ -6,7 +6,7 @@ import { appActions, type AppAction } from "../state/app-actions.js"; /** Contract for persist login mode selection input. */ export interface PersistLoginModeSelectionInput { - configClient?: Pick + configClient?: Pick & Partial>; dispatch: Dispatch; userMode: Extract; @@ -17,6 +17,9 @@ export interface PersistLoginModeSelectionInput { export async function persistLoginModeSelection(input: PersistLoginModeSelectionInput): Promise { const settingsPatch = { userMode: input.userMode }; const savedSettings = await saveSettingsPatch(input.configClient, settingsPatch); + const modelConfig = input.userMode === "account" + ? await requireCanonicalModelConfig(input.configClient) + : null; if (input.userMode === "account" && input.configClient?.getTokenUsage) { try { @@ -30,6 +33,7 @@ export async function persistLoginModeSelection(input: PersistLoginModeSelection } input.dispatch(appActions.settingsUpdated(savedSettings)); + if (modelConfig) input.dispatch(appActions.modelConfigUpdated(modelConfig)); if (!input.onboarding) { return; @@ -39,6 +43,14 @@ export async function persistLoginModeSelection(input: PersistLoginModeSelection input.dispatch(appActions.onboardingUpdated(savedOnboarding)); } +/** Loads the post-login canonical model catalog required before account-mode rendering. */ +async function requireCanonicalModelConfig( + configClient: PersistLoginModeSelectionInput["configClient"] +) { + if (!configClient) throw new Error("Config client is unavailable after account login"); + return configClient.getModelConfig(); +} + /** Writes save settings patch. */ async function saveSettingsPatch( configClient: PersistLoginModeSelectionInput["configClient"], diff --git a/App/frontend/desktop/src/app/tests/login-mode.test.ts b/App/frontend/desktop/src/app/tests/login-mode.test.ts index f09499f43..ab855afb8 100644 --- a/App/frontend/desktop/src/app/tests/login-mode.test.ts +++ b/App/frontend/desktop/src/app/tests/login-mode.test.ts @@ -4,6 +4,75 @@ import type { ConfigClient } from "../../api/config-client.js"; import { persistLoginModeSelection } from "../login-mode.js"; describe("persistLoginModeSelection", () => { + it("登录账号后先刷新 canonical 模型配置,再完成 onboarding", async () => { + const calls: string[] = []; + const dispatch = vi.fn(); + const modelConfig = { + provider: "memmy_account", + endpoint: "https://cloud.example.test/v1", + model: "platform-model", + apiKey: "", + apiKeyMasked: "", + configured: true + } as Awaited>; + const configClient = { + async updateSettings(settings) { + calls.push(`settings:${settings.userMode}`); + return settings; + }, + async updateOnboarding(onboarding) { + calls.push(`onboarding:${onboarding.currentStep}`); + return onboarding; + }, + async getModelConfig() { + calls.push("model-config"); + return modelConfig; + } + } satisfies Pick; + + await persistLoginModeSelection({ + configClient, + dispatch, + userMode: "account", + onboarding: { currentStep: "permissions_required" } + }); + + expect(calls).toEqual(["settings:account", "model-config", "onboarding:permissions_required"]); + expect(dispatch.mock.calls.map(([action]) => action.type)).toEqual([ + "settings/updated", + "modelConfig/updated", + "onboarding/updated" + ]); + expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ + type: "modelConfig/updated", + config: modelConfig + })); + }); + + it("账号模式 canonical 模型配置刷新失败时不渲染旧账号候选", async () => { + const dispatch = vi.fn(); + const configClient = { + async updateSettings(settings) { + return settings; + }, + async updateOnboarding(onboarding) { + return onboarding; + }, + async getModelConfig() { + throw new Error("model config offline"); + } + } satisfies Pick; + + await expect(persistLoginModeSelection({ + configClient, + dispatch, + userMode: "account", + onboarding: { currentStep: "permissions_required" } + })).rejects.toThrow("model config offline"); + + expect(dispatch).not.toHaveBeenCalled(); + }); + it("persists BYOK mode and onboarding step through config client", async () => { const calls: string[] = []; const dispatch = vi.fn(); @@ -15,8 +84,11 @@ describe("persistLoginModeSelection", () => { async updateOnboarding(onboarding) { calls.push(`onboarding:${onboarding.currentStep}`); return onboarding; + }, + async getModelConfig() { + throw new Error("BYOK mode must not load account model config"); } - } satisfies Pick; + } satisfies Pick; await persistLoginModeSelection({ configClient, @@ -37,8 +109,11 @@ describe("persistLoginModeSelection", () => { }, async updateOnboarding() { throw new Error("onboarding offline"); + }, + async getModelConfig() { + throw new Error("BYOK mode must not load account model config"); } - } satisfies Pick; + } satisfies Pick; await expect(persistLoginModeSelection({ configClient, diff --git a/App/frontend/desktop/src/pages/api-key-page.tsx b/App/frontend/desktop/src/pages/api-key-page.tsx index 20e35a2b9..f3ddd6e7b 100644 --- a/App/frontend/desktop/src/pages/api-key-page.tsx +++ b/App/frontend/desktop/src/pages/api-key-page.tsx @@ -15,6 +15,7 @@ import { assignCatalogPreset, createModelWorkspace, modelConfigInput, + setModelAssignment, upsertByokPreset } from "../state/model-workspace.js"; import { @@ -38,7 +39,7 @@ import { testModelConnection } from "./model-config.js"; -type EmbeddingMode = "custom"; +type EmbeddingMode = "local" | "custom"; interface EmbeddingCustomConfig { model: string; @@ -97,7 +98,10 @@ export function ApiKeyPage() { hasExistingApiKey: Boolean(apiKeyMasked) }; const [llmValidation, setLlmValidation] = useState(initialModelForm.llmValidation); - const initialEmbeddingMode: EmbeddingMode = "custom"; + const initialWorkspace = createModelWorkspace(state.modelConfig); + const initialEmbeddingMode: EmbeddingMode = initialWorkspace.catalog.modelAssignments.byok.embedding + ? "custom" + : "local"; const [embeddingMode, setEmbeddingMode] = useState(initialEmbeddingMode); const [embeddingConfig, setEmbeddingConfig] = useState({ model: initialModelForm.embModelId, @@ -116,7 +120,7 @@ export function ApiKeyPage() { }; const [embeddingValidation, setEmbeddingValidation] = useState(initialModelForm.embValidation); const canSave = canSaveModelConfig(modelFormValues, llmValidation) - && canSaveOptionalModelConfig(true, embeddingFormValues, embeddingValidation); + && canSaveOptionalModelConfig(embeddingMode === "custom", embeddingFormValues, embeddingValidation); const [savePending, setSavePending] = useState(false); const [saveError, setSaveError] = useState(null); const testedKey = createModelConfigValidationKey(modelFormValues); @@ -137,9 +141,8 @@ export function ApiKeyPage() { embeddingConfig.apiKey, embeddingConfig.apiKeyMasked ); - const saveSignature = `${testedKey}\n${embeddingTestKey}`; + const saveSignature = `${embeddingMode}\n${testedKey}\n${embeddingTestKey}`; const savedCatalogSignatureRef = useRef(null); - const initialWorkspace = createModelWorkspace(state.modelConfig); const initialAgentEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "agent"); const initialEmbeddingEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "embedding"); const savedEndpointIdentitiesRef = useRef>>({ @@ -211,20 +214,24 @@ export function ApiKeyPage() { }); workspace = assignCatalogPreset(agent.workspace, "byok", "agent", agent.presetId); const savedEmbeddingIdentity = savedEndpointIdentitiesRef.current.embedding; - const embeddingEndpointId = savedEmbeddingIdentity?.credentialSignature === embeddingCredentialSignature - ? savedEmbeddingIdentity.endpointId - : undefined; - const embedding = upsertByokPreset(workspace, { - provider: "openai", - ...(embeddingEndpointId ? { endpointId: embeddingEndpointId } : {}), - endpoint: embeddingConfig.endpoint, - protocol: "openai-embeddings", - ...(embeddingConfig.apiKey.trim() ? { apiKey: embeddingConfig.apiKey.trim() } : {}), - ...(embeddingConfig.apiKeyMasked ? { apiKeyMasked: embeddingConfig.apiKeyMasked } : {}), - model: embeddingConfig.model, - capabilities: ["embedding"] - }); - workspace = assignCatalogPreset(embedding.workspace, "byok", "embedding", embedding.presetId); + if (embeddingMode === "custom") { + const embeddingEndpointId = savedEmbeddingIdentity?.credentialSignature === embeddingCredentialSignature + ? savedEmbeddingIdentity.endpointId + : undefined; + const embedding = upsertByokPreset(workspace, { + provider: "openai", + ...(embeddingEndpointId ? { endpointId: embeddingEndpointId } : {}), + endpoint: embeddingConfig.endpoint, + protocol: "openai-embeddings", + ...(embeddingConfig.apiKey.trim() ? { apiKey: embeddingConfig.apiKey.trim() } : {}), + ...(embeddingConfig.apiKeyMasked ? { apiKeyMasked: embeddingConfig.apiKeyMasked } : {}), + model: embeddingConfig.model, + capabilities: ["embedding"] + }); + workspace = assignCatalogPreset(embedding.workspace, "byok", "embedding", embedding.presetId); + } else { + workspace = setModelAssignment(workspace, "byok", "embedding", null); + } const saved = await clients.config.saveModelCatalog(modelConfigInput(workspace)); if (!saved.catalog?.modelAssignments.byok.agent.candidates.length) { throw new Error("persisted BYOK Agent assignment is empty"); @@ -240,6 +247,8 @@ export function ApiKeyPage() { : {}), ...(savedEmbeddingEndpointId ? { embedding: { endpointId: savedEmbeddingEndpointId, credentialSignature: embeddingCredentialSignature } } + : embeddingMode === "local" && savedEmbeddingIdentity + ? { embedding: savedEmbeddingIdentity } : {}) }; savedCatalogSignatureRef.current = saveSignature; @@ -330,10 +339,11 @@ export function ApiKeyPage() { onValueChange={(value) => setEmbeddingMode(value as EmbeddingMode)} className="select-control--subtle" options={[ + { value: "local", label: t("apiKey.localEmbedding") }, { value: "custom", label: t("apiKey.customEmbedding") } ]} /> - {( + {embeddingMode === "custom" ? ( <> - )} + ) : null} diff --git a/App/frontend/desktop/src/pages/login-page.tsx b/App/frontend/desktop/src/pages/login-page.tsx index 069cb74b2..6a46bb332 100644 --- a/App/frontend/desktop/src/pages/login-page.tsx +++ b/App/frontend/desktop/src/pages/login-page.tsx @@ -31,6 +31,7 @@ export function LoginPage() { const [inviteCode, setInviteCode] = useState(""); const [modePersistencePending, setModePersistencePending] = useState(false); const [modePersistenceFeedback, setModePersistenceFeedback] = useState<{ text: string; tone: "error" | "success" } | null>(null); + const [pendingAccountOnboarding, setPendingAccountOnboarding] = useState | null>(null); const channel = resolveDesktopAccountChannel(); const invitationEnabled = state.bootstrap?.promotions?.invitation?.enabled === true; const canContinue = Boolean(identifier.trim() && code.trim()); @@ -40,6 +41,7 @@ export function LoginPage() { setCode(""); setInviteCode(""); setModePersistenceFeedback(null); + setPendingAccountOnboarding(null); verificationCodeAuth.resetInteractionState(); }, [channel, verificationCodeAuth.resetInteractionState]); @@ -52,10 +54,15 @@ export function LoginPage() { } async function submitLogin() { - if (!canContinue || verificationCodeAuth.loginPending || modePersistencePending) { + if (verificationCodeAuth.loginPending || modePersistencePending) { return; } setModePersistenceFeedback(null); + if (pendingAccountOnboarding) { + await continueAfterRegistration(pendingAccountOnboarding); + return; + } + if (!canContinue) return; const loginResult = await verificationCodeAuth.login( channel, @@ -88,17 +95,16 @@ export function LoginPage() { registeredAt: session.profile.registeredAt })); - if (session.profile.hasFinishedGuide) { - await continueAfterRegistration({ + const onboardingPatch: Partial = session.profile.hasFinishedGuide + ? { completed: true, currentStep: "completed", completedAt: new Date().toISOString(), hasAcceptedTerms: true - }); - return; - } - - await continueAfterRegistration(); + } + : buildAccountOnboardingStartPatch(); + setPendingAccountOnboarding(onboardingPatch); + await continueAfterRegistration(onboardingPatch); } async function continueAfterRegistration(forcedOnboarding?: Partial) { @@ -119,6 +125,7 @@ export function LoginPage() { userMode: "account", onboarding: onboardingPatch }); + setPendingAccountOnboarding(null); dispatch(appActions.navigate(nextRoute)); } catch (error) { console.error("persist account mode failed", error); @@ -149,7 +156,7 @@ export function LoginPage() { identifierType={channel} code={code} inviteCode={inviteCode} - disabled={!canContinue || verificationCodeAuth.loginPending || modePersistencePending} + disabled={(!canContinue && !pendingAccountOnboarding) || verificationCodeAuth.loginPending || modePersistencePending} sendCodeDisabled={verificationCodeAuth.sendCodeDisabled} sendCodeLabel={verificationCodeAuth.sendCodeLabel} feedback={modePersistenceFeedback ?? verificationCodeAuth.feedback} diff --git a/App/frontend/desktop/src/pages/model-workspace-section.tsx b/App/frontend/desktop/src/pages/model-workspace-section.tsx index a0332c64f..32224b0bd 100644 --- a/App/frontend/desktop/src/pages/model-workspace-section.tsx +++ b/App/frontend/desktop/src/pages/model-workspace-section.tsx @@ -53,6 +53,7 @@ export type ModelKind = "text" | "embedding" | "asr" | "image"; const DEFAULT_TEXT_CAPABILITIES: ModelCapability[] = ["chat", "memorySummary", "memoryEvolution"]; const MODEL_KIND_OPTIONS = ["text", "embedding", "asr", "image"] as const; +const LOCAL_EMBEDDING_OPTION_VALUE = "builtin:local-embedding"; export function modelCapabilitiesForKind(kind: ModelKind): ModelCapability[] { if (kind === "text") return [...DEFAULT_TEXT_CAPABILITIES]; @@ -585,7 +586,12 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { } function updateAssignment(kind: ModelAssignmentKind, candidateId: string) { - commitWorkspace(setModelAssignment(workspace, props.mode, kind, candidateId)); + const assignment = kind === "embedding" + && props.mode === "byok" + && candidateId === LOCAL_EMBEDDING_OPTION_VALUE + ? null + : candidateId; + commitWorkspace(setModelAssignment(workspace, props.mode, kind, assignment)); } function toggleTaskCandidate(candidateId: string) { @@ -654,7 +660,19 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { candidate.source, candidate.provider )); - const embeddingOptions: SelectOption[] = embeddingModelOptions; + const embeddingOptions: SelectOption[] = props.mode === "byok" + ? [ + { + value: LOCAL_EMBEDDING_OPTION_VALUE, + label: t("settings.modelWorkspace.localEmbedding"), + selectedLabel: t("settings.modelWorkspace.localEmbeddingShort"), + groupLabel: t("settings.modelWorkspace.specialBuiltins") + }, + ...embeddingModelOptions + ] + : embeddingModelOptions; + const embeddingAssignment = space.assignments.embedding + ?? (props.mode === "byok" ? LOCAL_EMBEDDING_OPTION_VALUE : undefined); const editorExistingConnection = editor?.connectionId ? space.connections.find((connection) => connection.id === editor.connectionId) : undefined; @@ -973,7 +991,7 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { kind="embedding" label={t("settings.model.embeddingSearch")} description={t("settings.model.embeddingDesc")} - value={space.assignments.embedding} + value={embeddingAssignment} options={embeddingOptions} onChange={updateAssignment} /> diff --git a/App/frontend/desktop/src/pages/settings-page.tsx b/App/frontend/desktop/src/pages/settings-page.tsx index da76e0384..d123528ba 100644 --- a/App/frontend/desktop/src/pages/settings-page.tsx +++ b/App/frontend/desktop/src/pages/settings-page.tsx @@ -1,7 +1,7 @@ /** Settings page for account, model, token usage, and desktop preferences. */ import { useCallback, useEffect, useRef, useState, type CSSProperties, type Dispatch, type ReactNode } from "react"; import { Brain, Palette, Rocket, Settings2, Shield, User, Zap, ArrowRight, Bell, ExternalLink, FolderOpen, Gift, Info, KeyRound, LogOut, Wrench, Eye, EyeOff, ChevronDown, ChevronUp, Database, Loader2, CheckCircle2, XCircle, Check, AlertTriangle, Mic, Image as ImageIcon, Copy} from "lucide-react"; -import type { AccountInvitationView, AppSettingsDto, ByokTokenUsageByKind, ByokTokenUsageByModel, ByokTokenUsageCapability, ByokTokenUsageKind, ByokTokenUsageSummary, Language, PrivacySettingsDto, TokenQuotaEligibility, TokenSceneUsageDto, TokenUsageDto } from "@memmy/local-api-contracts"; +import type { AccountInvitationView, AppSettingsDto, ByokTokenUsageByKind, ByokTokenUsageByModel, ByokTokenUsageCapability, ByokTokenUsageKind, ByokTokenUsageSummary, Language, ModelConfigView, PrivacySettingsDto, TokenQuotaEligibility, TokenSceneUsageDto, TokenUsageDto } from "@memmy/local-api-contracts"; import { useApiClients } from "../app/providers.js"; import { copyInvitationCode } from "../app/invitation-analytics.js"; import { resolveGiftTokenUsage } from "../app/routes.js"; @@ -11,7 +11,7 @@ import { useAnalytics } from "../analytics/use-analytics.js"; import type { AccountClient } from "../api/account-client.js"; import type { ByokTokenUsageClient } from "../api/byok-token-usage-client.js"; import type { TokenQuotaClient } from "../api/token-quota-client.js"; -import type { ConfigClient } from "../api/config-client.js"; +import type { ConfigClient, ModelProviderConfig } from "../api/config-client.js"; import { readCloseMainWindowAction, writeCloseMainWindowAction, @@ -289,6 +289,44 @@ export function shouldSaveAccountNicknameOnKeyDown(event: import("react").Keyboa return event.key === "Enter" && !isComposingKeyboardEvent(event); } +/** Returns whether the canonical catalog still contains a configured BYOK Agent model. */ +export function hasConfiguredByokAgentModel(catalog: ModelConfigView | null | undefined): boolean { + return Boolean(catalog?.providers.some((provider) => provider.models.some((model) => ( + model.source === "byok" && model.capabilities.includes("agent") + )))); +} + +/** Reconciles local UI state after the backend account session has already been cleared. */ +export async function finalizeAccountLogout(input: { + modelConfig: ModelProviderConfig; + configClient?: Pick; + dispatch: Dispatch; +}): Promise<"byok" | "unset"> { + let latestModelConfig = input.modelConfig; + try { + const refreshedModelConfig = await input.configClient?.getModelConfig(); + if (refreshedModelConfig) { + latestModelConfig = refreshedModelConfig; + input.dispatch(appActions.modelConfigUpdated(latestModelConfig)); + } + } catch (error) { + console.warn("refresh model config after logout failed", error); + } + + input.dispatch(appActions.accountCleared()); + const userMode = hasConfiguredByokAgentModel(latestModelConfig.catalog) ? "byok" : "unset"; + input.dispatch(appActions.settingsUpdated({ userMode })); + try { + if (input.configClient) { + const savedSettings = await input.configClient.updateSettings({ userMode }); + input.dispatch(appActions.settingsUpdated(savedSettings)); + } + } catch (error) { + console.warn("persist mode after logout failed", error); + } + return userMode; +} + /** * Renders the pure settings-page view. * @@ -1149,13 +1187,12 @@ export function SettingsPageView(props: SettingsPageViewProps) { try { await (accountClient?.logout() ?? Promise.resolve({ ok: true as const })); track({ name: "account_logout", params: { page_path: "/settings" }, consentTier: "basic" }); - dispatch(appActions.accountCleared()); - const canEnterByok = Boolean(state.modelConfig.catalog?.modelAssignments.byok.agent.candidates.length); - if (canEnterByok) { - dispatch(appActions.settingsUpdated({ userMode: "byok" })); - persistSettings({ userMode: "byok" }); - } else { - persistSettings({ userMode: "unset" }); + const nextUserMode = await finalizeAccountLogout({ + modelConfig: state.modelConfig, + configClient, + dispatch + }); + if (nextUserMode === "unset") { dispatch(appActions.navigate("/welcome")); } setConfirm(null); diff --git a/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts b/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts index 193d7e8e6..581753311 100644 --- a/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts +++ b/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts @@ -31,6 +31,11 @@ describe("ApiKeyPage source", () => { expect(fieldsSource).toContain("auth-code-form-input"); expect(source).toContain("testEmbeddingConnection"); expect(source).toContain('"embedding"'); + expect(source).toContain('type EmbeddingMode = "local" | "custom"'); + expect(source).toContain('{ value: "local", label: t("apiKey.localEmbedding") }'); + expect(source).toContain('canSaveOptionalModelConfig(embeddingMode === "custom"'); + expect(source).toContain('const saveSignature = `${embeddingMode}\\n${testedKey}\\n${embeddingTestKey}`'); + expect(source).toContain('workspace = setModelAssignment(workspace, "byok", "embedding", null)'); expect(source).not.toContain("testAsrConnection"); expect(source).not.toContain("testImageGenConnection"); expect(source).not.toContain("optionalModelMissingWarning"); diff --git a/App/frontend/desktop/src/pages/tests/auth-flow.test.ts b/App/frontend/desktop/src/pages/tests/auth-flow.test.ts index c5d9fc83f..2dee475ed 100644 --- a/App/frontend/desktop/src/pages/tests/auth-flow.test.ts +++ b/App/frontend/desktop/src/pages/tests/auth-flow.test.ts @@ -20,6 +20,24 @@ describe("auth flow pages", () => { expect(persistIndex).toBeGreaterThan(verifyIndex); }); + it.each([ + ["welcome-page.tsx"], + ["token-detail-page.tsx"], + ["login-page.tsx"] + ])("%s 登录已成功但本地配置刷新失败时只重试登录后续流程", (fileName) => { + const source = readSource(fileName); + const submitIndex = source.indexOf("async function submitLogin()"); + const pendingRetryIndex = source.indexOf("if (pendingAccountOnboarding)", submitIndex); + const cloudLoginIndex = source.indexOf("await verificationCodeAuth.login(", submitIndex); + const rememberIndex = source.indexOf("setPendingAccountOnboarding(onboardingPatch)", cloudLoginIndex); + const clearIndex = source.indexOf("setPendingAccountOnboarding(null)", rememberIndex); + + expect(pendingRetryIndex).toBeGreaterThan(submitIndex); + expect(pendingRetryIndex).toBeLessThan(cloudLoginIndex); + expect(rememberIndex).toBeGreaterThan(cloudLoginIndex); + expect(clearIndex).toBeGreaterThan(rememberIndex); + }); + it.each([ ["welcome-page.tsx"], ["token-detail-page.tsx"], @@ -31,7 +49,7 @@ describe("auth flow pages", () => { expect(source).toContain("feedback={modePersistenceFeedback ?? verificationCodeAuth.feedback}"); expect(source).toContain("sendCodeDisabled={verificationCodeAuth.sendCodeDisabled}"); expect(source).toContain("sendCodeLabel={verificationCodeAuth.sendCodeLabel}"); - expect(source).toContain("disabled={!canContinue || verificationCodeAuth.loginPending || modePersistencePending}"); + expect(source).toContain("disabled={(!canContinue && !pendingAccountOnboarding) || verificationCodeAuth.loginPending || modePersistencePending}"); expect(hookSource).toContain("validateAuthIdentifier(channel, rawIdentifier)"); expect(hookSource).toContain("resolveIdentifierValidationMessage(channel, validation.reason, t)"); expect(hookSource).toContain('"login.error.invalidPhone"'); diff --git a/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx index b882fe04c..f6cd72a99 100644 --- a/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx @@ -77,6 +77,94 @@ describe("BYOK setup save feedback", () => { expect(container.querySelector('[role="alert"]')).toBeNull(); }); + it("defaults a genuinely absent BYOK embedding assignment to the local model", async () => { + const catalog = configuredCatalog(false); + catalog.modelAssignments.byok.embedding = null; + mocks.state = { + ...createInitialAppState(), + modelConfig: { ...savedModelConfig(catalog), embedding: null } + }; + mocks.clients = createClients(vi.fn(async () => savedModelConfig(catalog))); + + await render(); + + expect(combobox("apiKey.embeddingMode").textContent).toContain("apiKey.localEmbedding"); + expect(hasField("apiKey.embeddingModel")).toBe(false); + expect(hasField("apiKey.embeddingEndpoint")).toBe(false); + expect(hasField("apiKey.embeddingKey")).toBe(false); + }); + + it("keeps an explicit invalid BYOK embedding assignment in custom mode", async () => { + const catalog = configuredCatalog(false); + catalog.modelAssignments.byok.embedding = "missing-embedding-preset"; + mocks.state = { + ...createInitialAppState(), + modelConfig: { ...savedModelConfig(catalog), embedding: null } + }; + mocks.clients = createClients(vi.fn(async () => savedModelConfig(catalog))); + + await render(); + + expect(combobox("apiKey.embeddingMode").textContent).toContain("apiKey.customEmbedding"); + expect(hasField("apiKey.embeddingModel")).toBe(true); + expect(button("apiKey.next").disabled).toBe(true); + }); + + it("saves local embedding without deleting the custom preset or account assignment", async () => { + const initialCatalog = catalogWithSharedEmbeddingAssignment(); + const embeddingPresetId = initialCatalog.modelAssignments.byok.embedding; + const server = createCatalogServer(initialCatalog); + mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) }; + mocks.clients = createClients(server.saveModelCatalog, { getModelConfig: server.getModelConfig }); + await render(); + + await selectOption("apiKey.embeddingMode", "apiKey.localEmbedding"); + await click(button("apiKey.next")); + + const saved = server.catalog(); + expect(saved.modelAssignments.byok.embedding).toBeNull(); + expect(saved.modelAssignments.account.embedding).toBe(embeddingPresetId); + expect(saved.providers.flatMap((provider) => provider.models).map((model) => model.presetId)) + .toContain(embeddingPresetId); + }); + + it("reuses masked custom embedding identity after a partial local-mode save", async () => { + const initialCatalog = catalogWithSharedEmbeddingAssignment(); + const initialEmbeddingEndpointId = assignedCatalogEndpointId( + createModelWorkspace(initialCatalog), + "byok", + "embedding" + ); + const initialEmbeddingPresetId = initialCatalog.modelAssignments.byok.embedding; + const initialProviderCount = initialCatalog.providers.length; + const initialPresetCount = initialCatalog.providers.flatMap((provider) => provider.models).length; + const server = createCatalogServer(initialCatalog); + const updateSettings = vi.fn(async (settings: unknown) => settings) + .mockRejectedValueOnce(new Error("settings offline")); + mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) }; + mocks.clients = createClients(server.saveModelCatalog, { + getModelConfig: server.getModelConfig, + updateSettings + }); + await render(); + + await selectOption("apiKey.embeddingMode", "apiKey.localEmbedding"); + await click(button("apiKey.next")); + expect(server.catalog().modelAssignments.byok.embedding).toBeNull(); + + await selectOption("apiKey.embeddingMode", "apiKey.customEmbedding"); + await click(button("apiKey.next")); + + const restored = server.catalog(); + expect(server.saveModelCatalog).toHaveBeenCalledTimes(2); + expect(assignedCatalogEndpointId(createModelWorkspace(restored), "byok", "embedding")) + .toBe(initialEmbeddingEndpointId); + expect(restored.modelAssignments.byok.embedding).toBe(initialEmbeddingPresetId); + expect(restored.modelAssignments.account.embedding).toBe(initialEmbeddingPresetId); + expect(restored.providers).toHaveLength(initialProviderCount); + expect(restored.providers.flatMap((provider) => provider.models)).toHaveLength(initialPresetCount); + }); + it("shows a first-step conflict, stays put, and allows a successful retry", async () => { const firstSave = deferred(); const saveModelCatalog = vi.fn() @@ -119,8 +207,33 @@ describe("BYOK setup save feedback", () => { expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/api-key-models")); }); + it("saves the catalog again when a partial custom save is retried as local", async () => { + const initialCatalog = catalogWithSharedEmbeddingAssignment(); + const server = createCatalogServer(initialCatalog); + const updateSettings = vi.fn(async (settings: unknown) => settings) + .mockRejectedValueOnce(new Error("settings offline")); + mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) }; + mocks.clients = createClients(server.saveModelCatalog, { + getModelConfig: server.getModelConfig, + updateSettings + }); + await render(); + + await click(button("apiKey.next")); + expect(server.saveModelCatalog).toHaveBeenCalledTimes(1); + expect(server.catalog().modelAssignments.byok.embedding).not.toBeNull(); + + await selectOption("apiKey.embeddingMode", "apiKey.localEmbedding"); + await click(button("apiKey.next")); + + expect(server.saveModelCatalog).toHaveBeenCalledTimes(2); + expect(server.catalog().modelAssignments.byok.embedding).toBeNull(); + expect(server.catalog().modelAssignments.account.embedding) + .toBe(initialCatalog.modelAssignments.account.embedding); + }); + it("reuses first-step endpoint identities when only the model changes after partial success", async () => { - const server = createCatalogServer(); + const server = createCatalogServer(configuredCatalog(false)); const updateSettings = vi.fn(async (settings: unknown) => settings) .mockRejectedValueOnce(new Error("settings offline")); mocks.state = { ...createInitialAppState(), modelConfig: savedModelConfig(server.catalog()) }; @@ -176,7 +289,7 @@ describe("BYOK setup save feedback", () => { }); it("invalidates only the changed first-step credential identity", async () => { - const server = createCatalogServer(); + const server = createCatalogServer(configuredCatalog(false)); const updateSettings = vi.fn(async (settings: unknown) => settings) .mockRejectedValueOnce(new Error("settings offline")); mocks.state = { ...createInitialAppState(), modelConfig: savedModelConfig(server.catalog()) }; @@ -373,6 +486,31 @@ describe("BYOK setup save feedback", () => { return target; } + function combobox(labelText: string): HTMLButtonElement { + const label = [...container.querySelectorAll(".select-control__label")] + .find((candidate) => candidate.textContent === labelText); + const target = label?.parentElement?.querySelector('button[role="combobox"]'); + if (!(target instanceof HTMLButtonElement)) { + throw new Error(`combobox not found: ${labelText}`); + } + return target; + } + + async function selectOption(labelText: string, optionText: string) { + await click(combobox(labelText)); + const target = [...container.querySelectorAll('button[role="option"]')] + .find((candidate) => candidate.textContent?.includes(optionText)); + if (!(target instanceof HTMLButtonElement)) { + throw new Error(`option not found: ${optionText}`); + } + await click(target); + } + + function hasField(labelText: string): boolean { + return [...container.querySelectorAll("label")] + .some((candidate) => candidate.textContent === labelText); + } + async function changeField(labelText: string, value: string) { const label = [...container.querySelectorAll("label")] .find((candidate) => candidate.textContent === labelText); @@ -517,6 +655,12 @@ function configuredCatalog(includeOptional = true): ModelConfigView { return catalogFromInput(modelConfigInput(workspace), empty, 1); } +function catalogWithSharedEmbeddingAssignment(): ModelConfigView { + const catalog = configuredCatalog(false); + catalog.modelAssignments.account.embedding = catalog.modelAssignments.byok.embedding; + return catalog; +} + function endpointCount(catalog: ModelConfigView): number { return catalog.providers.reduce((total, provider) => total + provider.endpoints.length, 0); } diff --git a/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx index 870738e69..68b5ac983 100644 --- a/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx @@ -127,16 +127,86 @@ describe("ModelWorkspaceSection BYOK connection deletion", () => { expect(bgeModel?.capabilities).toEqual(["embedding"]); }); - function renderWorkspace(seedConfig: ModelProviderConfig) { + it("shows the built-in embedding option for BYOK without changing labels", () => { + renderWorkspace(createSeedConfig(1)); + + const embeddingSelect = getAssignmentCombobox("Embedding 检索"); + expect(embeddingSelect.disabled).toBe(false); + expect(embeddingSelect.textContent).toContain("本地 Embedding"); + + act(() => embeddingSelect.click()); + expect(getOption("本地 · Xenova/all-MiniLM-L6-v2")).not.toBeNull(); + }); + + it("does not offer the built-in embedding option in account mode", () => { + const seedConfig = createEmbeddingSeedConfig(); + const presetId = seedConfig.catalog.modelAssignments.byok.embedding!; + seedConfig.catalog.modelAssignments.account.embedding = presetId; + + renderWorkspace(seedConfig, "account"); + + const embeddingSelect = getAssignmentCombobox("Embedding 检索"); + expect(embeddingSelect.textContent).toContain("text-embedding-3-small"); + act(() => embeddingSelect.click()); + expect(getOption("本地 · Xenova/all-MiniLM-L6-v2")).toBeNull(); + }); + + it("persists the built-in BYOK embedding as a null assignment", async () => { + const seedConfig = createEmbeddingSeedConfig(); + const presetId = seedConfig.catalog.modelAssignments.byok.embedding!; + seedConfig.catalog.modelAssignments.account.embedding = presetId; + const configClient = { + getModelConfig: vi.fn(async () => seedConfig), + saveModelCatalog: vi.fn(async () => seedConfig), + testModelConfig: vi.fn(async () => ({ + ok: true, + message: "ok", + checkedAt: "2026-08-13T00:00:00.000Z" + })) + }; + + await act(async () => { + root.render( + + + + ); + await Promise.resolve(); + }); + + act(() => getAssignmentCombobox("Embedding 检索").click()); + const localOption = getOption("本地 · Xenova/all-MiniLM-L6-v2"); + expect(localOption).not.toBeNull(); + act(() => localOption!.click()); + + await vi.waitFor(() => expect(configClient.saveModelCatalog).toHaveBeenCalledTimes(1)); + const input = configClient.saveModelCatalog.mock.calls[0]![0]; + expect(input.modelAssignments.byok.embedding).toBeNull(); + expect(input.modelAssignments.account.embedding).toBe(presetId); + }); + + function renderWorkspace(seedConfig: ModelProviderConfig, mode: "byok" | "account" = "byok") { act(() => { root.render( - + ); }); } + function getAssignmentCombobox(label: string): HTMLButtonElement { + const labelNode = [...container.querySelectorAll(".model-assignment-label")] + .find((node) => node.textContent === label)!; + return labelNode.closest("div.flex.items-center.justify-between")! + .querySelector('[role="combobox"]')!; + } + + function getOption(label: string): HTMLButtonElement | null { + return [...container.querySelectorAll('[role="option"]')] + .find((option) => option.textContent?.includes(label)) ?? null; + } + function getDeleteButtons(): HTMLButtonElement[] { return [...container.querySelectorAll('button[aria-label="删除 openai 配置"]')]; } diff --git a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx index 2582d6e84..4b1ce5b25 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx @@ -13,7 +13,9 @@ import type { UpdateCoordinatorValue } from "../../app/update-coordinator.js"; import { LOG_LEVEL_STORAGE_KEY, SettingsPageView, + finalizeAccountLogout, formatUsageUpdatedAt, + hasConfiguredByokAgentModel, isPendingQuotaRequestError, resolveQuotaEligibilityMessage, resolveSettingsTabFromHash, @@ -752,7 +754,7 @@ describe("SettingsPageView", () => { expect(html).toContain("打磨 Agent 技能与偏好"); expect(html).toContain("Embedding 检索"); expect(html).toContain("记忆向量化检索"); - expect(html).not.toContain("Xenova/all-MiniLM-L6-v2"); + expect(html).toContain("Xenova/all-MiniLM-L6-v2"); expect(html).toContain("语音识别 ASR"); expect(html).toContain("生图模型"); expect(html).toContain("未配置"); @@ -862,6 +864,92 @@ describe("SettingsPageView", () => { expect(source).toContain("appActions.accountCleared()"); }); + it("退出登录后刷新 canonical 配置,并按实际 BYOK Agent 模型决定落点", async () => { + const catalogWithUnselectedByokAgent = createCatalog(true); + catalogWithUnselectedByokAgent.modelAssignments.byok.agent = { candidates: [], default: null }; + expect(hasConfiguredByokAgentModel(catalogWithUnselectedByokAgent)).toBe(true); + expect(hasConfiguredByokAgentModel(createCatalog(false))).toBe(false); + + const dispatch = vi.fn(); + const canonicalModelConfig = { + ...createAccountModeWithSavedModelState().modelConfig, + catalog: catalogWithUnselectedByokAgent + }; + const configClient = { + getModelConfig: vi.fn(async () => canonicalModelConfig), + updateSettings: vi.fn(async (settings) => settings) + }; + + await expect(finalizeAccountLogout({ + modelConfig: createAccountModeState().modelConfig, + configClient, + dispatch + })).resolves.toBe("byok"); + + expect(configClient.getModelConfig).toHaveBeenCalledOnce(); + expect(configClient.updateSettings).toHaveBeenCalledWith({ userMode: "byok" }); + expect(dispatch.mock.calls.map(([action]) => action.type)).toEqual([ + "modelConfig/updated", + "account/cleared", + "settings/updated", + "settings/updated" + ]); + }); + + it("退出后的 canonical 刷新失败时仅使用缓存 BYOK catalog 回退", async () => { + const dispatch = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const configClient = { + getModelConfig: vi.fn(async () => { throw new Error("model config offline"); }), + updateSettings: vi.fn(async (settings) => settings) + }; + + try { + await expect(finalizeAccountLogout({ + modelConfig: createAccountModeWithSavedModelState().modelConfig, + configClient, + dispatch + })).resolves.toBe("byok"); + } finally { + warn.mockRestore(); + } + + expect(dispatch.mock.calls.map(([action]) => action.type)).toEqual([ + "account/cleared", + "settings/updated", + "settings/updated" + ]); + }); + + it("退出后的模式保存失败不回滚已清除账号,并继续返回欢迎页落点", async () => { + const dispatch = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const canonicalModelConfig = createAccountModeState().modelConfig; + const configClient = { + getModelConfig: vi.fn(async () => canonicalModelConfig), + updateSettings: vi.fn(async () => { throw new Error("settings offline"); }) + }; + + try { + await expect(finalizeAccountLogout({ + modelConfig: createAccountModeWithSavedModelState().modelConfig, + configClient, + dispatch + })).resolves.toBe("unset"); + } finally { + warn.mockRestore(); + } + + expect(dispatch.mock.calls.map(([action]) => action.type)).toEqual([ + "modelConfig/updated", + "account/cleared", + "settings/updated" + ]); + const source = readFileSync(settingsPageSourcePath, "utf8"); + expect(source).toContain('if (nextUserMode === "unset")'); + expect(source).toContain('dispatch(appActions.navigate("/welcome"))'); + }); + it("中文输入法组合输入中的 Enter 只确认候选,不保存账户昵称", () => { expect(shouldSaveAccountNicknameOnKeyDown(nicknameKeyEvent({ nativeEvent: { isComposing: true } }))).toBe(false); expect(shouldSaveAccountNicknameOnKeyDown(nicknameKeyEvent({ nativeEvent: { keyCode: 229 } }))).toBe(false); diff --git a/App/frontend/desktop/src/pages/token-detail-page.tsx b/App/frontend/desktop/src/pages/token-detail-page.tsx index 10a654558..2628b256c 100644 --- a/App/frontend/desktop/src/pages/token-detail-page.tsx +++ b/App/frontend/desktop/src/pages/token-detail-page.tsx @@ -31,6 +31,7 @@ export function TokenDetailPage() { const [inviteCode, setInviteCode] = useState(""); const [modePersistencePending, setModePersistencePending] = useState(false); const [modePersistenceFeedback, setModePersistenceFeedback] = useState<{ text: string; tone: "error" | "success" } | null>(null); + const [pendingAccountOnboarding, setPendingAccountOnboarding] = useState | null>(null); const channel = resolveDesktopAccountChannel(); const invitationEnabled = state.bootstrap?.promotions?.invitation?.enabled === true; const canContinue = Boolean(identifier.trim() && code.trim()); @@ -41,6 +42,7 @@ export function TokenDetailPage() { setCode(""); setInviteCode(""); setModePersistenceFeedback(null); + setPendingAccountOnboarding(null); verificationCodeAuth.resetInteractionState(); }, [channel, verificationCodeAuth.resetInteractionState]); @@ -53,10 +55,15 @@ export function TokenDetailPage() { } async function submitLogin() { - if (!canContinue || verificationCodeAuth.loginPending || modePersistencePending) { + if (verificationCodeAuth.loginPending || modePersistencePending) { return; } setModePersistenceFeedback(null); + if (pendingAccountOnboarding) { + await continueAfterRegistration(pendingAccountOnboarding); + return; + } + if (!canContinue) return; const loginResult = await verificationCodeAuth.login( channel, @@ -89,17 +96,16 @@ export function TokenDetailPage() { registeredAt: session.profile.registeredAt })); - if (session.profile.hasFinishedGuide) { - await continueAfterRegistration({ + const onboardingPatch: Partial = session.profile.hasFinishedGuide + ? { completed: true, currentStep: "completed", completedAt: new Date().toISOString(), hasAcceptedTerms: true - }); - return; - } - - await continueAfterRegistration(); + } + : buildAccountOnboardingStartPatch(); + setPendingAccountOnboarding(onboardingPatch); + await continueAfterRegistration(onboardingPatch); } async function continueAfterRegistration(forcedOnboarding?: Partial) { @@ -120,6 +126,7 @@ export function TokenDetailPage() { userMode: "account", onboarding: onboardingPatch }); + setPendingAccountOnboarding(null); dispatch(appActions.navigate(nextRoute)); } catch (error) { console.error("persist account mode failed", error); @@ -173,7 +180,7 @@ export function TokenDetailPage() { identifierType={channel} code={code} inviteCode={inviteCode} - disabled={!canContinue || verificationCodeAuth.loginPending || modePersistencePending} + disabled={(!canContinue && !pendingAccountOnboarding) || verificationCodeAuth.loginPending || modePersistencePending} sendCodeDisabled={verificationCodeAuth.sendCodeDisabled} sendCodeLabel={verificationCodeAuth.sendCodeLabel} feedback={modePersistenceFeedback ?? verificationCodeAuth.feedback} diff --git a/App/frontend/desktop/src/pages/welcome-page.tsx b/App/frontend/desktop/src/pages/welcome-page.tsx index b9a4c6cc4..0c33049fa 100644 --- a/App/frontend/desktop/src/pages/welcome-page.tsx +++ b/App/frontend/desktop/src/pages/welcome-page.tsx @@ -33,6 +33,7 @@ export function WelcomePage() { const [inviteCode, setInviteCode] = useState(""); const [modePersistencePending, setModePersistencePending] = useState(false); const [modePersistenceFeedback, setModePersistenceFeedback] = useState<{ text: string; tone: "error" | "success" } | null>(null); + const [pendingAccountOnboarding, setPendingAccountOnboarding] = useState | null>(null); const channel = resolveDesktopAccountChannel(); const invitationEnabled = state.bootstrap?.promotions?.invitation?.enabled === true; const canContinue = Boolean(identifier.trim() && code.trim()); @@ -46,6 +47,7 @@ export function WelcomePage() { setCode(""); setInviteCode(""); setModePersistenceFeedback(null); + setPendingAccountOnboarding(null); verificationCodeAuth.resetInteractionState(); }, [channel, verificationCodeAuth.resetInteractionState]); @@ -60,10 +62,15 @@ export function WelcomePage() { /** Handles submit login. */ async function submitLogin() { - if (!canContinue || verificationCodeAuth.loginPending || modePersistencePending) { + if (verificationCodeAuth.loginPending || modePersistencePending) { return; } setModePersistenceFeedback(null); + if (pendingAccountOnboarding) { + await continueAfterAccountEntry(pendingAccountOnboarding); + return; + } + if (!canContinue) return; const loginResult = await verificationCodeAuth.login( channel, @@ -96,19 +103,16 @@ export function WelcomePage() { registeredAt: session.profile.registeredAt })); - if (session.profile.hasFinishedGuide) { - await continueAfterAccountEntry({ + const onboardingPatch: Partial = session.profile.hasFinishedGuide + ? { completed: true, currentStep: "completed", completedAt: new Date().toISOString(), hasAcceptedTerms: true - }); - return; - } - - // Welcome page module. - // Welcome page module. - await continueAfterAccountEntry(); + } + : buildAccountOnboardingStartPatch(); + setPendingAccountOnboarding(onboardingPatch); + await continueAfterAccountEntry(onboardingPatch); } /** Handles continue after account entry. */ @@ -130,6 +134,7 @@ export function WelcomePage() { userMode: "account", onboarding: onboardingPatch }); + setPendingAccountOnboarding(null); dispatch(appActions.navigate(nextRoute)); } catch (error) { console.error("persist account mode failed", error); @@ -214,7 +219,7 @@ export function WelcomePage() { identifierType={channel} code={code} inviteCode={inviteCode} - disabled={!canContinue || verificationCodeAuth.loginPending || modePersistencePending} + disabled={(!canContinue && !pendingAccountOnboarding) || verificationCodeAuth.loginPending || modePersistencePending} sendCodeDisabled={verificationCodeAuth.sendCodeDisabled} sendCodeLabel={verificationCodeAuth.sendCodeLabel} feedback={modePersistenceFeedback ?? verificationCodeAuth.feedback} diff --git a/App/frontend/desktop/src/state/model-workspace.ts b/App/frontend/desktop/src/state/model-workspace.ts index 822100b84..963895893 100644 --- a/App/frontend/desktop/src/state/model-workspace.ts +++ b/App/frontend/desktop/src/state/model-workspace.ts @@ -553,13 +553,18 @@ export function setModelAssignment( workspace: ModelWorkspace, mode: ModelWorkspaceMode, kind: ModelAssignmentKind, - candidateId: string + candidateId: string | null ): ModelWorkspace { + const key = kind === "image" ? "imageGeneration" : kind; + if (candidateId === null) { + const next = cloneCatalog(workspace.catalog); + next.modelAssignments[mode][key] = null; + return createModelWorkspace(next); + } const capability = assignmentCapability(kind); const allowed = new Set(getModelCandidates(workspace, mode, capability).map((candidate) => candidate.id)); if (!allowed.has(candidateId)) return workspace; const next = cloneCatalog(workspace.catalog); - const key = kind === "image" ? "imageGeneration" : kind; next.modelAssignments[mode][key] = candidateId; return createModelWorkspace(next); } diff --git a/App/frontend/desktop/src/state/tests/model-workspace.test.ts b/App/frontend/desktop/src/state/tests/model-workspace.test.ts index b9a965f44..888035335 100644 --- a/App/frontend/desktop/src/state/tests/model-workspace.test.ts +++ b/App/frontend/desktop/src/state/tests/model-workspace.test.ts @@ -736,6 +736,21 @@ describe("canonical model workspace adapter", () => { expect(assigned.catalog.modelAssignments.byok).toEqual(originalByok); }); + it("清空本地 Embedding Assignment 时保留账号 Assignment 与目录项", () => { + const workspace = createModelWorkspace(catalog()); + const before = modelConfigInput(workspace); + + const cleared = modelConfigInput(setModelAssignment(workspace, "byok", "embedding", null)); + + expect(cleared.modelAssignments.byok).toEqual({ + ...before.modelAssignments.byok, + embedding: null + }); + expect(cleared.modelAssignments.account).toEqual(before.modelAssignments.account); + expect(cleared.providers).toEqual(before.providers); + expect(workspace.catalog.modelAssignments.byok.embedding).toBe("byok-embedding"); + }); + it("删除账号空间可见的共享 BYOK 连接时同步清理两个空间的引用", () => { const result = deleteModelConnection(createModelWorkspace(catalog()), "account", "openai:chat"); diff --git a/Memory/src/config/index.ts b/Memory/src/config/index.ts index 737431f28..becf2f5d5 100644 --- a/Memory/src/config/index.ts +++ b/Memory/src/config/index.ts @@ -806,9 +806,13 @@ function resolveAssignedEmbedding( embedding.mode, DEFAULT_MEMMY_CONFIG.embedding.mode ); + const rawAssignedPreset = mode + ? asRecord(asRecord(rootConfig.modelAssignments)[mode]).embedding + : undefined; + const hasExplicitAssignment = rawAssignedPreset !== undefined && rawAssignedPreset !== null; const resolved = resolveMemoryAssignment(rootConfig, mode, "embedding"); if (!resolved.ok) { - if (embeddingMode !== "local") { + if (hasExplicitAssignment || (mode !== "byok" && embeddingMode !== "local")) { return { ...embedding, provider: "openai_compatible", @@ -818,9 +822,16 @@ function resolveAssignedEmbedding( } return { ...embedding, - mode: embeddingMode, + mode: "local", provider: "local", - sourceProvider: "local" + sourceProvider: "local", + endpoint: undefined, + model: DEFAULT_MEMMY_CONFIG.embedding.model, + apiKey: undefined, + extraHeaders: undefined, + extraBody: undefined, + actualModelContext: undefined, + selectionError: undefined }; } if (!embeddingProtocolSupported(resolved.context.protocol)) { diff --git a/Memory/tests/config.test.ts b/Memory/tests/config.test.ts index db51b0b51..ee44cafad 100644 --- a/Memory/tests/config.test.ts +++ b/Memory/tests/config.test.ts @@ -341,6 +341,88 @@ describe("memmy memory config", () => { expect(config.evolution.thinkingBudget).toBeUndefined(); }); + it("uses local embedding for an absent BYOK assignment despite stale custom mode", () => { + const root = tempRoot(); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ + providers: {}, + modelPresets: {}, + modelAssignments: { + byok: { embedding: null }, + account: {} + }, + app: { userMode: "byok" }, + memmyMemory: { + embedding: { + mode: "custom", + endpoint: "https://embedding.example.com/v1", + model: "text-embedding-3-small", + apiKey: "sk-stale", + extraHeaders: { "X-Stale": "true" }, + extraBody: { dimensions: 1024 } + } + } + })); + + const { config } = loadMemmyConfig(configPath); + + expect(config.embedding).toMatchObject({ + mode: "local", + provider: "local", + sourceProvider: "local", + model: "Xenova/all-MiniLM-L6-v2" + }); + expect(config.embedding.endpoint).toBeUndefined(); + expect(config.embedding.apiKey).toBeUndefined(); + expect(config.embedding.extraHeaders).toBeUndefined(); + expect(config.embedding.extraBody).toBeUndefined(); + expect(config.embedding.selectionError).toBeUndefined(); + }); + + it("does not fall back locally for an explicit invalid BYOK embedding assignment", () => { + const root = tempRoot(); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ + providers: {}, + modelPresets: {}, + modelAssignments: { + byok: { embedding: "missing-embedding-preset" }, + account: {} + }, + app: { userMode: "byok" }, + memmyMemory: { embedding: { mode: "local" } } + })); + + const { config } = loadMemmyConfig(configPath); + + expect(config.embedding.provider).not.toBe("local"); + expect(config.embedding.selectionError).toBe("model_selection_unavailable"); + }); + + it.each([ + ["blank string", " "], + ["number", 42], + ["object", { presetId: "missing" }] + ])("does not treat an explicit invalid %s assignment as absent", (_label, embeddingAssignment) => { + const root = tempRoot(); + const configPath = join(root, "config.yaml"); + writeFileSync(configPath, YAML.stringify({ + providers: {}, + modelPresets: {}, + modelAssignments: { + byok: { embedding: embeddingAssignment }, + account: {} + }, + app: { userMode: "byok" }, + memmyMemory: { embedding: { mode: "local" } } + })); + + const { config } = loadMemmyConfig(configPath); + + expect(config.embedding.provider).not.toBe("local"); + expect(config.embedding.selectionError).toBe("model_selection_unavailable"); + }); + it("rejects a legacy fixed BYOK evolution connection before runtime use", () => { const root = tempRoot(); const configPath = join(root, "config.yaml");