diff --git a/memhub/apps/core/src/one_memory/api.py b/memhub/apps/core/src/one_memory/api.py index 6195537..6eca464 100644 --- a/memhub/apps/core/src/one_memory/api.py +++ b/memhub/apps/core/src/one_memory/api.py @@ -1450,6 +1450,7 @@ def create_app( app.state.memory_graph_backfill = MemoryGraphBackfill( coordinator=app.state.sdk_write_coordinator, state=app.state.evolve_state_store, + lifecycle_open=lambda: _embedding_writes_open(app), ) # C2 owns the single production budget (handle 5s + reserve 250ms, # total 5.25s, lease 10s, renew 1s, claim 250ms). The composition @@ -1848,6 +1849,16 @@ def create_app( name="one-memory-durable-write-drain", ) + async def resume_graph_backfill() -> None: + saved = app.state.evolve_state_store.graph_build() + if saved.state != "running": + return + if _memory_mode(app) != "ultra": + app.state.evolve_state_store.set_graph_build(GraphBuildState()) + return + users = await asyncio.to_thread(_known_user_ids, app) + app.state.memory_graph_backfill.recover(users) + async def start_evolve_schedule() -> None: # Idle outside ultra: each tick asks the mode first. app.state.evolve_scheduler.start() @@ -1936,6 +1947,7 @@ def create_app( # served from the live space throughout. if sdk: await step("embedding_spaces", resume_embedding_spaces) + await step("graph_backfill", resume_graph_backfill) await step("write_drain", start_write_drain) if sdk: await step("evolve_schedule", start_evolve_schedule) diff --git a/memhub/apps/core/src/one_memory/memory_evolution.py b/memhub/apps/core/src/one_memory/memory_evolution.py index 1488ac6..abfd9cc 100644 --- a/memhub/apps/core/src/one_memory/memory_evolution.py +++ b/memhub/apps/core/src/one_memory/memory_evolution.py @@ -545,10 +545,12 @@ class MemoryGraphBackfill: *, coordinator: EvolveCoordinator, state: EvolveStateStore, + lifecycle_open: Callable[[], bool] = lambda: True, now: Callable[[], datetime] = lambda: datetime.now(UTC), ) -> None: self.coordinator = coordinator self.state = state + self.lifecycle_open = lifecycle_open self._now = now self._task: asyncio.Task[None] | None = None @@ -569,6 +571,35 @@ class MemoryGraphBackfill: self._task = asyncio.create_task(self._run(users), name="one-memory-graph-backfill") return state + def recover(self, user_ids: Iterable[str]) -> GraphBuildState: + """Resume an interrupted backfill without repeating completed users.""" + if self.running: + return self.state.graph_build() + saved = self.state.graph_build() + if saved.state != "running": + return saved + users = tuple(dict.fromkeys((*saved.users.keys(), *user_ids))) + pending = tuple( + user_id + for user_id in users + if saved.users.get(user_id, GraphBuildUser()).state != "succeeded" + ) + resumed = saved.model_copy(deep=True) + for user_id in pending: + resumed.users[user_id] = GraphBuildUser() + if not pending: + resumed.state = "succeeded" + resumed.finished_at = self._now() + self.state.set_graph_build(resumed) + return resumed + resumed.state = "running" + resumed.finished_at = None + self.state.set_graph_build(resumed) + self._task = asyncio.create_task( + self._run(pending), name="one-memory-graph-backfill-recovery" + ) + return resumed + async def wait(self) -> None: if self._task is not None: await asyncio.shield(self._task) @@ -576,6 +607,8 @@ class MemoryGraphBackfill: async def _run(self, users: tuple[str, ...]) -> None: state = self.state.graph_build() for user_id in users: + while not self.lifecycle_open(): + await asyncio.sleep(0.2) state.users[user_id] = GraphBuildUser(state="running") await asyncio.to_thread(self.state.set_graph_build, state.model_copy(deep=True)) try: diff --git a/memhub/apps/core/tests/test_memory_mode_and_evolve.py b/memhub/apps/core/tests/test_memory_mode_and_evolve.py index b8f3255..fced7df 100644 --- a/memhub/apps/core/tests/test_memory_mode_and_evolve.py +++ b/memhub/apps/core/tests/test_memory_mode_and_evolve.py @@ -41,6 +41,8 @@ from one_memory.memory_evolution import ( EvolveScheduler, EvolveService, EvolveStateStore, + GraphBuildState, + GraphBuildUser, MemoryGraphBackfill, evolve_due, ) @@ -692,6 +694,63 @@ def test_the_graph_backfill_runs_once_per_user(tmp_path, monkeypatch) -> None: } +def test_an_interrupted_graph_backfill_resumes_only_unfinished_users(tmp_path, monkeypatch) -> None: + sdk = _EvolveSdk() + state = EvolveStateStore(tmp_path) + state.set_graph_build( + GraphBuildState( + state="running", + started_at=_at(1, 3), + users={ + "local": GraphBuildUser(state="succeeded", memories_scanned=4, nodes=4), + "wife": GraphBuildUser(state="running"), + }, + ) + ) + backfill = MemoryGraphBackfill(coordinator=_Coordinator(sdk, monkeypatch), state=state) + + async def scenario(): + resumed = backfill.recover(["local", "wife"]) + assert resumed.state == "running" + assert resumed.users["local"].state == "succeeded" + assert resumed.users["wife"].state == "pending" + await backfill.wait() + + asyncio.run(scenario()) + assert sdk.graph_builds == ["wife"] + assert state.graph_build().state == "succeeded" + + +def test_graph_backfill_waits_for_embedding_maintenance_to_finish(tmp_path, monkeypatch) -> None: + sdk = _EvolveSdk() + state = EvolveStateStore(tmp_path) + gate = [False] + checked = asyncio.Event() + + def lifecycle_open() -> bool: + checked.set() + return gate[0] + + backfill = MemoryGraphBackfill( + coordinator=_Coordinator(sdk, monkeypatch), + state=state, + lifecycle_open=lifecycle_open, + ) + + async def scenario(): + backfill.start(["local"]) + await checked.wait() + assert sdk.graph_builds == [] + assert state.graph_build().state == "running" + assert state.graph_build().users["local"].state == "pending" + gate[0] = True + await backfill.wait() + + asyncio.run(scenario()) + assert sdk.graph_builds == ["local"] + assert state.graph_build().state == "succeeded" + + # --- the HTTP surface ----------------------------------------------------------- diff --git a/memhub/apps/web/src/App.setup.test.tsx b/memhub/apps/web/src/App.setup.test.tsx index 716f1c1..f013891 100644 --- a/memhub/apps/web/src/App.setup.test.tsx +++ b/memhub/apps/web/src/App.setup.test.tsx @@ -37,6 +37,36 @@ const { getOnboarding, sources, modelProps, mutations } = vi.hoisted(() => ({ vi.mock("./api", () => ({ getOnboarding, getModelProfiles: vi.fn().mockResolvedValue([]), + getMemoryMode: vi.fn().mockResolvedValue({ + mode: "pro", + configured_mode: "pro", + explicitly_configured: false, + forced_by_env: false, + modes: ["lite", "pro", "ultra"], + graph_build: { + state: "idle", + started_at: null, + finished_at: null, + users: {}, + }, + }), + getEvolveSchedule: vi.fn().mockResolvedValue({ + enabled: true, + time: "03:00", + }), + getEvolveOverview: vi.fn().mockResolvedValue({ + mode: "pro", + schedule: { enabled: true, time: "03:00" }, + users: {}, + running: [], + }), + getMemoryProcessing: vi.fn().mockResolvedValue({ + embedding_switch: null, + }), + setMemoryMode: vi.fn(), + setEvolveSchedule: vi.fn(), + startEvolve: vi.fn(), + apiErrorCode: vi.fn().mockReturnValue(null), getSources: vi.fn().mockResolvedValue(sources), getCurrentEmbeddingSwitch: vi .fn() diff --git a/memhub/apps/web/src/MemoryModeSettings.test.tsx b/memhub/apps/web/src/MemoryModeSettings.test.tsx index 2151b7c..55dbb8a 100644 --- a/memhub/apps/web/src/MemoryModeSettings.test.tsx +++ b/memhub/apps/web/src/MemoryModeSettings.test.tsx @@ -80,6 +80,43 @@ it("confirms the data effects before switching to lite", async () => { await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite")); }); +it("traps modal focus, closes on Escape and restores the mode-card focus", async () => { + prepare("pro"); + const user = userEvent.setup(); + render(); + const lite = await screen.findByRole("button", { + name: /基础记忆(lite)/, + }); + await user.click(lite); + + const cancel = screen.getByRole("button", { name: "取消" }); + await waitFor(() => expect(document.activeElement).toBe(cancel)); + await user.keyboard("{Shift>}{Tab}{/Shift}"); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "切换模式" }), + ); + await user.keyboard("{Escape}"); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.activeElement).toBe(lite); +}); + +it("keeps focus inside the modal while the mode change is pending", async () => { + prepare("pro"); + vi.mocked(setMemoryMode).mockReturnValue(new Promise(() => {})); + const user = userEvent.setup(); + render(); + await user.click( + await screen.findByRole("button", { name: /基础记忆(lite)/ }), + ); + await user.click(screen.getByRole("button", { name: "切换模式" })); + + const modal = screen.getByRole("dialog"); + await waitFor(() => expect(document.activeElement).toBe(modal)); + await user.keyboard("{Tab}"); + expect(document.activeElement).toBe(modal); +}); + it("routes to model setup instead of switching pro without a model", async () => { prepare("lite"); const configure = vi.fn(); @@ -178,3 +215,22 @@ it("does not let an older refresh undo a saved schedule change", async () => { expect(toggle.getAttribute("aria-checked")).toBe("false"), ); }); + +it("keeps the mode usable when an evolve status read fails", async () => { + prepare("pro"); + vi.mocked(getEvolveSchedule).mockRejectedValueOnce(new Error("offline")); + render(); + + expect( + ( + await screen.findByRole("button", { name: /智能记忆(pro)/ }) + ).getAttribute("aria-pressed"), + ).toBe("true"); + expect( + ( + screen.getByRole("button", { + name: /基础记忆(lite)/, + }) as HTMLButtonElement + ).disabled, + ).toBe(false); +}); diff --git a/memhub/apps/web/src/MemoryModeSettings.tsx b/memhub/apps/web/src/MemoryModeSettings.tsx index 7f575ae..b18a2fc 100644 --- a/memhub/apps/web/src/MemoryModeSettings.tsx +++ b/memhub/apps/web/src/MemoryModeSettings.tsx @@ -67,10 +67,12 @@ function plannedEffects( export function MemoryModeSettings({ hasActiveModel, + modelStateKnown = true, onConfigureModel, onModeChange, }: { hasActiveModel: boolean; + modelStateKnown?: boolean; onConfigureModel(): void; onModeChange?(mode: MemoryPipelineMode): void; }) { @@ -86,29 +88,76 @@ export function MemoryModeSettings({ const [error, setError] = useState(""); const [run, setRun] = useState(null); const refreshVersion = useRef(0); + const dialog = useRef(null); + const dialogOpener = useRef(null); + const busyRef = useRef(false); + busyRef.current = busy; + + useEffect(() => { + if (!pendingMode || !dialog.current) return; + const panel = dialog.current; + const controls = () => + Array.from(panel.querySelectorAll("button:not(:disabled)")); + controls()[0]?.focus(); + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && !busyRef.current) { + event.preventDefault(); + setPendingMode(null); + return; + } + if (event.key !== "Tab") return; + const items = controls(); + const first = items[0]; + const last = items.at(-1); + if (!first || !last) { + event.preventDefault(); + panel.focus(); + return; + } + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + panel.addEventListener("keydown", handleKey); + return () => { + panel.removeEventListener("keydown", handleKey); + if (dialogOpener.current?.isConnected) dialogOpener.current.focus(); + }; + }, [pendingMode]); + + useEffect(() => { + if (pendingMode && busy) dialog.current?.focus(); + }, [busy, pendingMode]); const refresh = useCallback(async () => { const version = ++refreshVersion.current; - try { - const [nextMode, nextSchedule, nextOverview, processing] = - await Promise.all([ - getMemoryMode(), - getEvolveSchedule(), - getEvolveOverview(), - getMemoryProcessing(), - ]); - if (version !== refreshVersion.current) return; - setState(nextMode); - onModeChange?.(nextMode.mode); - setScheduleState(nextSchedule); - setOverview(nextOverview); - setMaintenance(Boolean(processing.embedding_switch)); - setRun(nextOverview.running[0] ?? null); + const [modeResult, scheduleResult, overviewResult, processingResult] = + await Promise.allSettled([ + getMemoryMode(), + getEvolveSchedule(), + getEvolveOverview(), + getMemoryProcessing(), + ]); + if (version !== refreshVersion.current) return; + if (modeResult.status === "fulfilled") { + setState(modeResult.value); + onModeChange?.(modeResult.value.mode); setError(""); - } catch { - if (version !== refreshVersion.current) return; + } else { setError("记忆模式状态暂时无法读取,请重试。"); } + if (scheduleResult.status === "fulfilled") + setScheduleState(scheduleResult.value); + if (overviewResult.status === "fulfilled") { + setOverview(overviewResult.value); + setRun(overviewResult.value.running[0] ?? null); + } + if (processingResult.status === "fulfilled") + setMaintenance(Boolean(processingResult.value.embedding_switch)); }, [onModeChange]); useEffect(() => { @@ -138,6 +187,10 @@ export function MemoryModeSettings({ return; } setError(""); + dialogOpener.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; setPendingMode(mode); } @@ -226,7 +279,12 @@ export function MemoryModeSettings({ type="button" className="r6-mode-option" aria-pressed={state?.mode === mode} - disabled={!state || busy || lifecycleBusy} + disabled={ + !state || + busy || + lifecycleBusy || + (mode !== "lite" && !modelStateKnown) + } onClick={() => choose(mode)} > {text(MODE_COPY[mode].title)} @@ -320,10 +378,12 @@ export function MemoryModeSettings({ {pendingMode ? (

{text("切换到 {mode}?").replace("{mode}", pendingMode)} diff --git a/memhub/apps/web/src/MemorySpace.r6.test.tsx b/memhub/apps/web/src/MemorySpace.r6.test.tsx index 75b4228..15004e7 100644 --- a/memhub/apps/web/src/MemorySpace.r6.test.tsx +++ b/memhub/apps/web/src/MemorySpace.r6.test.tsx @@ -255,6 +255,52 @@ it("shows raw conversations instead of advanced memory in lite", async () => { expect(screen.getByText(/当前使用基础记忆/)).toBeTruthy(); }); +it("does not request advanced memories before the pipeline mode is known", async () => { + setup(); + let resolveProcessing!: (value: unknown) => void; + reads.processing.mockReturnValueOnce( + new Promise((resolve) => { + resolveProcessing = resolve; + }), + ); + render(); + + await act(async () => Promise.resolve()); + expect(reads.sdkLibrary).not.toHaveBeenCalled(); + expect(reads.sdkGraph).not.toHaveBeenCalled(); + + await act(async () => { + resolveProcessing({ + running: 0, + pending: 0, + blocked_stages: [], + completed: 0, + pipeline: { + mode: "lite", + configured_mode: "lite", + forced_by_env: false, + evolve: { + mode: "lite", + schedule: { enabled: true, time: "03:00" }, + users: {}, + running: [], + }, + last_evolve_at: null, + graph_build: { + state: "idle", + started_at: null, + finished_at: null, + users: {}, + }, + features: {}, + }, + }); + }); + await screen.findByText(/当前使用基础记忆/); + expect(reads.sdkLibrary).not.toHaveBeenCalled(); + expect(reads.sdkGraph).not.toHaveBeenCalled(); +}); + it("keeps an added memory visible while it waits for model processing", async () => { setup(); render(); diff --git a/memhub/apps/web/src/MemorySpace.tsx b/memhub/apps/web/src/MemorySpace.tsx index 974b13f..2754cbe 100644 --- a/memhub/apps/web/src/MemorySpace.tsx +++ b/memhub/apps/web/src/MemorySpace.tsx @@ -214,7 +214,21 @@ export function MemorySpace({ // The page the list shows, for the namespace of a memory being opened. const sdkPageRef = useRef(null); sdkPageRef.current = sdkLibrary.page; - const sdkMapVisible = simplified && active && displayedMode === "map"; + const [processing, setProcessing] = useState( + null, + ); + const [processingFailed, setProcessingFailed] = useState(false); + const [processingResolved, setProcessingResolved] = useState(false); + const pipelineMode = + processing?.pipeline?.mode ?? + (processingResolved && !processingFailed ? "pro" : null); + const lite = pipelineMode === "lite"; + const sdkMapVisible = + simplified && + active && + displayedMode === "map" && + pipelineMode !== null && + !lite; useEffect(() => { if (!sdkMapVisible) return; // A write bumps the revision; reading it here re-runs the read at once @@ -337,15 +351,6 @@ export function MemorySpace({ } const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const [processing, setProcessing] = useState( - null, - ); - const [processingFailed, setProcessingFailed] = useState(false); - const [processingResolved, setProcessingResolved] = useState(false); - const pipelineMode = - processing?.pipeline?.mode ?? - (processingResolved && !processingFailed ? "pro" : null); - const lite = pipelineMode === "lite"; const [pendingAdd, setPendingAdd] = useState(null); const [checkingSetup, setCheckingSetup] = useState(false); // biome-ignore lint/correctness/useExhaustiveDependencies: Source authority changes invalidate an in-flight setup snapshot. @@ -1176,10 +1181,12 @@ export function MemorySpace({ className="memory-library-layout" hidden={displayedMode !== "library"} > - + {!simplified || (pipelineMode !== null && !lite) ? ( + + ) : null}

); diff --git a/memhub/apps/web/src/OnboardingModelController.test.tsx b/memhub/apps/web/src/OnboardingModelController.test.tsx new file mode 100644 index 0000000..1b2e3a2 --- /dev/null +++ b/memhub/apps/web/src/OnboardingModelController.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { OnboardingModelController } from "./OnboardingModelController"; + +const { models } = vi.hoisted(() => ({ + models: { + profiles: [ + { + profile_id: "active-profile", + revision: 3, + name: "Current model", + provider_kind: "openai_compatible", + model: "model-1", + base_url: "https://models.example/v1", + disable_thinking: true, + embedding_provider_kind: "local_onnx", + embedding_model: "bge-small-zh-v1.5", + embedding_base_url: null, + timeout_seconds: 90, + max_concurrency: 4, + context_window: null, + has_api_key: true, + is_active: true, + }, + ], + pendingProbe: false, + probeStorageFailed: false, + pendingCreation: false, + creationStorageFailed: false, + error: null, + create: vi.fn(), + update: vi.fn(), + probeProfile: vi.fn(), + activate: vi.fn(), + }, +})); + +vi.mock("./use-model-profiles", () => ({ + useModelProfiles: () => models, +})); +vi.mock("./OnboardingModelStep", () => ({ + OnboardingModelStep: (props: { + probe?: { message: string } | null; + enableActionLabel?: string; + onSaveAndEnable(): Promise; + }) => ( +
+

{props.probe?.message}

+ +
+ ), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +it("resumes onboarding with an already active model after a restart", async () => { + const enabled = vi.fn(); + render( + , + ); + + expect( + await screen.findByText("当前模型已经启用,可以继续完成首次设置。"), + ).toBeTruthy(); + await userEvent.click( + screen.getByRole("button", { name: "继续使用当前模型" }), + ); + + await waitFor(() => expect(enabled).toHaveBeenCalledOnce()); + expect(models.activate).not.toHaveBeenCalled(); +}); diff --git a/memhub/apps/web/src/OnboardingModelController.tsx b/memhub/apps/web/src/OnboardingModelController.tsx index b125dcf..b8e548b 100644 --- a/memhub/apps/web/src/OnboardingModelController.tsx +++ b/memhub/apps/web/src/OnboardingModelController.tsx @@ -89,6 +89,37 @@ export function OnboardingModelController({ null, ); const flight = useRef(false); + const draftTouched = useRef(false); + + useEffect(() => { + const active = (models.profiles ?? []).find((profile) => profile.is_active); + if (!active || saved || draftTouched.current) return; + setSaved(active); + setDraft((current) => ({ + ...current, + name: active.name, + provider_kind: active.provider_kind, + model: active.model, + base_url: active.base_url ?? "", + disable_thinking: active.disable_thinking, + embedding_mode: + active.embedding_provider_kind === "local_onnx" ? "local" : "custom", + embedding_provider_kind: + active.embedding_provider_kind === "ollama" + ? "ollama" + : "openai_compatible", + embedding_model: active.embedding_model ?? "", + embedding_base_url: active.embedding_base_url ?? "", + timeout_seconds: String(active.timeout_seconds), + max_concurrency: String(active.max_concurrency), + context_window: + active.context_window === null ? "" : String(active.context_window), + })); + setProbe({ + state: "passed", + message: "当前模型已经启用,可以继续完成首次设置。", + }); + }, [models.profiles, saved]); useEffect(() => { const finished = embeddingSwitch.current?.last_finished; @@ -113,6 +144,7 @@ export function OnboardingModelController({ function edit(patch: Partial) { if (flight.current) return; + draftTouched.current = true; if (patch.provider_id && patch.provider_id !== draft.provider_id) { const preset = PROVIDER_PRESETS.find( (item) => item.id === patch.provider_id, @@ -259,6 +291,10 @@ export function OnboardingModelController({ async function enable() { if (flight.current || probe?.state !== "passed" || !saved) return; + if (saved.is_active) { + onEnabled?.(); + return; + } flight.current = true; setPhase("enabling"); setError(null); @@ -313,6 +349,7 @@ export function OnboardingModelController({ 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} diff --git a/memhub/apps/web/src/R6Settings.tsx b/memhub/apps/web/src/R6Settings.tsx index 3b360e0..78edffa 100644 --- a/memhub/apps/web/src/R6Settings.tsx +++ b/memhub/apps/web/src/R6Settings.tsx @@ -307,6 +307,7 @@ export function R6Settings({ > openConfiguration("generation")} onModeChange={setMemoryModeState} /> diff --git a/memhub/apps/web/src/SdkRawHistory.test.tsx b/memhub/apps/web/src/SdkRawHistory.test.tsx index 9f69177..84604b5 100644 --- a/memhub/apps/web/src/SdkRawHistory.test.tsx +++ b/memhub/apps/web/src/SdkRawHistory.test.tsx @@ -1,5 +1,11 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, expect, it, vi } from "vitest"; @@ -15,6 +21,8 @@ import { SdkRawHistory } from "./SdkRawHistory"; afterEach(() => { cleanup(); localStorage.clear(); + vi.clearAllMocks(); + vi.useRealTimers(); }); it("localizes controls without translating original conversation fields", async () => { @@ -114,3 +122,123 @@ it("does not call a zero-result search an empty conversation history", async () screen.queryByText("还没有原始对话。连接来源后,对话会显示在这里。"), ).toBeNull(); }); + +it("retries the lite history read in place after a load failure", async () => { + getSdkRaw.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ + items: [ + { + raw_id: "raw-recovered", + content: "恢复后的原始对话", + session_id: "session-recovered", + source: "cursor", + memory_at: null, + }, + ], + total: 1, + offset: 0, + limit: 100, + }); + render(); + + await userEvent.click(await screen.findByRole("button", { name: "重试" })); + + expect(await screen.findByText("恢复后的原始对话")).toBeTruthy(); + expect(getSdkRaw).toHaveBeenCalledTimes(2); +}); + +it("retries the failed raw search instead of reloading history", async () => { + getSdkRaw.mockResolvedValue({ + items: [], + total: 0, + offset: 0, + limit: 100, + }); + searchSdkRaw + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce([ + { + kind: "raw", + raw_id: "raw-search-recovered", + content: "重试后的搜索结果", + source: "cursor", + session_id: "session-search", + memory_at: null, + score: 0.8, + truncated: false, + }, + ]); + render(); + + await userEvent.type(screen.getByLabelText("搜索原始对话"), "恢复搜索"); + await userEvent.click(screen.getByRole("button", { name: "搜索" })); + await userEvent.click(await screen.findByRole("button", { name: "重试" })); + + expect(await screen.findByText("重试后的搜索结果")).toBeTruthy(); + expect(searchSdkRaw).toHaveBeenCalledTimes(2); + expect(getSdkRaw).toHaveBeenCalledTimes(1); +}); + +it("does not let a history poll clear an independent search failure", async () => { + vi.useFakeTimers(); + getSdkRaw.mockResolvedValue({ + items: [ + { + raw_id: "raw-old", + content: "已有历史", + session_id: "session-old", + source: "cursor", + memory_at: null, + }, + ], + total: 1, + offset: 0, + limit: 100, + }); + searchSdkRaw.mockRejectedValue(new Error("search offline")); + render(); + await act(async () => Promise.resolve()); + + fireEvent.change(screen.getByLabelText("搜索原始对话"), { + target: { value: "失败查询" }, + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "搜索" })); + await Promise.resolve(); + }); + expect(screen.getByRole("alert").textContent).toContain("原始对话搜索失败"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(12_000); + }); + expect(screen.getByRole("alert").textContent).toContain("原始对话搜索失败"); +}); + +it("ignores a stale initial-history failure after a newer search succeeds", async () => { + let rejectHistory!: (reason: Error) => void; + getSdkRaw.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectHistory = reject; + }), + ); + searchSdkRaw.mockResolvedValue([ + { + kind: "raw", + raw_id: "raw-new-search", + content: "较新的搜索结果", + source: "cursor", + session_id: "session-new", + memory_at: null, + score: 0.9, + truncated: false, + }, + ]); + render(); + + await userEvent.type(screen.getByLabelText("搜索原始对话"), "新查询"); + await userEvent.click(screen.getByRole("button", { name: "搜索" })); + expect(await screen.findByText("较新的搜索结果")).toBeTruthy(); + await act(async () => rejectHistory(new Error("late history failure"))); + + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByText("较新的搜索结果")).toBeTruthy(); +}); diff --git a/memhub/apps/web/src/SdkRawHistory.tsx b/memhub/apps/web/src/SdkRawHistory.tsx index 7177d4a..c59af4b 100644 --- a/memhub/apps/web/src/SdkRawHistory.tsx +++ b/memhub/apps/web/src/SdkRawHistory.tsx @@ -12,18 +12,32 @@ export function SdkRawHistory({ active = true }: { active?: boolean }) { const [results, setResults] = useState(null); const [query, setQuery] = useState(""); const [error, setError] = useState(""); + const [errorKind, setErrorKind] = useState<"load" | "search" | null>(null); + const errorKindRef = useRef<"load" | "search" | null>(null); + const searchEpoch = useRef(0); const [busy, setBusy] = useState(false); + const [revision, setRevision] = useState(0); + // biome-ignore lint/correctness/useExhaustiveDependencies: revision is the explicit retry trigger. useEffect(() => { if (!active) return; let current = true; + const startedBeforeSearch = searchEpoch.current; const consume = (value: unknown) => { if (!current) return; setRecent((value as Awaited>).items); - setError(""); + if (errorKindRef.current !== "search") { + errorKindRef.current = null; + setError(""); + setErrorKind(null); + } }; void getSdkRaw({ limit: 100 }).then(consume, () => { - if (current) setError(translation.current("sdk.raw.loadFailed")); + if (current && startedBeforeSearch === searchEpoch.current) { + setError(translation.current("sdk.raw.loadFailed")); + errorKindRef.current = "load"; + setErrorKind("load"); + } }); const stop = subscribePoll({ key: "sdk-raw-history", @@ -35,20 +49,30 @@ export function SdkRawHistory({ active = true }: { active?: boolean }) { current = false; stop(); }; - }, [active]); + }, [active, revision]); + + async function runSearch(value: string) { + searchEpoch.current += 1; + setBusy(true); + setResults(null); + setError(""); + errorKindRef.current = null; + setErrorKind(null); + try { + setResults(await searchSdkRaw(value)); + } catch { + setError(t("sdk.raw.searchFailed")); + errorKindRef.current = "search"; + setErrorKind("search"); + } finally { + setBusy(false); + } + } async function submit(event: FormEvent) { event.preventDefault(); if (!query.trim()) return; - setBusy(true); - setError(""); - try { - setResults(await searchSdkRaw(query.trim())); - } catch { - setError(t("sdk.raw.searchFailed")); - } finally { - setBusy(false); - } + await runSearch(query.trim()); } const items = results ?? recent.map(asContextItem); @@ -66,7 +90,27 @@ export function SdkRawHistory({ active = true }: { active?: boolean }) { {busy ? t("sdk.raw.searching") : t("sdk.raw.search")} - {error ?

{error}

: null} + {error ? ( +

+ {error}{" "} + +

+ ) : null}

{results === null ? t("sdk.raw.recent") diff --git a/memhub/docs/contracts/memory-mode-evolve-v1.md b/memhub/docs/contracts/memory-mode-evolve-v1.md index f13c63e..c393592 100644 --- a/memhub/docs/contracts/memory-mode-evolve-v1.md +++ b/memhub/docs/contracts/memory-mode-evolve-v1.md @@ -72,6 +72,8 @@ and they are the owner's: (`build_memory_graph(user_id, limit=10000, clear_first=False)`); - this runs in the background, one user at a time, never beside that user's write; + - after a crash or App update, startup resumes users that did not finish + and does not repeat users already recorded as succeeded; - progress is reported in `graph_build`. - Vector collection names never depend on the mode. The mode is a separate client parameter, not part of the embedding-space identity. diff --git a/memhub/packages/i18n/src/gui-i18n-mode.ts b/memhub/packages/i18n/src/gui-i18n-mode.ts index bda3492..bcdf9e0 100644 --- a/memhub/packages/i18n/src/gui-i18n-mode.ts +++ b/memhub/packages/i18n/src/gui-i18n-mode.ts @@ -6,6 +6,9 @@ const modeChineseMessages = { 返回导入: "返回导入", 继续: "继续", "正在保存…": "正在保存…", + "当前模型已经启用,可以继续完成首次设置。": + "当前模型已经启用,可以继续完成首次设置。", + 继续使用当前模型: "继续使用当前模型", "你希望 MemHub 如何形成记忆?": "你希望 MemHub 如何形成记忆?", "需要明确选择一种方式;之后仍可在设置中更改。": "需要明确选择一种方式;之后仍可在设置中更改。", @@ -85,6 +88,9 @@ export const modeEnglishMessages: ModeMessages = { 返回导入: "Back to import", 继续: "Continue", "正在保存…": "Saving…", + "当前模型已经启用,可以继续完成首次设置。": + "The current model is already enabled. Continue to finish setup.", + 继续使用当前模型: "Continue with current model", "你希望 MemHub 如何形成记忆?": "How should MemHub create memories?", "需要明确选择一种方式;之后仍可在设置中更改。": "Choose one option. You can change it later in Settings.", @@ -166,6 +172,9 @@ export const modeSpanishMessages: ModeMessages = { 返回导入: "Volver a la importación", 继续: "Continuar", "正在保存…": "Guardando…", + "当前模型已经启用,可以继续完成首次设置。": + "El modelo actual ya está activo. Continúa para terminar la configuración.", + 继续使用当前模型: "Continuar con el modelo actual", "你希望 MemHub 如何形成记忆?": "¿Cómo quieres que MemHub cree memorias?", "需要明确选择一种方式;之后仍可在设置中更改。": "Elige una opción. Podrás cambiarla después en Configuración.", @@ -247,6 +256,9 @@ export const modeTraditionalChineseMessages: ModeMessages = { 返回导入: "返回匯入", 继续: "繼續", "正在保存…": "儲存中…", + "当前模型已经启用,可以继续完成首次设置。": + "目前模型已啟用,可以繼續完成首次設定。", + 继续使用当前模型: "繼續使用目前模型", "你希望 MemHub 如何形成记忆?": "你希望 MemHub 如何形成記憶?", "需要明确选择一种方式;之后仍可在设置中更改。": "請選擇一種方式;之後仍可在設定中更改。", @@ -322,6 +334,9 @@ export const modeJapaneseMessages: ModeMessages = { 返回导入: "インポートに戻る", 继续: "続ける", "正在保存…": "保存中…", + "当前模型已经启用,可以继续完成首次设置。": + "現在のモデルはすでに有効です。このまま初期設定を完了できます。", + 继续使用当前模型: "現在のモデルで続ける", "你希望 MemHub 如何形成记忆?": "MemHub でどのように記憶を作成しますか?", "需要明确选择一种方式;之后仍可在设置中更改。": "いずれかを選択してください。後から設定で変更できます。", @@ -403,6 +418,9 @@ export const modeGermanMessages: ModeMessages = { 返回导入: "Zurück zum Import", 继续: "Weiter", "正在保存…": "Wird gespeichert…", + "当前模型已经启用,可以继续完成首次设置。": + "Das aktuelle Modell ist bereits aktiviert. Fahre fort, um die Einrichtung abzuschließen.", + 继续使用当前模型: "Mit aktuellem Modell fortfahren", "你希望 MemHub 如何形成记忆?": "Wie soll MemHub Erinnerungen erstellen?", "需要明确选择一种方式;之后仍可在设置中更改。": "Wählen Sie eine Option. Sie können sie später in den Einstellungen ändern.", @@ -485,6 +503,9 @@ export const modePortugueseMessages: ModeMessages = { 返回导入: "Voltar à importação", 继续: "Continuar", "正在保存…": "A guardar…", + "当前模型已经启用,可以继续完成首次设置。": + "O modelo atual já está ativo. Continua para concluir a configuração.", + 继续使用当前模型: "Continuar com o modelo atual", "你希望 MemHub 如何形成记忆?": "Como deve o MemHub criar memórias?", "需要明确选择一种方式;之后仍可在设置中更改。": "Escolha uma opção. Pode alterá-la mais tarde nas Definições.", @@ -567,6 +588,9 @@ export const modeFrenchMessages: ModeMessages = { 返回导入: "Retour à l’importation", 继续: "Continuer", "正在保存…": "Enregistrement…", + "当前模型已经启用,可以继续完成首次设置。": + "Le modèle actuel est déjà activé. Continuez pour terminer la configuration.", + 继续使用当前模型: "Continuer avec le modèle actuel", "你希望 MemHub 如何形成记忆?": "Comment MemHub doit-il créer des mémoires ?", "需要明确选择一种方式;之后仍可在设置中更改。": "Choisissez une option. Vous pourrez la modifier plus tard dans les réglages.", @@ -648,6 +672,9 @@ export const modeKoreanMessages: ModeMessages = { 返回导入: "가져오기로 돌아가기", 继续: "계속", "正在保存…": "저장 중…", + "当前模型已经启用,可以继续完成首次设置。": + "현재 모델이 이미 활성화되어 있습니다. 계속해서 초기 설정을 완료하세요.", + 继续使用当前模型: "현재 모델로 계속", "你希望 MemHub 如何形成记忆?": "MemHub가 메모리를 어떻게 만들까요?", "需要明确选择一种方式;之后仍可在设置中更改。": "한 가지 방식을 선택하세요. 나중에 설정에서 변경할 수 있습니다.", @@ -729,6 +756,9 @@ export const modeRussianMessages: ModeMessages = { 返回导入: "Назад к импорту", 继续: "Продолжить", "正在保存…": "Сохранение…", + "当前模型已经启用,可以继续完成首次设置。": + "Текущая модель уже включена. Продолжите, чтобы завершить настройку.", + 继续使用当前模型: "Продолжить с текущей моделью", "你希望 MemHub 如何形成记忆?": "Как MemHub должен создавать воспоминания?", "需要明确选择一种方式;之后仍可在设置中更改。": "Выберите один вариант. Его можно изменить позже в настройках.", @@ -811,6 +841,9 @@ export const modeIndonesianMessages: ModeMessages = { 返回导入: "Kembali ke impor", 继续: "Lanjutkan", "正在保存…": "Menyimpan…", + "当前模型已经启用,可以继续完成首次设置。": + "Model saat ini sudah aktif. Lanjutkan untuk menyelesaikan penyiapan.", + 继续使用当前模型: "Lanjutkan dengan model saat ini", "你希望 MemHub 如何形成记忆?": "Bagaimana MemHub membuat memori?", "需要明确选择一种方式;之后仍可在设置中更改。": "Pilih satu opsi. Anda dapat mengubahnya nanti di Pengaturan.",