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/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/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/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..e3ac86055 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx @@ -752,7 +752,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("未配置"); 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");