diff --git a/memhub/apps/web/src/App.setup.test.tsx b/memhub/apps/web/src/App.setup.test.tsx index 030e8d2..e3efdfb 100644 --- a/memhub/apps/web/src/App.setup.test.tsx +++ b/memhub/apps/web/src/App.setup.test.tsx @@ -68,6 +68,10 @@ vi.mock("./api", () => ({ startEvolve: vi.fn(), apiErrorCode: vi.fn().mockReturnValue(null), getSources: vi.fn().mockResolvedValue(sources), + getCurrentEmbeddingSwitch: vi + .fn() + .mockRejectedValue(new Error("unsupported")), + embeddingSwitchUnsupported: vi.fn().mockReturnValue(true), ...mutations, })); vi.mock("./use-import-sources", () => ({ @@ -149,7 +153,9 @@ it("enters D47 settings without a tour or mandatory second scope-confirmation fl ).toBeNull(); noModelWrites(); expect(screen.queryByRole("button", { name: "管理已有授权" })).toBeNull(); - expect(screen.getByRole("button", { name: "记忆模式与模型" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "界面语言" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "记忆模式" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "模型配置" })).toBeTruthy(); expect(screen.getByRole("button", { name: "连接其他应用" })).toBeTruthy(); expect(screen.getByRole("button", { name: "帮助与诊断" })).toBeTruthy(); noModelWrites(); @@ -212,13 +218,14 @@ it("keeps an open model draft while switching settings groups", async () => { window.history.replaceState({}, "", "/?view=settings"); render(); await waitFor(() => expect(modelProps).toHaveBeenCalled()); + await userEvent.click(screen.getByRole("button", { name: "模型配置" })); await userEvent.click(screen.getByRole("button", { name: "配置模型" })); await userEvent.type( screen.getByLabelText("保留的模型草稿"), "unsaved draft", ); await userEvent.click(screen.getByRole("button", { name: "连接其他应用" })); - await userEvent.click(screen.getByRole("button", { name: "记忆模式与模型" })); + await userEvent.click(screen.getByRole("button", { name: "模型配置" })); expect(window.location.search).toBe("?view=settings"); expect(screen.getByLabelText("保留的模型草稿")).toHaveProperty( "value", diff --git a/memhub/apps/web/src/App.tsx b/memhub/apps/web/src/App.tsx index 2a7590e..bbe1336 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"; @@ -34,6 +35,7 @@ import { OnboardingFlow } from "./OnboardingFlow"; import { R6Button, R6LocalStorage } from "./R6Controls"; import { R6Settings } from "./R6Settings"; import { prefetchDataRuntime } from "./runtime-state"; +import { EmbeddingSwitchProvider } from "./use-embedding-switch"; import "./styles.css"; import "./core-visual.css"; import "./r6-shell.css"; @@ -47,10 +49,12 @@ const rendererStartedAt = Date.now(); export function App() { return ( - - - - + + + + + + ); } @@ -309,6 +313,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..aa9e73f --- /dev/null +++ b/memhub/apps/web/src/EmbeddingSwitchExperience.test.tsx @@ -0,0 +1,289 @@ +// @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"; +import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language"; + +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; + localStorage.removeItem(LANGUAGE_STORAGE_KEY); + vi.clearAllMocks(); +}); + +it("switches Chinese, Spanish, and English without leaking Chinese", async () => { + localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-Hans"); + const view = render( + + + , + ); + + await waitFor(() => + expect( + screen.getByRole("heading", { name: "更新记忆搜索索引?" }), + ).toBeTruthy(), + ); + + localStorage.setItem(LANGUAGE_STORAGE_KEY, "es"); + window.dispatchEvent( + new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }), + ); + await waitFor(() => + expect( + screen.getByRole("heading", { + name: "¿Actualizar el índice de búsqueda de memorias?", + }), + ).toBeTruthy(), + ); + expect(view.container.textContent).not.toMatch(/[一-鿿]/); + expect(view.container.textContent).toContain("8421"); + + localStorage.setItem(LANGUAGE_STORAGE_KEY, "en"); + window.dispatchEvent( + new StorageEvent("storage", { key: LANGUAGE_STORAGE_KEY }), + ); + await waitFor(() => + expect( + screen.getByRole("heading", { + name: "Update the memory search index?", + }), + ).toBeTruthy(), + ); + expect(view.container.textContent).not.toMatch(/[一-鿿]/); + expect(view.container.textContent).toContain("8,421"); +}); + +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..0888465 --- /dev/null +++ b/memhub/apps/web/src/EmbeddingSwitchExperience.tsx @@ -0,0 +1,480 @@ +import type { + EmbeddingSwitchCurrent, + EmbeddingSwitchJob, + EmbeddingSwitchPlan, +} from "@one-memory/contracts"; +import type { GuiLocale, MessageKey } from "@one-memory/i18n"; +import { ChevronDown, CircleAlert, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { deleteRetiredEmbeddingSpace } from "./api"; +import { useGuiLanguage } from "./gui-language"; +import { R6Button } from "./R6Controls"; +import { useEmbeddingSwitch } from "./use-embedding-switch"; +import "./embedding-switch.css"; + +type Translator = ( + key: MessageKey, + params?: Record, +) => string; + +function count(locale: GuiLocale, value: number | null | undefined) { + return new Intl.NumberFormat(locale).format(value ?? 0); +} + +function duration(locale: GuiLocale, t: Translator, seconds: number | null) { + if (seconds === null) return t("embeddingSwitch.duration.unknown"); + const value = Math.max(1, Math.round(seconds < 60 ? seconds : seconds / 60)); + return t( + seconds < 60 + ? "embeddingSwitch.duration.seconds" + : "embeddingSwitch.duration.minutes", + { count: count(locale, value) }, + ); +} + +function size(locale: GuiLocale, bytes: number) { + const megabytes = bytes >= 1024 * 1024; + const value = Math.ceil(bytes / 1024 / (megabytes ? 1024 : 1)); + return `${count(locale, value)} ${megabytes ? "MB" : "KB"}`; +} + +const blockerKeys: Record = { + embedding_probe_required: "embeddingSwitch.blocker.embedding_probe_required", + embedding_dimensions_unknown: + "embeddingSwitch.blocker.embedding_dimensions_unknown", + embedding_switch_requires_migration: + "embeddingSwitch.blocker.embedding_switch_requires_migration", + sdk_reembed_unavailable: "embeddingSwitch.blocker.sdk_reembed_unavailable", + insufficient_disk_space: "embeddingSwitch.blocker.insufficient_disk_space", + embedding_switch_running: "embeddingSwitch.blocker.embedding_switch_running", + memory_lifecycle_busy: "embeddingSwitch.blocker.memory_lifecycle_busy", +}; + +const failureKeys: Record = { + writes_did_not_drain: "embeddingSwitch.failure.writes_did_not_drain", + embedding_service_failed: "embeddingSwitch.failure.embedding_service_failed", + embedding_dimensions_mismatch: + "embeddingSwitch.failure.embedding_dimensions_mismatch", + embedding_verification_failed: + "embeddingSwitch.failure.embedding_verification_failed", + target_profile_changed: "embeddingSwitch.failure.target_profile_changed", + embedding_switch_cancelled: + "embeddingSwitch.failure.embedding_switch_cancelled", + model_secret_unavailable: "embeddingSwitch.failure.model_secret_unavailable", + secure_storage_unavailable: + "embeddingSwitch.failure.secure_storage_unavailable", + embedding_switch_clients_busy: + "embeddingSwitch.failure.embedding_switch_clients_busy", +}; + +export function EmbeddingSwitchConfirmDialog({ + plan, + busy, + errorCode, + onCancel, + onStart, +}: { + plan: EmbeddingSwitchPlan; + busy: boolean; + errorCode: string | null; + onCancel(): void; + onStart(): void; +}) { + const { locale, t } = useGuiLanguage(); + const panel = useRef(null); + const blockers = plan.blockers.map((code) => + t(blockerKeys[code] ?? "embeddingSwitch.blocker.default"), + ); + + 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(); + } + }} + > +
+
+

+ {t("embeddingSwitch.confirm.title")} +

+

{t("embeddingSwitch.confirm.description")}

+
+
+ +
+
+
{t("embeddingSwitch.impact.memories")}
+
+ {t("embeddingSwitch.impact.memoryCount", { + count: count(locale, plan.total_vectors), + })} + {plan.users && plan.users > 1 + ? t("embeddingSwitch.impact.userCount", { + count: count(locale, plan.users), + }) + : ""} +
+
+
+
{t("embeddingSwitch.impact.duration")}
+
{duration(locale, t, plan.estimate_s)}
+
+
+
{t("embeddingSwitch.impact.requests")}
+
+ {plan.target_local + ? t("embeddingSwitch.requests.none") + : plan.requests === null + ? t("embeddingSwitch.requests.billed") + : t("embeddingSwitch.requests.approx", { + count: count(locale, plan.requests), + })} +
+
+
+ +
+ {t("embeddingSwitch.safety.searchTitle")} +

{t("embeddingSwitch.safety.searchBody")}

+ {t("embeddingSwitch.safety.dataTitle")} +

{t("embeddingSwitch.safety.dataBody")}

+
+ +
+ + +
+
+
{t("embeddingSwitch.details.currentModel")}
+
+ {plan.source.model ?? t("embeddingSwitch.details.legacyIndex")} +
+
+
+
{t("embeddingSwitch.details.newModel")}
+
+ {plan.target.model ?? t("embeddingSwitch.details.pending")} +
+
+
+
{t("embeddingSwitch.details.dimensions")}
+
+ {plan.source.dims ?? t("embeddingSwitch.details.unknown")} →{" "} + {plan.target.dims ?? t("embeddingSwitch.details.unknown")} +
+
+
+
{t("embeddingSwitch.details.disk")}
+
{size(locale, plan.disk_bytes_needed)}
+
+
+
{t("embeddingSwitch.details.retention")}
+
+ {t("embeddingSwitch.details.days", { + count: count(locale, plan.retention_days), + })} +
+
+
+
+ + {blockers.length > 0 ? ( +
+
+ ) : null} + {errorCode ? ( +
+
+ ) : null} + +
+ + {t("embeddingSwitch.action.notNow")} + + 0} + onClick={onStart} + > + {t("embeddingSwitch.action.start")} + +
+
+
+ ); +} + +function jobTitle(t: Translator, job: EmbeddingSwitchJob) { + if (job.kind === "merge") return t("embeddingSwitch.job.merge"); + if (job.state === "preparing") return t("embeddingSwitch.job.preparing"); + if (job.state === "reembedding") return t("embeddingSwitch.job.reembedding"); + if (job.state === "verifying") return t("embeddingSwitch.job.verifying"); + if (job.state === "committing") return t("embeddingSwitch.job.committing"); + if (job.state === "failed") return t("embeddingSwitch.job.failed"); + return t("embeddingSwitch.job.default"); +} + +function canCancel(job: EmbeddingSwitchJob) { + return ["preparing", "reembedding", "verifying"].includes(job.state); +} + +function ActiveSwitchBanner({ + current, + onCancel, + cancelling, +}: { + current: EmbeddingSwitchCurrent; + onCancel(): void; + cancelling: boolean; +}) { + const { locale, t } = useGuiLanguage(); + 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 { t } = useGuiLanguage(); + 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 ? ( +

+ {t("embeddingSwitch.cleanup.failed")} +

+ ) : null} + {recent?.state === "rolled_back" ? ( + + ) : null} + + ); +} diff --git a/memhub/apps/web/src/MemoryModeSettings.test.tsx b/memhub/apps/web/src/MemoryModeSettings.test.tsx index 575f954..21fb3c2 100644 --- a/memhub/apps/web/src/MemoryModeSettings.test.tsx +++ b/memhub/apps/web/src/MemoryModeSettings.test.tsx @@ -1,9 +1,16 @@ // @vitest-environment jsdom -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; import { + apiErrorCode, getEvolveOverview, getEvolveSchedule, getMemoryMode, @@ -59,15 +66,15 @@ async function chooseMode( user: ReturnType, name: RegExp, ) { - const picker = await screen.findByRole("combobox", { name: "当前模式" }); - await user.click(picker); - await user.click(screen.getByRole("option", { name })); - return picker; + const option = await screen.findByRole("radio", { name }); + await user.click(option); + return option; } afterEach(() => { cleanup(); vi.clearAllMocks(); + vi.mocked(apiErrorCode).mockReturnValue(null); }); it("confirms the data effects before switching to lite", async () => { @@ -81,29 +88,43 @@ it("confirms the data effects before switching to lite", async () => { render(); await chooseMode(user, /基础记忆(lite)/); - expect(screen.getByText(/高级记忆会保留/)).toBeTruthy(); + expect(screen.getByText(/已有记忆继续保留/)).toBeTruthy(); + expect(screen.getByText("模型调用与费用")).toBeTruthy(); + expect(screen.getByText("搜索范围")).toBeTruthy(); expect(setMemoryMode).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "切换模式" })); + await user.click( + screen.getByRole("button", { name: "切换到基础记忆(lite)" }), + ); await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite")); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("radio", { name: /基础记忆(lite)/ }), + ), + ); }); it("traps modal focus, closes on Escape and restores the mode-card focus", async () => { prepare("pro"); + const outside = document.createElement("button"); + document.body.append(outside); const user = userEvent.setup(); render(); const lite = await chooseMode(user, /基础记忆(lite)/); const cancel = screen.getByRole("button", { name: "取消" }); await waitFor(() => expect(document.activeElement).toBe(cancel)); + expect(outside.inert).toBe(true); await user.keyboard("{Shift>}{Tab}{/Shift}"); expect(document.activeElement).toBe( - screen.getByRole("button", { name: "切换模式" }), + screen.getByRole("button", { name: "切换到基础记忆(lite)" }), ); await user.keyboard("{Escape}"); expect(screen.queryByRole("dialog")).toBeNull(); + expect(outside.inert).toBeFalsy(); expect(document.activeElement).toBe(lite); + outside.remove(); }); it("keeps focus inside the modal while the mode change is pending", async () => { @@ -112,7 +133,9 @@ it("keeps focus inside the modal while the mode change is pending", async () => const user = userEvent.setup(); render(); await chooseMode(user, /基础记忆(lite)/); - await user.click(screen.getByRole("button", { name: "切换模式" })); + await user.click( + screen.getByRole("button", { name: "切换到基础记忆(lite)" }), + ); const modal = screen.getByRole("dialog"); await waitFor(() => expect(document.activeElement).toBe(modal)); @@ -129,10 +152,60 @@ it("routes to model setup instead of switching pro without a model", async () => ); await chooseMode(user, /智能记忆(pro)/); - expect(configure).toHaveBeenCalledTimes(1); + expect(configure).toHaveBeenCalledExactlyOnceWith( + "使用 pro 或 ultra 前需要先配置并启用生成模型。", + ); expect(setMemoryMode).not.toHaveBeenCalled(); +}); + +it("explains model cost, search scope and the next step before switching to ultra", async () => { + prepare("lite"); + const user = userEvent.setup(); + render(); + + await chooseMode(user, /深度记忆(ultra)/); + + expect(screen.getByText("模型调用与费用")).toBeTruthy(); + expect(screen.getByText("搜索范围")).toBeTruthy(); + expect(screen.getByText("切换后")).toBeTruthy(); + expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy(); + expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy(); +}); + +it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => { + prepare("ultra"); + const user = userEvent.setup(); + render(); + + await chooseMode(user, /智能记忆(pro)/); + + expect(screen.getByText(/普通沉淀记忆仍可使用/)).toBeTruthy(); + expect(screen.getByText(/更高层记忆和图谱数据继续保留/)).toBeTruthy(); + expect(screen.getByText(/每天自动整合会停止/)).toBeTruthy(); +}); + +it("focuses the Evolve settings after switching to ultra", async () => { + prepare("pro"); + vi.mocked(setMemoryMode).mockResolvedValue({ + ...state("ultra"), + previous_mode: "pro", + effects: ["existing_memories_added_to_graph"], + }); + const user = userEvent.setup(); + render(); + await screen.findByRole("radio", { name: /智能记忆(pro)/ }); + vi.mocked(getMemoryMode).mockResolvedValue(state("ultra")); + + await chooseMode(user, /深度记忆(ultra)/); + await user.click( + screen.getByRole("button", { name: "切换到深度记忆(ultra)" }), + ); + + await waitFor(() => + expect(document.activeElement?.getAttribute("id")).toContain("-evolve"), + ); expect( - screen.getByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"), + screen.getByRole("heading", { name: "记忆整合(Evolve)" }), ).toBeTruthy(); }); @@ -145,37 +218,14 @@ it("shows the default nightly evolve controls in ultra", async () => { "aria-checked", ), ).toBe("true"); - expect( - (screen.getByLabelText("自动整合时间") as HTMLInputElement).value, - ).toBe("03:00"); + expect((screen.getByLabelText("执行时间") as HTMLInputElement).value).toBe( + "03:00", + ); + expect(screen.getByText(/只运行这一次,不改变每天的计划/)).toBeTruthy(); expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy(); }); -it("ignores an older refresh that finishes after a newer mode read", async () => { - prepare("pro"); - const user = userEvent.setup(); - render(); - const picker = await screen.findByRole("combobox", { name: "当前模式" }); - - let resolveOld!: (value: ReturnType) => void; - vi.mocked(getMemoryMode) - .mockReturnValueOnce( - new Promise((resolve) => { - resolveOld = resolve; - }), - ) - .mockResolvedValueOnce(state("lite")); - - const refresh = screen.getByRole("button", { name: "刷新记忆模式" }); - await user.click(refresh); - await user.click(refresh); - await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite")); - resolveOld(state("pro")); - - await waitFor(() => expect((picker as HTMLButtonElement).value).toBe("lite")); -}); - -it("does not let an older refresh undo a saved schedule change", async () => { +it("hides the time field when automatic evolve is off and keeps the saved time visible", async () => { prepare("ultra"); vi.mocked(setEvolveSchedule).mockResolvedValue({ enabled: false, @@ -183,26 +233,28 @@ it("does not let an older refresh undo a saved schedule change", async () => { }); const user = userEvent.setup(); render(); - const toggle = await screen.findByRole("switch", { + + const automatic = await screen.findByRole("switch", { name: "每天自动整合", }); + await user.click(automatic); - let resolveOld!: (value: { enabled: true; time: string }) => void; - vi.mocked(getEvolveSchedule).mockReturnValueOnce( - new Promise((resolve) => { - resolveOld = resolve; - }), - ); - await user.click(screen.getByRole("button", { name: "刷新记忆模式" })); - await user.click(toggle); - await waitFor(() => - expect(setEvolveSchedule).toHaveBeenCalledWith({ enabled: false }), - ); - resolveOld({ enabled: true, time: "03:00" }); + await screen.findByText(/重新开启后仍按 03:00 运行/); + expect(screen.queryByLabelText("执行时间")).toBeNull(); + expect(screen.getByRole("button", { name: "立即整合" })).toBeTruthy(); +}); - await waitFor(() => - expect(toggle.getAttribute("aria-checked")).toBe("false"), - ); +it("updates mode state automatically without showing a manual refresh control", async () => { + prepare("pro"); + render(); + const pro = await screen.findByRole("radio", { name: /智能记忆(pro)/ }); + + expect((pro as HTMLInputElement).checked).toBe(true); + expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull(); + expect(getMemoryMode).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(getEvolveSchedule).toHaveBeenCalledTimes(1); + }); }); it("keeps the mode usable when an evolve status read fails", async () => { @@ -211,15 +263,55 @@ it("keeps the mode usable when an evolve status read fails", async () => { render(); expect( - (await screen.findByRole("combobox", { - name: "当前模式", - })) as HTMLButtonElement, + (await screen.findByRole("radio", { + name: /智能记忆(pro)/, + })) as HTMLInputElement, ).toHaveProperty("disabled", false); }); +it("offers a text recovery action when the mode read fails", async () => { + prepare("pro"); + vi.mocked(getMemoryMode).mockRejectedValueOnce(new Error("offline")); + const user = userEvent.setup(); + render(); + + expect( + await screen.findByText("记忆模式状态暂时无法读取,请重试。"), + ).toBeTruthy(); + expect( + screen.getByRole("radio", { name: /基础记忆(lite)/ }), + ).toHaveProperty("disabled", true); + + await user.click(screen.getByRole("button", { name: "重新读取" })); + await waitFor(() => + expect( + screen.getByRole("radio", { name: /智能记忆(pro)/ }), + ).toHaveProperty("disabled", false), + ); +}); + +it("keeps a failed switch explanation inside the open confirmation dialog", async () => { + prepare("pro"); + vi.mocked(apiErrorCode).mockReturnValue("graph_store_unavailable"); + vi.mocked(setMemoryMode).mockRejectedValue(new Error("unavailable")); + const user = userEvent.setup(); + render(); + + await chooseMode(user, /深度记忆(ultra)/); + await user.click( + screen.getByRole("button", { name: "切换到深度记忆(ultra)" }), + ); + + const dialog = screen.getByRole("dialog"); + expect( + await within(dialog).findByText( + "当前安装缺少知识图谱组件,无法开启 ultra。", + ), + ).toBeTruthy(); +}); + it("keeps lite available while model status is unknown", async () => { prepare("pro"); - const user = userEvent.setup(); render( { />, ); - await user.click(await screen.findByRole("combobox", { name: "当前模式" })); expect( - screen.getByRole("option", { name: /基础记忆(lite)/ }), + await screen.findByRole("radio", { name: /基础记忆(lite)/ }), ).toHaveProperty("disabled", false); + expect(screen.getByRole("radio", { name: /智能记忆(pro)/ })).toHaveProperty( + "disabled", + true, + ); expect( - screen.getByRole("option", { name: /智能记忆(pro)/ }), - ).toHaveProperty("disabled", true); - expect( - screen.getByRole("option", { name: /深度记忆(ultra)/ }), + screen.getByRole("radio", { name: /深度记忆(ultra)/ }), ).toHaveProperty("disabled", true); }); + +it("blocks mode and Evolve controls during embedding maintenance", async () => { + prepare("ultra"); + vi.mocked(getMemoryProcessing).mockResolvedValue({ + embedding_switch: { phase: "running" }, + } as never); + render(); + + expect( + await screen.findByText( + "正在检查或切换向量模型,完成前不能切换模式或手动整合。", + ), + ).toBeTruthy(); + expect( + screen.getByRole("radio", { name: /基础记忆(lite)/ }), + ).toHaveProperty("disabled", true); + expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty( + "disabled", + true, + ); +}); + +it("blocks lifecycle controls while the knowledge graph is being built", async () => { + prepare("ultra"); + vi.mocked(getMemoryMode).mockResolvedValue({ + ...state("ultra"), + graph_build: { + state: "running", + started_at: "2026-11-03T09:00:00Z", + finished_at: null, + users: {}, + }, + }); + render(); + + expect( + await screen.findByText("正在把已有高级记忆加入知识图谱…"), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "立即整合" })).toHaveProperty( + "disabled", + true, + ); +}); diff --git a/memhub/apps/web/src/MemoryModeSettings.tsx b/memhub/apps/web/src/MemoryModeSettings.tsx index d448755..d4dc054 100644 --- a/memhub/apps/web/src/MemoryModeSettings.tsx +++ b/memhub/apps/web/src/MemoryModeSettings.tsx @@ -6,7 +6,6 @@ import type { MemoryModeState, MemoryPipelineMode, } from "@one-memory/contracts"; -import { RefreshCw } from "lucide-react"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { apiErrorCode, @@ -18,9 +17,9 @@ import { setMemoryMode, startEvolve, } from "./api"; -import { OptionMenu } from "./OptionMenu"; import { useOnboardingTranslation } from "./onboarding-language"; import { R6Button, R6Switch } from "./R6Controls"; +import { R6StateRecovery } from "./R6StateRecovery"; const MODE_COPY: Record< MemoryPipelineMode, @@ -28,42 +27,104 @@ const MODE_COPY: Record< > = { lite: { title: "基础记忆(lite)", - description: "不调用生成模型。保存并检索原始对话,不再生成高级记忆。", + description: "保存原始对话 · 不调用生成模型 · 搜索原始会话", }, pro: { title: "智能记忆(pro)", - description: "使用当前模型从新对话中提取事实、偏好和计划。", + description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆", }, ultra: { title: "深度记忆(ultra)", - description: "在智能记忆之上使用知识图谱,并默认每天 03:00 自动整合记忆。", + description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回", }, }; -const EFFECT_COPY: Record = { - earlier_raw_not_extracted: - "lite 下已经处理完成的原始对话不会补提取;排队中和之后的新对话会生成高级记忆。", - lite_search_stops: "原始对话检索会停止,改为使用高级记忆。", - higher_mode_memories_hidden: - "以前生成的高级记忆会继续保留,但在 lite 下不会显示或参与记忆召回。", - evolve_stopped: "每天自动整合会停止,已经生成的数据不会删除。", - existing_memories_added_to_graph: - "已有高级记忆会在后台加入知识图谱,期间可以继续使用 MemHub。", - graph_backfill_already_running: "知识图谱仍在补建,不会重复启动。", -}; - -function plannedEffects( +function confirmationRows( current: MemoryPipelineMode, target: MemoryPipelineMode, -): string[] { - if (current === target) return []; - const effects: string[] = []; - if (current === "lite" && target !== "lite") - effects.push("earlier_raw_not_extracted", "lite_search_stops"); - if (target === "lite") effects.push("higher_mode_memories_hidden"); - if (current === "ultra" && target !== "ultra") effects.push("evolve_stopped"); - if (target === "ultra") effects.push("existing_memories_added_to_graph"); - return effects; +): Array<{ label: string; content: string }> { + if (target === "lite") { + return [ + { + label: "新对话", + content: "之后只保存原始对话,不再生成新记忆。", + }, + { + label: "模型调用与费用", + content: + current === "ultra" + ? "停止调用生成模型;每天自动整合也会停止。" + : "停止调用生成模型,不再产生这部分费用。", + }, + { + label: "搜索范围", + content: "立即改为搜索原始对话。", + }, + { + label: "已有数据", + content: + "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。", + }, + ]; + } + if (target === "pro") { + return [ + { + label: "新对话", + content: + current === "lite" + ? "排队中和之后的新对话会由模型整理成记忆;已经处理完的旧对话不会补做。" + : "仍会由模型整理成记忆;新记忆不再写入知识图谱。", + }, + { + label: "模型调用与费用", + content: + current === "ultra" + ? "提取仍会调用生成模型;每天自动整合会停止。" + : "开始调用当前生成模型,可能产生服务商费用。", + }, + { + label: "搜索范围", + content: + current === "ultra" + ? "改为搜索智能记忆,不再使用知识图谱补充关联。" + : "立即改为搜索沉淀后的记忆。", + }, + { + label: "已有数据", + content: + current === "ultra" + ? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。" + : "原始对话全部保留,不会删除。", + }, + ]; + } + return [ + { + label: "新对话", + content: + current === "lite" + ? "排队中和之后的新对话会提取记忆并写入知识图谱;已经处理完的旧对话不会补做。" + : "提取方式不变,新记忆会写入知识图谱。", + }, + { + label: "模型调用与费用", + content: + "提取会调用生成模型;若每天自动整合已开启,到设定时间还会产生额外调用。切换本身不会立即整合。", + }, + { + label: "搜索范围", + content: "搜索沉淀后的记忆,并用知识图谱补充关联。", + }, + { + label: "已有数据", + content: "已有记忆会在后台加入知识图谱,期间仍可使用;原始对话不会删除。", + }, + { + label: "切换后", + content: "可设置每天自动整合时间,也可以随时立即整合一次。", + }, + ]; } export function MemoryModeSettings({ @@ -74,10 +135,10 @@ export function MemoryModeSettings({ }: { hasActiveModel: boolean; modelStateKnown?: boolean; - onConfigureModel(): void; + onConfigureModel(message?: string): void; onModeChange?(mode: MemoryPipelineMode): void; }) { - const { text } = useOnboardingTranslation(); + const { text, locale } = useOnboardingTranslation(); const id = useId(); const [state, setState] = useState(null); const [schedule, setScheduleState] = useState(null); @@ -88,16 +149,30 @@ export function MemoryModeSettings({ const [busy, setBusy] = useState(false); const [maintenance, setMaintenance] = useState(false); const [error, setError] = useState(""); + const [readFailed, setReadFailed] = useState(false); const [run, setRun] = useState(null); const refreshVersion = useRef(0); const dialog = useRef(null); const dialogOpener = useRef(null); + const focusEvolveWhenReady = useRef(false); + const focusModeWhenReady = useRef(null); const busyRef = useRef(false); busyRef.current = busy; useEffect(() => { if (!pendingMode || !dialog.current) return; const panel = dialog.current; + const inertSiblings = new Map(); + let branch: HTMLElement = panel.parentElement ?? panel; + while (branch.parentElement) { + for (const sibling of branch.parentElement.children) { + if (!(sibling instanceof HTMLElement) || sibling === branch) continue; + inertSiblings.set(sibling, sibling.inert); + sibling.inert = true; + } + branch = branch.parentElement; + if (branch === document.body) break; + } const controls = () => Array.from(panel.querySelectorAll("button:not(:disabled)")); controls()[0]?.focus(); @@ -127,6 +202,7 @@ export function MemoryModeSettings({ panel.addEventListener("keydown", handleKey); return () => { panel.removeEventListener("keydown", handleKey); + for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert; if (dialogOpener.current?.isConnected) dialogOpener.current.focus(); }; }, [pendingMode]); @@ -135,6 +211,24 @@ export function MemoryModeSettings({ if (pendingMode && busy) dialog.current?.focus(); }, [busy, pendingMode]); + useEffect(() => { + if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra") + return; + focusEvolveWhenReady.current = false; + document.getElementById(`${id}-evolve`)?.focus(); + }, [id, pendingMode, state?.mode]); + + useEffect(() => { + const target = focusModeWhenReady.current; + if (!target || busy || pendingMode || state?.mode !== target) return; + focusModeWhenReady.current = null; + document + .querySelector( + `input[name="${id}-mode"][value="${target}"]`, + ) + ?.focus(); + }, [busy, id, pendingMode, state?.mode]); + const refresh = useCallback(async () => { const version = ++refreshVersion.current; const [modeResult, scheduleResult, overviewResult, processingResult] = @@ -149,8 +243,10 @@ export function MemoryModeSettings({ setState(modeResult.value); onModeChange?.(modeResult.value.mode); setError(""); + setReadFailed(false); } else { setError("记忆模式状态暂时无法读取,请重试。"); + setReadFailed(true); } if (scheduleResult.status === "fulfilled") setScheduleState(scheduleResult.value); @@ -181,25 +277,29 @@ export function MemoryModeSettings({ run?.state === "running" || state?.graph_build.state === "running"; - function choose(mode: MemoryPipelineMode) { + function choose(mode: MemoryPipelineMode, opener: HTMLElement) { if (!state || mode === state.mode || busy || lifecycleBusy) return; if (mode !== "lite" && !hasActiveModel) { - setError("使用 pro 或 ultra 前需要先配置并启用生成模型。"); - onConfigureModel(); + const message = "使用 pro 或 ultra 前需要先配置并启用生成模型。"; + setError(""); + onConfigureModel(message); return; } setError(""); - dialogOpener.current = document.getElementById(`${id}-select`); + dialogOpener.current = opener; setPendingMode(mode); } async function commitMode() { if (!pendingMode) return; + const targetMode = pendingMode; refreshVersion.current++; setBusy(true); setError(""); try { const changed: MemoryModeChange = await setMemoryMode(pendingMode); + focusEvolveWhenReady.current = targetMode === "ultra"; + focusModeWhenReady.current = targetMode === "ultra" ? null : targetMode; setState(changed); setPendingMode(null); await refresh(); @@ -261,41 +361,53 @@ export function MemoryModeSettings({

{text("记忆模式")}

{text("模式决定新对话如何保存,以及记忆页面展示什么。")}

- void refresh()} - > -
- - ({ - value: mode, - label: text(MODE_COPY[mode].title), - description: text(MODE_COPY[mode].description), - disabled: mode !== "lite" && !modelStateKnown, - }))} - className="r6-language-option-menu r6-mode-option-menu" - panelClassName="r6-language-option-menu-panel--settings r6-mode-option-menu-panel" - onChange={(value) => { - if (value === "lite" || value === "pro" || value === "ultra") - choose(value); - }} - /> + {(["lite", "pro", "ultra"] as const).map((mode) => { + const checked = state?.mode === mode; + return ( + + ); + })}
{state?.forced_by_env ? (

@@ -312,20 +424,38 @@ export function MemoryModeSettings({ {text("正在把已有高级记忆加入知识图谱…")}

) : null} - {error ? ( -

- {text(error)} -

+ {error && !pendingMode ? ( + readFailed ? ( + void refresh()} + /> + ) : ( +

+ {text(error)} +

+ ) ) : null} {state?.mode === "ultra" && schedule ? ( -
-
-
+
+
+

{text("记忆整合(Evolve)")}

+

{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}

+
+
+

{text("每天自动整合")}

{text( - "每天 {time} 自动运行 evolve,设备休眠错过后会补跑。", + schedule.enabled + ? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。" + : "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。", ).replace("{time}", schedule.time)}

@@ -335,40 +465,45 @@ export function MemoryModeSettings({ disabled={busy || maintenance} onCheckedChange={(enabled) => void changeSchedule({ enabled })} /> + {schedule.enabled ? ( +
+
+ +

{text("使用这台设备的本地时间。")}

+
+ + void changeSchedule({ time: event.target.value }) + } + /> +
+ ) : null}
-
-
-

{text("自动整合时间")}

-

{text("使用这台设备的本地时间。")}

-
- - void changeSchedule({ time: event.target.value }) - } - /> -
-
-
-

{text("立即整合记忆")}

+
+
+

{text("立即整合一次")}

+ {text("只运行这一次,不改变每天的计划。")} + {" · "} {text( run?.state === "running" ? "正在整合记忆…" : last?.last_run_at ? text("上次整合:{time}").replace( "{time}", - new Date(last.last_run_at).toLocaleString(), + new Date(last.last_run_at).toLocaleString(locale), ) : "尚未整合", )}

void evolveNow()} @@ -376,30 +511,40 @@ export function MemoryModeSettings({ {text("立即整合")}
-
+
) : null} {pendingMode ? (

- {text("切换到 {mode}?").replace("{mode}", pendingMode)} + {text("切换到 {mode}?").replace( + "{mode}", + text(MODE_COPY[pendingMode].title), + )}

-
    - {plannedEffects(state?.mode ?? "pro", pendingMode).map( - (effect) => ( -
  • {text(EFFECT_COPY[effect])}
  • +
    + {confirmationRows(state?.mode ?? "pro", pendingMode).map( + (row) => ( +
    +
    {text(row.label)}
    +
    {text(row.content)}
    +
    ), )} -
  • {text("原始对话在所有模式下都会保留。")}
  • -
+ + {error ? ( +

+ {text(error)} +

+ ) : null}
setPendingMode(null)}> {text("取消")} @@ -409,7 +554,10 @@ export function MemoryModeSettings({ pending={busy} onClick={() => void commitMode()} > - {text("切换模式")} + {text("切换到 {mode}").replace( + "{mode}", + text(MODE_COPY[pendingMode].title), + )}
diff --git a/memhub/apps/web/src/MemorySpace.tsx b/memhub/apps/web/src/MemorySpace.tsx index 67ee041..c03aa4c 100644 --- a/memhub/apps/web/src/MemorySpace.tsx +++ b/memhub/apps/web/src/MemorySpace.tsx @@ -1494,7 +1494,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:这条记忆的来源授权已撤回,暂时不能读取。", @@ -1507,8 +1507,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..067c8c6 100644 --- a/memhub/apps/web/src/ModelSettings.tsx +++ b/memhub/apps/web/src/ModelSettings.tsx @@ -6,7 +6,7 @@ import type { ProbeCode, ProviderKind, } from "@one-memory/contracts"; -import { Check, Pencil, Plus, RefreshCw } from "lucide-react"; +import { Check, Pencil, Plus } from "lucide-react"; import { type FormEvent, type Ref, @@ -16,9 +16,12 @@ 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 { R6StateRecovery } from "./R6StateRecovery"; +import { useEmbeddingSwitch } from "./use-embedding-switch"; import { useModelProfiles } from "./use-model-profiles"; import "./model-setup-next.css"; @@ -216,6 +219,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 +230,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 +271,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 +285,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 +498,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 +570,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 +658,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 +707,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 +726,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 +790,23 @@ export function ModelSettings({

02

-

{text("记忆模型")}

+

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

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

@@ -797,18 +900,12 @@ export function ModelSettings({

)} {models.refreshError && ( -
-

{text(models.refreshError)}

- -
+ void models.refreshProfiles()} + /> )} {/* The paste-one-key path goes first when it exists. Behind it sits the @@ -956,326 +1053,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 +1551,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/OnboardingFlow.language.test.tsx b/memhub/apps/web/src/OnboardingFlow.language.test.tsx index f019858..29fbb28 100644 --- a/memhub/apps/web/src/OnboardingFlow.language.test.tsx +++ b/memhub/apps/web/src/OnboardingFlow.language.test.tsx @@ -44,6 +44,22 @@ const models = { activate: vi.fn(), }; vi.mock("./use-model-profiles", () => ({ useModelProfiles: () => models })); +vi.mock("./use-embedding-switch", () => ({ + useEmbeddingSwitch: () => ({ + current: null, + plan: null, + busy: null, + errorCode: null, + refreshFailed: false, + supported: false, + planActivation: vi.fn(), + startPlannedSwitch: vi.fn(), + cancelCurrentSwitch: vi.fn(), + shouldUseLegacyActivation: () => true, + clearPlan: vi.fn(), + clearError: vi.fn(), + }), +})); const source: SourceResponse = { source_id: "cursor", display_name: "Cursor", diff --git a/memhub/apps/web/src/OnboardingModelController.test.tsx b/memhub/apps/web/src/OnboardingModelController.test.tsx index 1b2e3a2..a288a11 100644 --- a/memhub/apps/web/src/OnboardingModelController.test.tsx +++ b/memhub/apps/web/src/OnboardingModelController.test.tsx @@ -41,6 +41,21 @@ const { models } = vi.hoisted(() => ({ vi.mock("./use-model-profiles", () => ({ useModelProfiles: () => models, })); +vi.mock("./use-embedding-switch", () => ({ + useEmbeddingSwitch: () => ({ + current: null, + plan: null, + busy: null, + errorCode: null, + refreshFailed: false, + supported: false, + planActivation: vi.fn(), + startPlannedSwitch: vi.fn(), + shouldUseLegacyActivation: () => true, + clearPlan: vi.fn(), + clearError: vi.fn(), + }), +})); vi.mock("./OnboardingModelStep", () => ({ OnboardingModelStep: (props: { probe?: { message: string } | null; diff --git a/memhub/apps/web/src/OnboardingModelController.tsx b/memhub/apps/web/src/OnboardingModelController.tsx index af92996..b8e548b 100644 --- a/memhub/apps/web/src/OnboardingModelController.tsx +++ b/memhub/apps/web/src/OnboardingModelController.tsx @@ -5,6 +5,7 @@ import type { } from "@one-memory/contracts"; 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,6 +85,9 @@ export function OnboardingModelController({ state: "passed" | "failed" | "stale"; message: string; } | null>(null); + const [switchPendingJobId, setSwitchPendingJobId] = useState( + null, + ); const flight = useRef(false); const draftTouched = useRef(false); @@ -115,6 +121,27 @@ export function OnboardingModelController({ }); }, [models.profiles, saved]); + 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; draftTouched.current = true; @@ -273,6 +300,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"); @@ -287,77 +326,102 @@ 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} - enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined} - 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} + enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined} + 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 b84c530..2f085d3 100644 --- a/memhub/apps/web/src/R6Settings.test.tsx +++ b/memhub/apps/web/src/R6Settings.test.tsx @@ -1,10 +1,21 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; +import { + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { Ref } from "react"; import { afterEach, expect, it, vi } from "vitest"; -import { getModelProfiles, getSedimentation, setSedimentation } from "./api"; +import { + getMemoryMode, + getModelProfiles, + getSedimentation, + setSedimentation, +} from "./api"; import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language"; import { queryClient, resetClient } from "./query-client"; import { R6Settings } from "./R6Settings"; @@ -19,7 +30,7 @@ it("changes settings copy live without rereading profiles or changing permission , ); expect( - await screen.findByRole("heading", { name: "Modelo de memorias" }), + await screen.findByRole("heading", { name: "Modo de memoria" }), ).toBeTruthy(); expect( screen.queryByRole("switch", { @@ -27,9 +38,13 @@ it("changes settings copy live without rereading profiles or changing permission }), ).toBeNull(); const reads = vi.mocked(getModelProfiles).mock.calls.length; + await user.click( + screen.getByRole("button", { name: "Idioma de la interfaz" }), + ); await user.click(screen.getByRole("combobox", { name: "Idioma" })); await user.click(screen.getByRole("option", { name: "简体中文" })); - expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "模型配置" })); + expect(await screen.findByRole("heading", { name: "模型配置" })).toBeTruthy(); expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads); }); vi.mock("./api", () => ({ @@ -85,14 +100,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" }, "核对测试结果"), + ); }, }; }); @@ -127,6 +153,7 @@ it("shows the help and diagnostics group by default and hides it when opted out" } as NonNullable; const user = userEvent.setup(); const view = render(); + await user.click(screen.getByRole("button", { name: "界面语言" })); expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy(); await user.click(screen.getByRole("combobox", { name: "语言" })); expect(screen.getAllByRole("option")).toHaveLength(12); @@ -145,6 +172,79 @@ it("shows the help and diagnostics group by default and hides it when opted out" expect(screen.queryByRole("button", { name: "帮助与诊断" })).toBeNull(); }); +it("uses one sidebar hierarchy and explains when a saved model is inactive in lite", async () => { + vi.mocked(getMemoryMode).mockResolvedValueOnce({ + mode: "lite", + configured_mode: "lite", + explicitly_configured: true, + forced_by_env: false, + modes: ["lite", "pro", "ultra"], + graph_build: { + state: "idle", + started_at: null, + finished_at: null, + users: {}, + }, + }); + const user = userEvent.setup(); + render(); + + expect(screen.getByRole("button", { name: "界面语言" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "记忆模式" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "模型配置" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "刷新记忆模式" })).toBeNull(); + + await user.click(screen.getByRole("button", { name: "界面语言" })); + expect(screen.getByRole("combobox", { name: "语言" })).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "模型配置" })); + expect(await screen.findByText("当前是基础记忆(lite)")).toBeTruthy(); + expect( + screen.getByText(/是否开始整理取决于.*“用模型沉淀记忆”.*开关/), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: "刷新模型状态" })).toBeNull(); +}); + +it("carries a missing-model explanation into model configuration and moves focus", async () => { + vi.mocked(getModelProfiles).mockResolvedValueOnce([]); + vi.mocked(getMemoryMode).mockResolvedValueOnce({ + mode: "lite", + configured_mode: "lite", + explicitly_configured: true, + forced_by_env: false, + modes: ["lite", "pro", "ultra"], + graph_build: { + state: "idle", + started_at: null, + finished_at: null, + users: {}, + }, + }); + const user = userEvent.setup(); + render(); + + await user.click( + await screen.findByRole("radio", { name: /智能记忆(pro)/ }), + ); + + expect( + await screen.findByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"), + ).toBeTruthy(); + const heading = screen.getByRole("heading", { name: "模型配置" }); + await waitFor(() => expect(document.activeElement).toBe(heading)); +}); + +it("offers a text recovery action when the model overview cannot be read", async () => { + vi.mocked(getModelProfiles).mockRejectedValueOnce(new Error("offline")); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "模型配置" })); + + expect(await screen.findByText("模型状态未能读取,请重试。")).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "重新读取" })); + + expect(await screen.findByText("当前模型")).toBeTruthy(); +}); + it("stops model sedimentation on one switch flip without touching the model config", async () => { const user = userEvent.setup(); vi.mocked(setSedimentation).mockResolvedValue({ @@ -153,6 +253,7 @@ it("stops model sedimentation on one switch flip without touching the model conf imports_unaffected: true, }); render(); + await user.click(screen.getByRole("button", { name: "模型配置" })); const toggle = await screen.findByRole("switch", { name: "用模型沉淀记忆" }); expect(toggle.getAttribute("aria-checked")).toBe("true"); @@ -171,6 +272,7 @@ it("reports a stop that did not confirm instead of showing it stopped", async () const user = userEvent.setup(); vi.mocked(setSedimentation).mockRejectedValue(new Error("offline")); render(); + await user.click(screen.getByRole("button", { name: "模型配置" })); const toggle = await screen.findByRole("switch", { name: "用模型沉淀记忆" }); await user.click(toggle); @@ -181,14 +283,21 @@ 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(screen.getByRole("button", { name: "模型配置" })); - 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"); @@ -202,15 +311,57 @@ it("reaches the same recovery entry when no model has been configured yet", asyn vi.mocked(getModelProfiles).mockResolvedValueOnce([]); const user = userEvent.setup(); render(); + await user.click(screen.getByRole("button", { name: "模型配置" })); - 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( + await within(generationLane).findByRole("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(); + await user.click(screen.getByRole("button", { name: "模型配置" })); + + 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 () => { vi.mocked(getSedimentation).mockRejectedValueOnce(new Error("offline")); render(); + await userEvent.click(screen.getByRole("button", { name: "模型配置" })); const toggle = (await screen.findByRole("switch", { name: "用模型沉淀记忆", diff --git a/memhub/apps/web/src/R6Settings.tsx b/memhub/apps/web/src/R6Settings.tsx index 22ee908..5603438 100644 --- a/memhub/apps/web/src/R6Settings.tsx +++ b/memhub/apps/web/src/R6Settings.tsx @@ -1,5 +1,5 @@ import type { MemoryPipelineMode, ModelProfile } from "@one-memory/contracts"; -import { Download, RefreshCw } from "lucide-react"; +import { Download } from "lucide-react"; import { useCallback, useEffect, @@ -13,9 +13,14 @@ 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 { R6StateRecovery } from "./R6StateRecovery"; import { refreshLoginPreference, setLoginPreference, @@ -28,14 +33,9 @@ import { } from "./sedimentation-state"; import "./r6-settings.css"; -type Group = "model" | "connections" | "help"; +type Group = "language" | "mode" | "models" | "connections" | "help"; -const labels: Record = { - model: "记忆模式与模型", - connections: "连接其他应用", - help: "帮助与诊断", -}; -const primaryGroups: Group[] = ["model", "connections"]; +const primaryGroups: Group[] = ["language", "mode", "models", "connections"]; function safeEndpoint(value: string | null) { if (!value) return "本机 / 受管端点"; @@ -62,12 +62,22 @@ export function R6Settings({ const groups = showDiagnostics ? [...primaryGroups, "help" as const] : primaryGroups; - const [group, setGroup] = useState("model"); + const groupLabel = (key: Group) => { + if (key === "language") return t("languageTitle"); + if (key === "mode") return text("记忆模式"); + if (key === "models") return text("模型配置"); + if (key === "connections") return text("连接其他应用"); + return text("帮助与诊断"); + }; + const [group, setGroup] = useState("mode"); const [profiles, setProfiles] = useState(null); const [modelError, setModelError] = useState(false); const [configurationRefusal, setConfigurationRefusal] = useState(false); + const [modeHandoffMessage, setModeHandoffMessage] = useState(""); const [modelVersion, setModelVersion] = useState(0); const [configurationOpen, setConfigurationOpen] = useState(false); + const [configurationFocus, setConfigurationFocus] = + useState("generation"); const [memoryMode, setMemoryModeState] = useState( null, ); @@ -126,9 +136,8 @@ export function R6Settings({ // biome-ignore lint/correctness/useExhaustiveDependencies: modelVersion is the explicit refresh trigger. useEffect(() => { if (!active) return; - if (group !== "model") return; void readModels(); - }, [active, group, modelVersion, readModels]); + }, [active, modelVersion, readModels]); const readSedimentation = useCallback(async () => { try { @@ -143,7 +152,7 @@ export function R6Settings({ useEffect(() => { if (!active) return; - if (group !== "model") return; + if (group !== "models") return; void readSedimentation(); }, [active, group, readSedimentation]); @@ -212,8 +221,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(); @@ -272,7 +284,6 @@ export function R6Settings({ return (

{t("settings")}

-
+