Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/web/src/OnboardingModelController.tsx
#	memhub/apps/web/src/R6Settings.tsx
This commit is contained in:
kristinhe committed 2026-11-03 18:38:24 +08:00
commit f754f8bc0f
15 files changed
+677 -46

No files matched your search

+12
View File
@@ -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)
@@ -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:
@@ -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 -----------------------------------------------------------
+30
View File
@@ -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()
@@ -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(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
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(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
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(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
expect(
(
await screen.findByRole("button", { name: /智能记忆(pro)/ })
).getAttribute("aria-pressed"),
).toBe("true");
expect(
(
screen.getByRole("button", {
name: /基础记忆(lite)/,
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
+78 -18
View File
@@ -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<EvolveRunReceipt | null>(null);
const refreshVersion = useRef(0);
const dialog = useRef<HTMLElement>(null);
const dialogOpener = useRef<HTMLElement | null>(null);
const busyRef = useRef(false);
busyRef.current = busy;
useEffect(() => {
if (!pendingMode || !dialog.current) return;
const panel = dialog.current;
const controls = () =>
Array.from(panel.querySelectorAll<HTMLElement>("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)}
>
<strong>{text(MODE_COPY[mode].title)}</strong>
@@ -320,10 +378,12 @@ export function MemoryModeSettings({
{pendingMode ? (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={dialog}
className="r6-settings-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace("{mode}", pendingMode)}
@@ -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(<MemorySpace simplified />);
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(<MemorySpace simplified onConfigureModel={vi.fn()} />);
+21 -14
View File
@@ -214,7 +214,21 @@ export function MemorySpace({
// The page the list shows, for the namespace of a memory being opened.
const sdkPageRef = useRef<MemoryLibraryPage | null>(null);
sdkPageRef.current = sdkLibrary.page;
const sdkMapVisible = simplified && active && displayedMode === "map";
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
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<string | null>(null);
const [processing, setProcessing] = useState<MemoryProcessingSummary | null>(
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<PendingMemoryAdd | null>(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"}
>
<SdkMemoryLibrary
active={active}
focusedMemoryId={sdkFocusedMemoryId}
/>
{!simplified || (pipelineMode !== null && !lite) ? (
<SdkMemoryLibrary
active={active}
focusedMemoryId={sdkFocusedMemoryId}
/>
) : null}
</div>
</section>
);
@@ -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<void>;
}) => (
<section>
<p>{props.probe?.message}</p>
<button type="button" onClick={() => void props.onSaveAndEnable()}>
{props.enableActionLabel}
</button>
</section>
),
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("resumes onboarding with an already active model after a restart", async () => {
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
onBack={vi.fn()}
onEnabled={enabled}
/>,
);
expect(
await screen.findByText("当前模型已经启用,可以继续完成首次设置。"),
).toBeTruthy();
await userEvent.click(
screen.getByRole("button", { name: "继续使用当前模型" }),
);
await waitFor(() => expect(enabled).toHaveBeenCalledOnce());
expect(models.activate).not.toHaveBeenCalled();
});
@@ -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<OnboardingModelDraft>) {
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}
+1
View File
@@ -307,6 +307,7 @@ export function R6Settings({
>
<MemoryModeSettings
hasActiveModel={currentModel !== null}
modelStateKnown={profiles !== null && !modelError}
onConfigureModel={() => openConfiguration("generation")}
onModeChange={setMemoryModeState}
/>
+129 -1
View File
@@ -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(<SdkRawHistory />);
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(<SdkRawHistory />);
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(<SdkRawHistory />);
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(<SdkRawHistory />);
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();
});
+57 -13
View File
@@ -12,18 +12,32 @@ export function SdkRawHistory({ active = true }: { active?: boolean }) {
const [results, setResults] = useState<SdkRawContextItem[] | null>(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<ReturnType<typeof getSdkRaw>>).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")}
</button>
</form>
{error ? <p role="alert">{error}</p> : null}
{error ? (
<p role="alert">
{error}{" "}
<button
type="button"
disabled={busy}
onClick={() => {
if (errorKind === "search" && query.trim()) {
void runSearch(query.trim());
} else {
setError("");
errorKindRef.current = null;
setErrorKind(null);
setRevision((value) => value + 1);
}
}}
>
{t("connectionFallback.retry")}
</button>
</p>
) : null}
<p aria-live="polite">
{results === null
? t("sdk.raw.recent")
@@ -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.
+33
View File
@@ -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.",