From 2155c4c7732909cd8e592e6a9aa606e61b982ec3 Mon Sep 17 00:00:00 2001 From: kristinhe Date: Tue, 3 Nov 2026 18:36:08 +0800 Subject: [PATCH] feat(memhub): add safe model center Co-authored-by: Cursor --- memhub/apps/web/src/App.setup.test.tsx | 4 + memhub/apps/web/src/App.tsx | 5 + .../src/EmbeddingSwitchExperience.test.tsx | 238 +++++ .../web/src/EmbeddingSwitchExperience.tsx | 441 +++++++++ memhub/apps/web/src/MemorySpace.tsx | 10 +- memhub/apps/web/src/ModelSettings.test.tsx | 188 +++- memhub/apps/web/src/ModelSettings.tsx | 852 +++++++++++------- .../web/src/OnboardingModelController.tsx | 208 +++-- memhub/apps/web/src/R6Settings.test.tsx | 68 +- memhub/apps/web/src/R6Settings.tsx | 216 +++-- .../apps/web/src/api.embedding-switch.test.ts | 242 +++++ memhub/apps/web/src/api.ts | 154 +++- memhub/apps/web/src/embedding-switch.css | 283 ++++++ memhub/apps/web/src/poll-scheduler.ts | 1 + memhub/apps/web/src/r6-settings.css | 104 ++- .../apps/web/src/use-embedding-switch.test.ts | 290 ++++++ memhub/apps/web/src/use-embedding-switch.ts | 267 ++++++ memhub/docs/contracts/embedding-switch-v1.md | 3 + .../contracts/src/embedding-switch.ts | 161 ++++ memhub/packages/contracts/src/index.ts | 28 +- .../src/embedding-switch-operation.test.ts | 240 +++++ .../src/embedding-switch-operation.ts | 455 ++++++++++ memhub/packages/operations/src/index.ts | 1 + 23 files changed, 3922 insertions(+), 537 deletions(-) create mode 100644 memhub/apps/web/src/EmbeddingSwitchExperience.test.tsx create mode 100644 memhub/apps/web/src/EmbeddingSwitchExperience.tsx create mode 100644 memhub/apps/web/src/api.embedding-switch.test.ts create mode 100644 memhub/apps/web/src/embedding-switch.css create mode 100644 memhub/apps/web/src/use-embedding-switch.test.ts create mode 100644 memhub/apps/web/src/use-embedding-switch.ts create mode 100644 memhub/packages/contracts/src/embedding-switch.ts create mode 100644 memhub/packages/operations/src/embedding-switch-operation.test.ts create mode 100644 memhub/packages/operations/src/embedding-switch-operation.ts diff --git a/memhub/apps/web/src/App.setup.test.tsx b/memhub/apps/web/src/App.setup.test.tsx index 1803712..716f1c1 100644 --- a/memhub/apps/web/src/App.setup.test.tsx +++ b/memhub/apps/web/src/App.setup.test.tsx @@ -38,6 +38,10 @@ vi.mock("./api", () => ({ getOnboarding, getModelProfiles: vi.fn().mockResolvedValue([]), getSources: vi.fn().mockResolvedValue(sources), + getCurrentEmbeddingSwitch: vi + .fn() + .mockRejectedValue(new Error("unsupported")), + embeddingSwitchUnsupported: vi.fn().mockReturnValue(true), ...mutations, })); vi.mock("./use-import-sources", () => ({ diff --git a/memhub/apps/web/src/App.tsx b/memhub/apps/web/src/App.tsx index 2a7590e..fc03e27 100644 --- a/memhub/apps/web/src/App.tsx +++ b/memhub/apps/web/src/App.tsx @@ -25,6 +25,7 @@ import { useApplicationsLanguage } from "./applications-language"; import { CallHistory } from "./CallHistory"; import { DebugLogPanel } from "./DebugLogPanel"; import { DesktopFrame } from "./DesktopFrame"; +import { EmbeddingSwitchGlobalStatus } from "./EmbeddingSwitchExperience"; import { useGuiLanguage } from "./gui-language"; import { HealthPanel } from "./HealthPanel"; import { ImportProgressStrip } from "./ImportProgressStrip"; @@ -309,6 +310,10 @@ function AppContent() { ref={content} data-view={displayedView} > + navigate("settings")} + onDiagnostics={() => navigate("health")} + /> {/* First run hands the user in here while the import is still going. * Without this the work would be invisible from every screen except * the one they were no longer required to sit on. */} diff --git a/memhub/apps/web/src/EmbeddingSwitchExperience.test.tsx b/memhub/apps/web/src/EmbeddingSwitchExperience.test.tsx new file mode 100644 index 0000000..f7e7151 --- /dev/null +++ b/memhub/apps/web/src/EmbeddingSwitchExperience.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment jsdom + +import type { + EmbeddingSwitchCurrent, + EmbeddingSwitchJob, + EmbeddingSwitchPlan, +} from "@one-memory/contracts"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; + +const cancelCurrentSwitch = vi.fn(); +let current: EmbeddingSwitchCurrent | null = null; + +vi.mock("./use-embedding-switch", () => ({ + useEmbeddingSwitch: () => ({ + current, + busy: null, + cancelCurrentSwitch, + }), +})); +vi.mock("./api", () => ({ + deleteRetiredEmbeddingSpace: vi.fn(), +})); + +import { + EmbeddingSwitchConfirmDialog, + EmbeddingSwitchGlobalStatus, +} from "./EmbeddingSwitchExperience"; + +const NOW = "2026-11-03T09:00:00Z"; +const space = (id: string | null, model: string) => ({ + space_id: id, + model, + dims: model === "old-search" ? 512 : 1024, + collection_base: id ? "memory" : "", + vectors: id ? 8421 : null, +}); +const plan: EmbeddingSwitchPlan = { + needed: true, + reason: "embedding_identity_changed", + plan_token: "token", + source: space("space-old", "old-search"), + target: space(null, "new-search"), + target_profile_id: "profile-1", + target_revision: 2, + target_provider_kind: "openai_compatible", + target_local: false, + sources: [ + { + space_id: "space-old", + collection_base: "memory", + dims: 512, + vectors: 8421, + unmerged: null, + }, + ], + total_vectors: 8421, + users: 1, + graph_vectors: null, + estimate_s: 120, + requests: 43, + disk_bytes_needed: 72 * 1024 * 1024, + disk_bytes_free: 1024 * 1024 * 1024, + retention_days: 14, + blockers: [], +}; +const job: EmbeddingSwitchJob = { + job_id: "job-1", + kind: "switch", + state: "reembedding", + source: space("space-old", "old-search"), + target: space("space-new", "new-search"), + stray_space_ids: [], + target_profile_id: "profile-1", + target_revision: 2, + requested_by: "app", + progress: { + phase: "vectors", + done: 4884, + total: 8421, + percent: 58, + embedded: 4884, + reused: 0, + kept: 0, + role: "memory", + source_index: 0, + sources: 1, + rate_per_s: 90, + eta_s: 52, + updated_at: NOW, + }, + cancel_requested: false, + error_code: null, + attempt: 1, + created_at: NOW, + updated_at: NOW, + finished_at: null, +}; + +afterEach(() => { + cleanup(); + current = null; + vi.clearAllMocks(); +}); + +it("explains the index update in user terms before exposing technical details", async () => { + const onStart = vi.fn(); + render( + , + ); + + expect( + screen.getByRole("heading", { name: "更新记忆搜索索引?" }), + ).toBeTruthy(); + expect(screen.getByText("8,421 条")).toBeTruthy(); + expect(screen.getByText("约 2 分钟")).toBeTruthy(); + expect(screen.getByText(/失败或取消时仍使用原来的模型/)).toBeTruthy(); + await userEvent.click(screen.getByText("查看技术详情")); + expect(screen.getByText("512 → 1024")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "开始更新索引" })); + expect(onStart).toHaveBeenCalledTimes(1); +}); + +it("blocks start when Core reports a prerequisite", () => { + render( + , + ); + expect(screen.getByText(/剩余空间不足/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "开始更新索引" })).toHaveProperty( + "disabled", + true, + ); +}); + +it("keeps a running switch visible and cancellable outside settings", async () => { + current = { + schema_version: 1, + phase: "running", + writes_paused: true, + scan_state: "done", + scan_error: null, + job, + last_finished: null, + }; + render( + , + ); + + expect(screen.getByText("正在更新记忆索引")).toBeTruthy(); + expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe( + "58", + ); + await userEvent.click(screen.getByRole("button", { name: "取消更新" })); + expect(cancelCurrentSwitch).toHaveBeenCalledTimes(1); +}); + +it("shows startup index inspection without blocking the rest of the app", () => { + current = { + schema_version: 1, + phase: "scanning", + writes_paused: true, + scan_state: "scanning", + scan_error: null, + job: null, + last_finished: null, + }; + render( + , + ); + + expect(screen.getByText("正在检查本机的记忆搜索索引")).toBeTruthy(); + expect(screen.getByText(/搜索仍可使用/)).toBeTruthy(); +}); + +it("explains rollback safety and gives recovery actions", async () => { + current = { + schema_version: 1, + phase: "running", + writes_paused: true, + scan_state: "done", + scan_error: null, + job, + last_finished: null, + }; + const onConfigureSearch = vi.fn(); + const onDiagnostics = vi.fn(); + const view = render( + , + ); + + current = { + schema_version: 1, + phase: "idle", + writes_paused: false, + scan_state: "done", + scan_error: null, + job: null, + last_finished: { + ...job, + state: "rolled_back", + error_code: "embedding_service_failed", + progress: { ...job.progress, phase: "done" }, + finished_at: NOW, + }, + }; + view.rerender( + , + ); + + await waitFor(() => expect(screen.getByText(/记忆没有丢失/)).toBeTruthy()); + await userEvent.click(screen.getByRole("button", { name: "检查连接" })); + expect(onConfigureSearch).toHaveBeenCalledTimes(1); +}); diff --git a/memhub/apps/web/src/EmbeddingSwitchExperience.tsx b/memhub/apps/web/src/EmbeddingSwitchExperience.tsx new file mode 100644 index 0000000..1391c72 --- /dev/null +++ b/memhub/apps/web/src/EmbeddingSwitchExperience.tsx @@ -0,0 +1,441 @@ +import type { + EmbeddingSwitchCurrent, + EmbeddingSwitchJob, + EmbeddingSwitchPlan, +} from "@one-memory/contracts"; +import { ChevronDown, CircleAlert, Search, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { deleteRetiredEmbeddingSpace } from "./api"; +import { R6Button } from "./R6Controls"; +import { useEmbeddingSwitch } from "./use-embedding-switch"; +import "./embedding-switch.css"; + +function count(value: number | null | undefined) { + return new Intl.NumberFormat().format(value ?? 0); +} + +function duration(seconds: number | null) { + if (seconds === null) return "暂时无法估算"; + if (seconds < 60) return `约 ${Math.max(1, Math.round(seconds))} 秒`; + return `约 ${Math.max(1, Math.round(seconds / 60))} 分钟`; +} + +function size(bytes: number) { + if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`; + return `${Math.ceil(bytes / 1024 / 1024)} MB`; +} + +const blockerCopy: Record = { + embedding_probe_required: "请先测试新的搜索模型连接。", + embedding_dimensions_unknown: + "还无法确认新搜索模型的索引规格,请重新测试连接。", + embedding_switch_requires_migration: + "旧版本数据仍在升级,请等待升级完成后再更换。", + sdk_reembed_unavailable: "当前安装版本还不能安全更新记忆索引。", + insufficient_disk_space: "本机剩余空间不足,无法安全建立新索引。", + embedding_switch_running: "另一次索引更新正在进行。", + memory_lifecycle_busy: "记忆正在整合,请等待完成后再更换搜索模型。", +}; + +const failureCopy: Record = { + writes_did_not_drain: "等待正在进行的记忆整理结束超时。", + embedding_service_failed: "搜索模型服务连续请求失败。", + embedding_dimensions_mismatch: "搜索服务返回的索引规格与测试结果不一致。", + embedding_verification_failed: "新的记忆索引没有通过完整性校验。", + target_profile_changed: "更新期间搜索模型配置发生了变化。", + embedding_switch_cancelled: "索引更新已取消。", + model_secret_unavailable: "无法读取搜索服务的 API Key。", + secure_storage_unavailable: "系统安全存储暂时不可用。", + embedding_switch_clients_busy: "记忆服务一直被占用,暂时无法完成切换。", +}; + +export function EmbeddingSwitchConfirmDialog({ + plan, + busy, + errorCode, + onCancel, + onStart, +}: { + plan: EmbeddingSwitchPlan; + busy: boolean; + errorCode: string | null; + onCancel(): void; + onStart(): void; +}) { + const panel = useRef(null); + const blockers = plan.blockers.map( + (code) => blockerCopy[code] ?? "当前状态暂时不允许更换搜索模型。", + ); + + useEffect(() => { + const opener = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + panel.current + ?.querySelector("button:not(:disabled)") + ?.focus(); + return () => { + if (opener?.isConnected) opener.focus(); + }; + }, []); + + return ( +
+
{ + if (event.key === "Escape" && !busy) onCancel(); + if (event.key !== "Tab" || !panel.current) return; + const controls = [ + ...panel.current.querySelectorAll( + "button:not(:disabled), summary, input:not(:disabled), select:not(:disabled)", + ), + ]; + const first = controls[0]; + const last = controls.at(-1); + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }} + > +
+ +
+

更新记忆搜索索引?

+

+ 不同搜索模型建立的索引不能混用。为了继续找到已有记忆,需要重新建立索引。 +

+
+
+ +
+
+
已有记忆
+
+ {count(plan.total_vectors)} 条 + {plan.users && plan.users > 1 + ? ` · ${count(plan.users)} 位用户` + : ""} +
+
+
+
预计耗时
+
{duration(plan.estimate_s)}
+
+
+
远程请求
+
+ {plan.target_local + ? "不会产生" + : plan.requests === null + ? "按服务商计费" + : `约 ${count(plan.requests)} 次`} +
+
+
+ +
+ 更新期间仍可搜索记忆 +

+ MemHub + 会继续使用原来的搜索模型。新对话仍会保存,更新完成后再整理成记忆。 +

+ 失败不会影响已有数据 +

+ 只有全部更新并校验通过后才会切换;失败或取消时仍使用原来的模型。 +

+
+ +
+ + +
+
+
当前搜索模型
+
{plan.source.model ?? "旧版本索引"}
+
+
+
新的搜索模型
+
{plan.target.model ?? "待确认"}
+
+
+
索引维度
+
+ {plan.source.dims ?? "未知"} → {plan.target.dims ?? "未知"} +
+
+
+
额外磁盘空间
+
{size(plan.disk_bytes_needed)}
+
+
+
旧索引保留
+
{plan.retention_days} 天
+
+
+
+ + {blockers.length > 0 ? ( +
+
+ ) : null} + {errorCode ? ( +
+
+ ) : null} + +
+ + 暂不更换 + + 0} + onClick={onStart} + > + 开始更新索引 + +
+
+
+ ); +} + +function jobTitle(job: EmbeddingSwitchJob) { + if (job.kind === "merge") return "正在整理旧版本留下的搜索索引"; + if (job.state === "preparing") return "正在准备更新记忆索引"; + if (job.state === "reembedding") return "正在更新记忆索引"; + if (job.state === "verifying") return "正在校验新的记忆索引"; + if (job.state === "committing") return "正在启用新的搜索模型"; + if (job.state === "failed") return "更新没有完成,正在恢复原设置"; + return "记忆索引更新"; +} + +function canCancel(job: EmbeddingSwitchJob) { + return ["preparing", "reembedding", "verifying"].includes(job.state); +} + +function ActiveSwitchBanner({ + current, + onCancel, + cancelling, +}: { + current: EmbeddingSwitchCurrent; + onCancel(): void; + cancelling: boolean; +}) { + const job = current.job; + if (!job && current.phase !== "scanning") return null; + const percent = job?.progress.percent ?? 0; + return ( + + ); +} + +export function EmbeddingSwitchGlobalStatus({ + onConfigureSearch, + onDiagnostics, +}: { + onConfigureSearch(): void; + onDiagnostics(): void; +}) { + const controller = useEmbeddingSwitch(); + const [recent, setRecent] = useState(null); + const [cleaning, setCleaning] = useState(false); + const [cleanupError, setCleanupError] = useState(false); + const previousJob = useRef(null); + const alive = useRef(true); + + useEffect( + () => () => { + alive.current = false; + }, + [], + ); + + useEffect(() => { + const open = controller.current?.job?.job_id ?? null; + if ( + previousJob.current && + !open && + controller.current?.last_finished?.job_id === previousJob.current + ) { + setRecent(controller.current.last_finished); + } + previousJob.current = open; + }, [controller.current]); + + return ( + <> + {controller.current ? ( + void controller.cancelCurrentSwitch()} + /> + ) : null} + {controller.refreshFailed && controller.supported === true ? ( + + ) : null} + {controller.current && + ["failed", "unavailable"].includes(controller.current.scan_state) ? ( + + ) : null} + {recent?.state === "done" ? ( + + ) : null} + {cleanupError ? ( +

+ 旧索引暂时无法清理,MemHub 会在保留期结束后自动重试。 +

+ ) : null} + {recent?.state === "rolled_back" ? ( + + ) : null} + + ); +} diff --git a/memhub/apps/web/src/MemorySpace.tsx b/memhub/apps/web/src/MemorySpace.tsx index c4b995a..974b13f 100644 --- a/memhub/apps/web/src/MemorySpace.tsx +++ b/memhub/apps/web/src/MemorySpace.tsx @@ -1398,7 +1398,7 @@ function message(reason: unknown, t: Translate): string { reason.message === "memory_answer_context_changed" ? reason.message : apiErrorCode(reason); - const labels: Record[0]> = { + const labels: Record = { memory_answer_context_changed: "memory:记忆内容或使用权限已变化,回答已停止。请重新提问。", memory_source_revoked: "memory:这条记忆的来源授权已撤回,暂时不能读取。", @@ -1411,8 +1411,14 @@ function message(reason: unknown, t: Translate): string { memory_request_conflict: "memory:这次操作与已完成的请求不一致。", request_validation_failed: "memory:输入内容不符合要求。", local_store_unavailable: "memory:本地记忆库正忙,请稍后重试。", + embedding_unavailable: + "memory:搜索服务暂时不可用,无法检索记忆。请到设置中检查记忆搜索配置。", + embedding_switch_in_progress: + "memory:正在更新记忆索引,完成后才能修改或删除记忆。搜索仍可使用。", + embedding_space_profile_missing: + "memory:当前搜索模型配置已不存在,请到设置中重新配置记忆搜索。", }; - if (code && labels[code]) return t(labels[code]); + if (code && labels[code]) return t(labels[code] as Parameters[0]); // No code at all means the request never reached the core — usually because // it is still starting. Saying only "failed" left the one case that resolves // itself looking like the ones that do not. diff --git a/memhub/apps/web/src/ModelSettings.test.tsx b/memhub/apps/web/src/ModelSettings.test.tsx index c70196c..e357992 100644 --- a/memhub/apps/web/src/ModelSettings.test.tsx +++ b/memhub/apps/web/src/ModelSettings.test.tsx @@ -1,6 +1,12 @@ // @vitest-environment jsdom -import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts"; +import type { + EmbeddingSwitchCurrent, + EmbeddingSwitchJob, + EmbeddingSwitchPlan, + ModelProbeResult, + ModelProfile, +} from "@one-memory/contracts"; import { act, cleanup, @@ -66,6 +72,10 @@ const probeProfile = vi.fn(); const activate = vi.fn(); const remove = vi.fn(); const update = vi.fn(); +const planActivation = vi.fn(); +const startPlannedSwitch = vi.fn(); +let switchPlan: EmbeddingSwitchPlan | null = null; +let switchCurrent: EmbeddingSwitchCurrent | null = null; it("explains an insufficient model balance with the required recovery", () => { expect(probeLabel("insufficient_balance")).toBe( @@ -114,6 +124,71 @@ const preset: ModelProfile = { has_api_key: false, disable_thinking: true, }; +const migrationPlan: EmbeddingSwitchPlan = { + needed: true, + reason: "embedding_identity_changed", + plan_token: "plan-token", + source: { + space_id: "old-space", + model: "old-search", + dims: 512, + collection_base: "memory", + vectors: 12, + }, + target: { + space_id: null, + model: "new-search", + dims: 1024, + collection_base: "", + vectors: null, + }, + target_profile_id: profile.profile_id, + target_revision: profile.revision, + target_provider_kind: "openai_compatible", + target_local: false, + sources: [], + total_vectors: 12, + users: 1, + graph_vectors: null, + estimate_s: 10, + requests: 1, + disk_bytes_needed: 1024, + disk_bytes_free: 4096, + retention_days: 14, + blockers: [], +}; +const oldFinishedJob: EmbeddingSwitchJob = { + job_id: "old-job", + kind: "switch", + state: "done", + source: migrationPlan.source, + target: { ...migrationPlan.target, space_id: "new-space", vectors: 12 }, + stray_space_ids: [], + target_profile_id: profile.profile_id, + target_revision: profile.revision, + requested_by: "app", + progress: { + phase: "done", + done: 12, + total: 12, + percent: 100, + embedded: 12, + reused: 0, + kept: 0, + role: null, + source_index: 0, + sources: 1, + rate_per_s: null, + eta_s: 0, + updated_at: "2026-11-03T09:00:00Z", + }, + cancel_requested: false, + error_code: null, + attempt: 1, + created_at: "2026-11-03T09:00:00Z", + updated_at: "2026-11-03T09:00:01Z", + finished_at: "2026-11-03T09:00:01Z", +}; /** Re-renders on demand so a cleared journal is picked up, like a real refresh. */ function ProbeRecoveryHarness({ @@ -151,6 +226,19 @@ vi.mock("./use-model-profiles", () => ({ update, }), })); +vi.mock("./use-embedding-switch", () => ({ + useEmbeddingSwitch: () => ({ + current: switchCurrent, + plan: switchPlan, + busy: null, + errorCode: null, + planActivation, + startPlannedSwitch, + shouldUseLegacyActivation: () => false, + clearPlan: vi.fn(), + clearError: vi.fn(), + }), +})); describe("ModelSettings", () => { it("exposes distinct edit, add and test actions from the same owner without activating", async () => { @@ -187,8 +275,14 @@ describe("ModelSettings", () => { probes = {}; pendingProbe = null; profiles = [profile]; + switchPlan = null; + switchCurrent = null; create.mockResolvedValue(profile); update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 }); + planActivation.mockResolvedValue({ + needed: false, + reason: "same_embedding", + }); }); it("submits a generation-only profile and leaves embedding to the builtin", async () => { @@ -399,6 +493,62 @@ describe("ModelSettings", () => { }); }); + it("focuses generation without exposing or patching the search lane", async () => { + update.mockResolvedValue(profile); + render(); + + await userEvent.click(screen.getByRole("button", { name: "编辑配置" })); + expect(screen.getByRole("heading", { name: "记忆整理" })).toBeTruthy(); + expect(screen.queryByLabelText("搜索模型")).toBeNull(); + expect(screen.getByLabelText("生成模型名")).toHaveProperty( + "value", + profile.model, + ); + + await userEvent.click(screen.getByRole("button", { name: "保存修改" })); + expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, { + expected_revision: profile.revision, + }); + }); + + it("does not repair a hidden legacy search endpoint while editing generation", async () => { + const legacy = { + ...profile, + embedding_provider_kind: "openai_compatible" as const, + embedding_model: "remote-search", + embedding_base_url: null, + }; + profiles = [legacy]; + update.mockResolvedValue(legacy); + render(); + + await userEvent.click(screen.getByRole("button", { name: "编辑配置" })); + await userEvent.type(screen.getByLabelText("生成模型名"), "-new"); + await userEvent.click(screen.getByRole("button", { name: "保存修改" })); + + expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, { + expected_revision: profile.revision, + model: "memory-model-new", + }); + }); + + it("focuses search on the recommended builtin without exposing or patching generation", async () => { + update.mockResolvedValue(profile); + render(); + + await userEvent.click(screen.getByRole("button", { name: "编辑配置" })); + expect(screen.getByRole("heading", { name: "记忆搜索" })).toBeTruthy(); + expect(screen.queryByLabelText("生成模型名")).toBeNull(); + expect(screen.getByLabelText("搜索模型")).toHaveProperty("value", "local"); + expect(screen.getByText(/无需密钥/)).toBeTruthy(); + expect(screen.getByText("技术类型:Embedding")).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: "保存修改" })); + expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, { + expected_revision: profile.revision, + }); + }); + it("does not replace an edit draft with a newer external profile revision", async () => { const view = render(); await userEvent.click(screen.getByRole("button", { name: "编辑配置" })); @@ -661,9 +811,45 @@ describe("ModelSettings", () => { expect(container.textContent).not.toContain("secret-value"); await user.click(screen.getByRole("button", { name: "设为当前模型" })); + expect(planActivation).toHaveBeenCalledWith("profile-1", 1); expect(activate).toHaveBeenCalledWith("profile-1"); }); + it("asks before activation when the search index must be rebuilt", async () => { + profiles = [ + { + ...profile, + capabilities: { + ...profile.capabilities, + endpoint_reachable: true, + model_available: true, + embedding_available: true, + }, + }, + ]; + planActivation.mockImplementationOnce(async () => { + switchPlan = migrationPlan; + return migrationPlan; + }); + switchCurrent = { + schema_version: 1, + phase: "idle", + writes_paused: false, + scan_state: "done", + scan_error: null, + job: null, + last_finished: oldFinishedJob, + }; + render(); + + await userEvent.click(screen.getByRole("button", { name: "设为当前模型" })); + + expect( + await screen.findByRole("heading", { name: "更新记忆搜索索引?" }), + ).toBeTruthy(); + expect(activate).not.toHaveBeenCalled(); + }); + it("hands a successful save to its card, clears only the key, and never tests or activates", async () => { profiles = []; const user = userEvent.setup(); diff --git a/memhub/apps/web/src/ModelSettings.tsx b/memhub/apps/web/src/ModelSettings.tsx index e7b1cef..0d251e4 100644 --- a/memhub/apps/web/src/ModelSettings.tsx +++ b/memhub/apps/web/src/ModelSettings.tsx @@ -16,9 +16,11 @@ import { useRef, useState, } from "react"; +import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience"; import { OptionMenu } from "./OptionMenu"; import { useOnboardingTranslation } from "./onboarding-language"; import { PROVIDER_PRESETS } from "./provider-presets"; +import { useEmbeddingSwitch } from "./use-embedding-switch"; import { useModelProfiles } from "./use-model-profiles"; import "./model-setup-next.css"; @@ -216,6 +218,8 @@ export interface ModelSettingsActions { test(profileId: string): Promise; } +export type ModelSettingsFocusSection = "generation" | "embedding"; + /** What the owner asked for before the fence said no. */ type ModelOpenIntent = | { kind: "add" } @@ -225,15 +229,18 @@ export function ModelSettings({ controlsRef, onProfilesChanged, compactSingle = false, + focusSection, onSetupComplete, }: { controlsRef?: Ref; onProfilesChanged?(): void; compactSingle?: boolean; + focusSection?: ModelSettingsFocusSection; onSetupComplete?(): void; } = {}) { const { text } = useOnboardingTranslation(); const models = useModelProfiles(); + const embeddingSwitch = useEmbeddingSwitch(); const [provider, setProvider] = useState("openai_compatible"); const [providerPreset, setProviderPreset] = useState("custom"); const [advancedEmbedding, setAdvancedEmbedding] = useState(false); @@ -263,6 +270,9 @@ export function ModelSettings({ null, ); const [setupError, setSetupError] = useState(null); + const [switchTarget, setSwitchTarget] = useState(null); + const [switchJobId, setSwitchJobId] = useState(null); + const settledSwitchJob = useRef(null); const flight = useRef(false); const alive = useRef(true); const queuedOpen = useRef(null); @@ -274,6 +284,41 @@ export function ModelSettings({ alive.current = false; }; }, []); + useEffect(() => { + const finished = embeddingSwitch.current?.last_finished; + if ( + !switchTarget || + !switchJobId || + !finished || + finished.job_id !== switchJobId || + settledSwitchJob.current === finished.job_id + ) + return; + settledSwitchJob.current = finished.job_id; + if (finished.state === "done") { + void models.refreshProfiles(); + setSetupError(null); + setSwitchTarget(null); + setSwitchJobId(null); + onProfilesChanged?.(); + return; + } + if (finished.state === "rolled_back") { + setSetupError( + finished.error_code === "embedding_switch_cancelled" + ? "索引更新已取消,仍在使用原来的搜索模型。" + : "索引更新没有完成,仍在使用原来的搜索模型,记忆没有丢失。", + ); + setSwitchTarget(null); + setSwitchJobId(null); + } + }, [ + embeddingSwitch.current?.last_finished, + models.refreshProfiles, + onProfilesChanged, + switchJobId, + switchTarget, + ]); useLayoutEffect(() => { if (saved) savedCard.current?.focus({ preventScroll: true }); }, [saved]); @@ -452,6 +497,43 @@ export function ModelSettings({ onProfilesChanged?.(); }, [profileSnapshot, onProfilesChanged]); + async function activateWithPlan( + profile: ModelProfile, + result: ModelProbeResult | null, + ): Promise { + setSetupPhase("activating"); + const switchPlan = embeddingSwitch.shouldUseLegacyActivation() + ? null + : await embeddingSwitch.planActivation( + profile.profile_id, + profile.revision, + ); + if (!alive.current) return false; + if (!switchPlan && !embeddingSwitch.shouldUseLegacyActivation()) { + setSetupError("暂时无法检查搜索模型更换影响,配置已保存,请稍后重试。"); + return false; + } + if (switchPlan?.needed) { + setSwitchTarget(profile); + setSetupPhase(null); + return false; + } + const active = await models.activate(profile.profile_id); + if (!alive.current) return false; + if ( + !active || + active.profile_id !== profile.profile_id || + !active.is_active || + !ready(active, result) + ) { + setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。"); + return false; + } + if (profile.name !== PRESET_PROFILE_NAME) setSaved(active); + onSetupComplete?.(); + return true; + } + async function finishSetup(profile: ModelProfile): Promise { setSetupPhase("testing"); const result = await models.probeProfile(profile.profile_id); @@ -487,21 +569,24 @@ export function ModelSettings({ setFields(probeFieldErrors(result)); return false; } - setSetupPhase("activating"); - const active = await models.activate(profile.profile_id); - if (!alive.current) return false; - if ( - !active || - active.profile_id !== profile.profile_id || - !active.is_active || - !ready(active, result) - ) { - setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。"); - return false; + return activateWithPlan(profile, result); + } + + async function activateTested(profile: ModelProfile) { + const result = models.probeFor(profile); + if (!isReady(profile) || flight.current || locked) return; + flight.current = true; + setSaving(true); + setSetupError(null); + try { + await activateWithPlan(profile, result); + } finally { + flight.current = false; + if (alive.current) { + setSaving(false); + setSetupPhase(null); + } } - if (profile.name !== PRESET_PROFILE_NAME) setSaved(active); - onSetupComplete?.(); - return true; } async function resumeSetup(profile: ModelProfile, key?: string) { @@ -572,12 +657,14 @@ export function ModelSettings({ ) return; const errors: Partial> = {}; - if (!model.trim()) errors.model = "请填写生成模型名。"; - if (!validEndpoint(baseUrl)) - errors["base-url"] = "请填写完整的 http:// 或 https:// 接口地址。"; - const credentialError = keyError(apiKey, keyRequired); - if (credentialError) errors["api-key"] = credentialError; - if (embeddingMode === "custom") { + if (focusSection !== "embedding") { + if (!model.trim()) errors.model = "请填写生成模型名。"; + if (!validEndpoint(baseUrl)) + errors["base-url"] = "请填写完整的 http:// 或 https:// 接口地址。"; + const credentialError = keyError(apiKey, keyRequired); + if (credentialError) errors["api-key"] = credentialError; + } + if (focusSection !== "generation" && embeddingMode === "custom") { if (!embeddingModel.trim()) errors["embedding-model"] = "请填写向量模型名。"; if (!validEndpoint(embeddingBaseUrl)) @@ -619,14 +706,16 @@ export function ModelSettings({ ? { expected_revision: editing.revision } : { expected_revision: 0 }; if (editing) { - if (input.name !== editing.name) patch.name = input.name; - if (input.model !== editing.model) patch.model = input.model; - if (input.base_url !== editing.base_url) - patch.base_url = input.base_url; - if (input.disable_thinking !== editing.disable_thinking) - patch.disable_thinking = input.disable_thinking; - if (apiKey) patch.api_key = apiKey; - if (embeddingMode === "custom") { + if (focusSection !== "embedding") { + if (input.name !== editing.name) patch.name = input.name; + if (input.model !== editing.model) patch.model = input.model; + if (input.base_url !== editing.base_url) + patch.base_url = input.base_url; + if (input.disable_thinking !== editing.disable_thinking) + patch.disable_thinking = input.disable_thinking; + if (apiKey) patch.api_key = apiKey; + } + if (focusSection !== "generation" && embeddingMode === "custom") { if (embeddingModel !== editing.embedding_model) patch.embedding_model = embeddingModel; if (embeddingProvider !== editing.embedding_provider_kind) @@ -636,6 +725,7 @@ export function ModelSettings({ if (embeddingBaseUrl !== (editing.embedding_base_url ?? "")) patch.embedding_base_url = embeddingBaseUrl; } else if ( + focusSection !== "generation" && embeddingModeChanged && editing.embedding_provider_kind !== BUILTIN_EMBEDDING_KIND ) { @@ -699,11 +789,23 @@ export function ModelSettings({

02

-

{text("记忆模型")}

+

+ {text( + focusSection === "generation" + ? "记忆整理" + : focusSection === "embedding" + ? "记忆搜索" + : "记忆模型", + )} +

{text( - "只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2 分开选模型。", + focusSection === "generation" + ? "配置用于理解会话、整理记忆的模型。" + : focusSection === "embedding" + ? "推荐使用本地内置搜索;需要连接自定义服务时再展开配置。" + : "只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2 分开选模型。", )}

@@ -956,326 +1058,359 @@ export function ModelSettings({ {text("刷新配置列表")} )} -
- {text("服务商预设")} - ({ - value: item.id, - label: item.label, - })), - { value: "custom", label: text("自定义兼容服务") }, - ]} - onChange={(value) => { - const next = PROVIDER_PRESETS.find((item) => item.id === value); - setProviderPreset(value); - if (!next) return; - setProvider(next.provider); - setBaseUrl(next.baseUrl); - setModel(next.model); - setApiKey(""); - setDisableThinking(true); - if (editing) { - setName(next.label); - setNameEdited(true); - } else if (!nameEdited) { - setName(suggestedName(next.model, next.provider)); - } - setFields({}); - setSaveError(null); - }} - /> - - {text( - "只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。 请对照服务商的模型列表核对后再保存。", - )} - -
- - - - {/* Disclosure only changes visibility; mode determines what is saved. */} -
- - {advancedEmbedding ? ( - <> - - {embeddingMode === "custom" ? ( - <> - - - - {embeddingProvider === "openai_compatible" ? ( - - {text("为避免凭据外泄,向量地址必须与生成模型同源。")} - - ) : null} - - ) : ( - - {text("使用内置本地向量模型;不会发送内容给远程向量服务。")} + {focusSection !== "embedding" && ( + <> +
+ {text("服务商预设")} + ({ + value: item.id, + label: item.label, + })), + { value: "custom", label: text("自定义兼容服务") }, + ]} + onChange={(value) => { + const next = PROVIDER_PRESETS.find( + (item) => item.id === value, + ); + setProviderPreset(value); + if (!next) return; + setProvider(next.provider); + setBaseUrl(next.baseUrl); + setModel(next.model); + setApiKey(""); + setDisableThinking(true); + if (editing) { + setName(next.label); + setNameEdited(true); + } else if (!nameEdited) { + setName(suggestedName(next.model, next.provider)); + } + setFields({}); + setSaveError(null); + }} + /> + + {text( + "只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。 请对照服务商的模型列表核对后再保存。", + )} + +
+ + +
- - + + )} + {/* Disclosure only changes visibility; mode determines what is saved. */} + {focusSection !== "generation" && ( +
+ {focusSection === "embedding" && ( +
+ {text("搜索方式")} +

+ {text( + "本地内置适合大多数人:无需密钥,内容不会发送到远程向量服务。", + )} +

+
)} -
- {fields["api-key"] && ( - - {text(fields["api-key"])} - - )} - - + {focusSection !== "embedding" && ( + + )} + {focusSection === "embedding" || advancedEmbedding ? ( + <> + + {text("技术类型:Embedding")} + {embeddingMode === "custom" ? ( + <> + + + + {embeddingProvider === "openai_compatible" ? ( + + {text("为避免凭据外泄,向量地址必须与生成模型同源。")} + + ) : null} + + ) : ( + + {text( + "使用内置本地向量模型;不会发送内容给远程向量服务。", + )} + + )} + + ) : null} + {focusSection !== "embedding" && + !advancedEmbedding && + embeddingMode === "custom" ? ( +

+ {text("自定义向量")}: {embeddingModel || text("尚未填写模型")}{" "} + · {embeddingBaseUrl || text("尚未填写地址")} +

+ ) : null} +
+ )} + {focusSection !== "embedding" && ( + <> + + + + + )}
{!compactSingle && ( @@ -1421,6 +1556,27 @@ export function ModelSettings({ ))}
)} + {embeddingSwitch.plan?.needed && switchTarget ? ( + { + embeddingSwitch.clearPlan(); + embeddingSwitch.clearError(); + setSwitchTarget(null); + setSwitchJobId(null); + }} + onStart={() => { + void embeddingSwitch.startPlannedSwitch().then((job) => { + if (!job || !alive.current) return; + setSwitchJobId(job.job_id); + setSetupError(null); + onSetupComplete?.(); + }); + }} + /> + ) : null} ); } diff --git a/memhub/apps/web/src/OnboardingModelController.tsx b/memhub/apps/web/src/OnboardingModelController.tsx index bb6c961..b125dcf 100644 --- a/memhub/apps/web/src/OnboardingModelController.tsx +++ b/memhub/apps/web/src/OnboardingModelController.tsx @@ -3,8 +3,9 @@ import type { ModelProfile, ModelProfileInput, } from "@one-memory/contracts"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { getModelProfiles } from "./api"; +import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience"; import { type OnboardingModelDraft, OnboardingModelStep, @@ -12,6 +13,7 @@ import { import { useOnboardingTranslation } from "./onboarding-language"; import { PROVIDER_PRESETS } from "./provider-presets"; import { traceUiAction } from "./ui-trace"; +import { useEmbeddingSwitch } from "./use-embedding-switch"; import { useModelProfiles } from "./use-model-profiles"; const defaultPreset = PROVIDER_PRESETS.find( @@ -68,6 +70,7 @@ export function OnboardingModelController({ }) { const { text, t } = useOnboardingTranslation(); const models = useModelProfiles(); + const embeddingSwitch = useEmbeddingSwitch(); const [draft, setDraft] = useState(initial); const [advanced, setAdvanced] = useState(false); const [saved, setSaved] = useState(null); @@ -82,8 +85,32 @@ export function OnboardingModelController({ state: "passed" | "failed" | "stale"; message: string; } | null>(null); + const [switchPendingJobId, setSwitchPendingJobId] = useState( + null, + ); const flight = useRef(false); + useEffect(() => { + const finished = embeddingSwitch.current?.last_finished; + if ( + !switchPendingJobId || + !finished || + finished.job_id !== switchPendingJobId + ) + return; + if (finished.state === "done") { + flight.current = false; + setSwitchPendingJobId(null); + setPhase("idle"); + onEnabled?.(); + } else if (finished.state === "rolled_back") { + flight.current = false; + setSwitchPendingJobId(null); + setPhase("idle"); + setError("索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。"); + } + }, [embeddingSwitch.current?.last_finished, onEnabled, switchPendingJobId]); + function edit(patch: Partial) { if (flight.current) return; if (patch.provider_id && patch.provider_id !== draft.provider_id) { @@ -237,6 +264,18 @@ export function OnboardingModelController({ setError(null); const trace = traceUiAction("memory", "submit"); try { + const plan = embeddingSwitch.shouldUseLegacyActivation() + ? null + : await embeddingSwitch.planActivation( + saved.profile_id, + saved.revision, + ); + if (!plan && !embeddingSwitch.shouldUseLegacyActivation()) + throw new Error("embedding_switch_plan_unconfirmed"); + if (plan?.needed) { + trace.done("succeeded", { reason: "embedding_switch_confirmation" }); + return; + } const active = await models.activate(saved.profile_id); if (!active?.is_active) throw new Error("model_activation_unconfirmed"); trace.done("succeeded"); @@ -251,76 +290,101 @@ export function OnboardingModelController({ } return ( - ({ - id: preset.id, - label: preset.label, - keyRequired: preset.provider !== "ollama", - supported: true, - })), - { - id: "custom", - label: "自定义兼容服务", - keyRequired: true, - supported: true, - }, - ]} - advancedOpen={advanced} - onAdvancedChange={setAdvanced} - onDraftChange={edit} - phase={phase} - capability={{ saveAndEnable: true }} - canSaveAndEnable={probe?.state === "passed" && !!saved} - canTest={!models.pendingProbe && !models.probeStorageFailed} - canSaveDraft={!models.pendingCreation && !models.creationStorageFailed} - inputsLocked={flight.current} - fieldErrors={Object.fromEntries( - Object.entries(fields).map(([key, value]) => [ - key, - value === "onboardingIntegerRange" - ? t( - "onboardingIntegerRange", - key === "timeout_seconds" - ? { min: 1, max: 600 } - : key === "max_concurrency" - ? { min: 1, max: 32 } - : { min: 1024, max: 10_000_000 }, - ) - : text(value), - ]), - )} - error={error ?? (models.error ? text(models.error) : null)} - probe={probe} - profile={ - saved - ? { - profile_id: saved.profile_id, - revision: saved.revision, - has_configured_api_key: - saved.has_configured_api_key ?? saved.has_api_key, - credential_state: saved.credential_state, - } - : null - } - sourceNames={sourceNames} - importedConversationCount={importedConversationCount} - connectionSummary={ - connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接" - } - generationRecipient={draft.base_url || "尚未填写接收方"} - embeddingRecipient={ - draft.embedding_mode === "local" - ? "本机内置向量模型" - : draft.embedding_base_url || draft.base_url || "尚未填写接收方" - } - onSaveDraft={() => save(false)} - onTest={() => save(true)} - onSaveAndEnable={enable} - onBack={onBack} - onSkip={onSkip} - onActionError={() => setError("操作未确认,草稿已保留。")} - /> + <> + ({ + id: preset.id, + label: preset.label, + keyRequired: preset.provider !== "ollama", + supported: true, + })), + { + id: "custom", + label: "自定义兼容服务", + keyRequired: true, + supported: true, + }, + ]} + advancedOpen={advanced} + onAdvancedChange={setAdvanced} + onDraftChange={edit} + phase={phase} + capability={{ saveAndEnable: true }} + canSaveAndEnable={probe?.state === "passed" && !!saved} + canTest={!models.pendingProbe && !models.probeStorageFailed} + canSaveDraft={!models.pendingCreation && !models.creationStorageFailed} + inputsLocked={flight.current} + fieldErrors={Object.fromEntries( + Object.entries(fields).map(([key, value]) => [ + key, + value === "onboardingIntegerRange" + ? t( + "onboardingIntegerRange", + key === "timeout_seconds" + ? { min: 1, max: 600 } + : key === "max_concurrency" + ? { min: 1, max: 32 } + : { min: 1024, max: 10_000_000 }, + ) + : text(value), + ]), + )} + error={error ?? (models.error ? text(models.error) : null)} + probe={probe} + profile={ + saved + ? { + profile_id: saved.profile_id, + revision: saved.revision, + has_configured_api_key: + saved.has_configured_api_key ?? saved.has_api_key, + credential_state: saved.credential_state, + } + : null + } + sourceNames={sourceNames} + importedConversationCount={importedConversationCount} + connectionSummary={ + connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接" + } + generationRecipient={draft.base_url || "尚未填写接收方"} + embeddingRecipient={ + draft.embedding_mode === "local" + ? "本机内置向量模型" + : draft.embedding_base_url || draft.base_url || "尚未填写接收方" + } + onSaveDraft={() => save(false)} + onTest={() => save(true)} + onSaveAndEnable={enable} + onBack={onBack} + onSkip={onSkip} + onActionError={() => setError("操作未确认,草稿已保留。")} + /> + {embeddingSwitch.plan?.needed && saved ? ( + { + embeddingSwitch.clearPlan(); + embeddingSwitch.clearError(); + }} + onStart={() => { + flight.current = true; + setPhase("enabling"); + void embeddingSwitch.startPlannedSwitch().then((job) => { + if (job) { + setSwitchPendingJobId(job.job_id); + return; + } + flight.current = false; + setPhase("idle"); + }); + }} + /> + ) : null} + ); } diff --git a/memhub/apps/web/src/R6Settings.test.tsx b/memhub/apps/web/src/R6Settings.test.tsx index 99272e3..ab92300 100644 --- a/memhub/apps/web/src/R6Settings.test.tsx +++ b/memhub/apps/web/src/R6Settings.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { Ref } from "react"; import { afterEach, expect, it, vi } from "vitest"; @@ -85,14 +85,25 @@ vi.mock("./ModelSettings", async () => { // Stands in for the real component at its worst: every mutating action is // fenced, and the only way out is the recovery entry that lives inside the // same area the fence used to keep shut. - ModelSettings: ({ controlsRef }: { controlsRef?: Ref }) => { + ModelSettings: ({ + controlsRef, + focusSection, + }: { + controlsRef?: Ref; + focusSection?: "generation" | "embedding"; + }) => { useImperativeHandle(controlsRef, () => ({ edit: () => false, editProfile: () => false, add: () => false, test: () => Promise.resolve(undefined), })); - return createElement("button", { type: "button" }, "核对测试结果"); + return createElement( + "div", + null, + createElement("span", null, focusSection), + createElement("button", { type: "button" }, "核对测试结果"), + ); }, }; }); @@ -181,14 +192,20 @@ it("reports a stop that did not confirm instead of showing it stopped", async () ); // The model configuration is a different store: a failed switch must not // leave the page claiming the model was removed. - expect(screen.getByRole("heading", { name: "当前模型" })).toBeTruthy(); + expect(screen.getByText("当前模型")).toBeTruthy(); }); it("opens the model area even when a pending probe fences the editor, so recovery stays reachable", async () => { const user = userEvent.setup(); render(); - await user.click(await screen.findByRole("button", { name: "修改模型" })); + const generationLane = ( + await screen.findByRole("heading", { name: "记忆整理" }) + ).closest(".r6-model-lane") as HTMLElement | null; + if (!generationLane) throw new Error("Missing generation lane"); + await user.click( + within(generationLane).getByRole("button", { name: "配置" }), + ); // The fence is still reported: the editor really did refuse. const alert = await screen.findByRole("alert"); @@ -203,9 +220,48 @@ it("reaches the same recovery entry when no model has been configured yet", asyn const user = userEvent.setup(); render(); - await user.click(await screen.findByRole("button", { name: "配置模型" })); + const generationLane = ( + await screen.findByRole("heading", { name: "记忆整理" }) + ).closest(".r6-model-lane") as HTMLElement | null; + if (!generationLane) throw new Error("Missing generation lane"); + await user.click( + within(generationLane).getByRole("button", { name: "配置模型" }), + ); expect(screen.getByRole("button", { name: "核对测试结果" })).toBeTruthy(); + const searchLane = screen + .getByRole("heading", { name: "记忆搜索" }) + .closest(".r6-model-lane") as HTMLElement | null; + if (!searchLane) throw new Error("Missing search lane"); + expect( + within(searchLane).getByRole("button", { name: "配置" }), + ).toHaveProperty("disabled", true); + expect(searchLane.textContent).toContain("配置记忆整理后可自定义"); +}); + +it("shows separate compact lanes and opens the requested presentation", async () => { + const user = userEvent.setup(); + render(); + + const generationLane = ( + await screen.findByRole("heading", { name: "记忆整理" }) + ).closest(".r6-model-lane") as HTMLElement | null; + const embeddingLane = screen + .getByRole("heading", { name: "记忆搜索" }) + .closest(".r6-model-lane") as HTMLElement | null; + if (!generationLane || !embeddingLane) throw new Error("Missing model lane"); + + expect(generationLane.textContent).toContain("当前模型"); + expect(generationLane.textContent).toContain("LLM"); + expect(embeddingLane.textContent).toContain("本地内置(推荐)"); + expect(embeddingLane.textContent).toContain("Embedding"); + + await user.click(within(embeddingLane).getByRole("button", { name: "配置" })); + expect(screen.getByText("embedding")).toBeTruthy(); + await user.click( + within(generationLane).getByRole("button", { name: "配置" }), + ); + expect(screen.getByText("generation")).toBeTruthy(); }); it("leaves the switch unusable when the state cannot be read", async () => { diff --git a/memhub/apps/web/src/R6Settings.tsx b/memhub/apps/web/src/R6Settings.tsx index 0ab3a8f..3b360e0 100644 --- a/memhub/apps/web/src/R6Settings.tsx +++ b/memhub/apps/web/src/R6Settings.tsx @@ -13,7 +13,11 @@ import { ConnectionFallback } from "./ConnectionFallback"; import { exportDiagnosticsFile } from "./diagnostics-export"; import { LanguageSelector } from "./LanguageSelector"; import { MemoryModeSettings } from "./MemoryModeSettings"; -import { ModelSettings, type ModelSettingsActions } from "./ModelSettings"; +import { + ModelSettings, + type ModelSettingsActions, + type ModelSettingsFocusSection, +} from "./ModelSettings"; import { useOnboardingTranslation } from "./onboarding-language"; import { R6Button, R6Switch } from "./R6Controls"; import { @@ -68,6 +72,8 @@ export function R6Settings({ const [configurationRefusal, setConfigurationRefusal] = useState(false); const [modelVersion, setModelVersion] = useState(0); const [configurationOpen, setConfigurationOpen] = useState(false); + const [configurationFocus, setConfigurationFocus] = + useState("generation"); const [memoryMode, setMemoryModeState] = useState( null, ); @@ -212,8 +218,11 @@ export function R6Settings({ } } - function openConfiguration() { + function openConfiguration( + focusSection: ModelSettingsFocusSection = "generation", + ) { setConfigurationRefusal(false); + setConfigurationFocus(focusSection); const opened = currentModel ? modelActions.current?.editProfile(currentModel) : modelActions.current?.add(); @@ -298,98 +307,138 @@ export function R6Settings({ > openConfiguration("generation")} onModeChange={setMemoryModeState} /> -

{text("记忆模型")}

+
+
+

{text("记忆模型")}

+

{text("分别管理记忆整理与搜索所使用的模型。")}

+
+ setModelVersion((value) => value + 1)} + > + +
-

{text("当前模型")}

-

- {currentModel?.name ?? - text( - profiles === null - ? "正在读取模型…" - : modelError - ? "模型状态暂不可读" - : "尚未配置模型", +
+
+

{text("记忆整理")}

+

{text("把会话理解并整理成可长期使用的记忆。")}

+ + {currentModel?.name ?? + text( + profiles === null + ? "正在读取模型…" + : modelError + ? "状态暂不可读" + : "尚未配置", + )} + + {currentModel && ( + + LLM · {currentModel.model} ·{" "} + {text(safeEndpoint(currentModel.base_url))} + )} -

- {currentModel && ( -

- {currentModel.model} ·{" "} - {text(safeEndpoint(currentModel.base_url))} -

- )} -
- - {text(currentModel ? "修改模型" : "配置模型")} - +
setModelVersion((value) => value + 1)} + disabled={profiles === null} + onClick={() => openConfiguration("generation")} > -
- {modelError && ( -

- {text( - profiles === null - ? "模型状态未能读取,请重试。" - : "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。", - )} -

- )} - {configurationRefusal && ( -

- {text( - "上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。", - )} -

- )} - {currentModel && memoryMode !== "lite" && ( -
-
-

{text("用模型沉淀记忆")}

-

- {text( - "开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。", - )} -

-
- - void changeSedimentation(enabled) - } - /> +
+
+

{text("记忆搜索")}

+

{text("在需要时找到与你的问题最相关的记忆。")}

+ + {text( + currentModel?.embedding_provider_kind && + currentModel.embedding_provider_kind !== "local_onnx" && + currentModel.embedding_model + ? currentModel.embedding_model + : "本地内置(推荐)", + )} + + + {text( + currentModel?.embedding_provider_kind && + currentModel.embedding_provider_kind !== "local_onnx" + ? `Embedding · ${currentModel.embedding_provider_kind}` + : profiles !== null && !currentModel + ? "Embedding · 配置记忆整理后可自定义" + : "Embedding · 本机处理,无需额外配置", + )} +
- )} - {sedimentationError && ( -

- {text(sedimentationError)} -

- )} - {sedimentationUnavailable && ( -

- {text( - "当前安装还不能读取这个开关,请确认后台服务已启动后重试。", - )} -

- )} + openConfiguration("embedding")} + > + {text("配置")} + +
+ {modelError && ( +

+ {text( + profiles === null + ? "模型状态未能读取,请重试。" + : "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。", + )} +

+ )} + {configurationRefusal && ( +

+ {text( + "上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。", + )} +

+ )} + {currentModel && memoryMode !== "lite" && ( +
+
+

{text("用模型沉淀记忆")}

+

+ {text( + "开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。", + )} +

+
+ + void changeSedimentation(enabled) + } + /> +
+ )} + {sedimentationError && ( +

+ {text(sedimentationError)} +

+ )} + {sedimentationUnavailable && ( +

+ {text( + "当前安装还不能读取这个开关,请确认后台服务已启动后重试。", + )} +

+ )}