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:
commit
f754f8bc0f
15 files changed
+677
-46
No files matched your search
@@ -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 -----------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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()} />);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -307,6 +307,7 @@ export function R6Settings({
|
||||
>
|
||||
<MemoryModeSettings
|
||||
hasActiveModel={currentModel !== null}
|
||||
modelStateKnown={profiles !== null && !modelError}
|
||||
onConfigureModel={() => openConfiguration("generation")}
|
||||
onModeChange={setMemoryModeState}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in new issue
Block a user