Compare commits

...
Author SHA1 Message Date
kristinheandCursor f6713a4343 fix(memhub): share model probe readiness
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 03:07:59 +08:00
kristinhe1111 4cbf4f1501 feat(memhub): offer all memory modes during onboarding 2026-11-05 02:40:34 +08:00
26 changed files with 1634 additions and 590 deletions

No files matched your search

@@ -118,7 +118,7 @@ class OnboardingState(BaseModel):
selected_source_ids: tuple[str, ...]
started_at: datetime | None
completed_at: datetime | None
mode_intent: Literal["lite", "pro"] | None
mode_intent: Literal["lite", "pro", "ultra"] | None
setup_step: Literal["review", "mode", "model"] | None
model_profile_id: str | None
@@ -128,7 +128,7 @@ class OnboardingProgressUpdate(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
mode_intent: Literal["lite", "pro"] | None = None
mode_intent: Literal["lite", "pro", "ultra"] | None = None
setup_step: Literal["review", "mode", "model"] | None = None
model_profile_id: str | None = Field(default=None, min_length=36, max_length=36)
@@ -149,6 +149,13 @@ def test_progress_patch_and_put_round_trip_only_resumable_choices(tmp_path: Path
assert replaced.json()["model_profile_id"] == profile_id
assert (await client.get("/api/v1/onboarding")).json() == replaced.json()
ultra = await client.put(
"/api/v1/onboarding/progress",
json={"mode_intent": "ultra", "setup_step": "model"},
)
assert ultra.status_code == 200
assert ultra.json()["mode_intent"] == "ultra"
asyncio.run(scenario())
@@ -166,7 +173,7 @@ def test_progress_rejects_unknown_or_invalid_fields(tmp_path: Path) -> None:
)
invalid_mode = await client.patch(
"/api/v1/onboarding/progress",
json={"mode_intent": "ultra"},
json={"mode_intent": "future"},
)
assert secret.status_code == 422
@@ -115,9 +115,7 @@ it("confirms the data effects before switching to lite", async () => {
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(setMemoryMode).not.toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("lite"));
await waitFor(() =>
expect(document.activeElement).toBe(
@@ -139,7 +137,7 @@ it("traps modal focus, closes on Escape and restores the mode-card focus", async
expect(outside.inert).toBe(true);
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
screen.getByRole("button", { name: "确认选择" }),
);
await user.keyboard("{Escape}");
@@ -155,9 +153,7 @@ it("keeps focus inside the modal while the mode change is pending", async () =>
const user = userEvent.setup();
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /基础记忆(lite)/);
await user.click(
screen.getByRole("button", { name: "切换到基础记忆(lite)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const modal = screen.getByRole("dialog");
await waitFor(() => expect(document.activeElement).toBe(modal));
@@ -229,11 +225,11 @@ it("explains model cost, search scope and the next step before switching to ultr
expect(screen.getByText("模型调用与费用")).toBeTruthy();
expect(screen.getByText("搜索范围")).toBeTruthy();
expect(screen.getByText("切换后")).toBeTruthy();
expect(screen.getByText("选择后")).toBeTruthy();
expect(screen.getByText(/已经处理完的旧对话不会补做/)).toBeTruthy();
expect(screen.getByText("知识图谱")).toBeTruthy();
expect(screen.getAllByText("已有数据")).toHaveLength(1);
expect(screen.getByText(/切换本身不会立即整合/)).toBeTruthy();
expect(screen.getByText(/选择模式本身不会立即整合/)).toBeTruthy();
});
it("distinguishes regular memories from higher-level Ultra data when switching to pro", async () => {
@@ -279,9 +275,7 @@ it("focuses the Evolve settings after switching to ultra", async () => {
vi.mocked(getMemoryMode).mockResolvedValue(state("ultra"));
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
await waitFor(() =>
expect(document.activeElement?.getAttribute("id")).toContain("-evolve"),
@@ -498,14 +492,12 @@ it("keeps a failed switch explanation inside the open confirmation dialog", asyn
render(<MemoryModeSettings hasActiveModel onConfigureModel={vi.fn()} />);
await chooseMode(user, /深度记忆(ultra)/);
await user.click(
screen.getByRole("button", { name: "切换到深度记忆(ultra)" }),
);
await user.click(screen.getByRole("button", { name: "确认选择" }));
const dialog = screen.getByRole("dialog");
expect(
await within(dialog).findByText(
"当前安装缺少知识图谱组件,无法开启 ultra。",
"当前安装缺少知识图谱组件,无法选择 ultra。",
),
).toBeTruthy();
});
@@ -541,7 +533,7 @@ it("blocks mode and Evolve controls during embedding maintenance", async () => {
expect(
await screen.findByText(
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
),
).toBeTruthy();
expect(
+27 -241
View File
@@ -26,96 +26,15 @@ import {
issueConfirmedModeTicket,
} from "./confirmed-memory-mode";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import {
BLOCKER_COPY,
MemoryModeConfirmDialog,
MODE_COPY,
} from "./memory-mode-ui";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import { R6Button } from "./R6Controls";
import { R6StateRecovery } from "./R6StateRecovery";
import { TimePicker } from "./TimePicker";
const MODE_COPY: Record<
MemoryPipelineMode,
{ title: string; description: string }
> = {
lite: {
title: "基础记忆(lite)",
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
},
pro: {
title: "智能记忆(pro)",
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
},
ultra: {
title: "深度记忆(ultra)",
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
},
};
function planRows(plan: MemoryModePlan) {
const rows = [
{
label: "新对话",
content:
plan.capabilities.content_plane === "raw"
? "之后只保存原始对话,不再生成新记忆。"
: plan.capabilities.graph_enabled
? "新对话会整理成记忆并写入知识图谱。"
: "新对话会由模型整理成记忆。",
},
{
label: "模型调用与费用",
content: plan.capabilities.generation_required
? plan.capabilities.evolve_available
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
: "会调用当前生成模型,可能产生服务商费用。"
: "不调用生成模型,不再产生这部分费用。",
},
{
label: "搜索范围",
content:
plan.capabilities.content_plane === "raw"
? "切换后搜索原始对话。"
: plan.capabilities.graph_enabled
? "搜索沉淀记忆,并用知识图谱补充关联。"
: "搜索沉淀后的记忆。",
},
];
if (plan.effects.includes("earlier_raw_not_extracted"))
rows.push({
label: "已有数据",
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
});
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content:
plan.effective_target === "pro"
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "知识图谱",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "每天自动整合",
content: "每天自动整合会停止,已经生成的数据不会删除。",
});
if (plan.capabilities.evolve_available)
rows.push({
label: "切换后",
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
});
return rows;
}
const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> = {
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
embedding_maintenance_in_progress:
"正在检查或切换向量模型,完成后才能切换记忆模式。",
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
};
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
export function MemoryModeSettings({
hasActiveModel,
@@ -143,65 +62,9 @@ export function MemoryModeSettings({
const [readFailed, setReadFailed] = useState(false);
const [run, setRun] = useState<EvolveRunReceipt | null>(null);
const refreshVersion = useRef(0);
const dialog = useRef<HTMLElement>(null);
const dialogOpener = useRef<HTMLElement | null>(null);
const focusEvolveWhenReady = useRef(false);
const focusModeWhenReady = useRef<MemoryPipelineMode | null>(null);
const busyRef = useRef(false);
busyRef.current = busy;
useEffect(() => {
if (!pendingMode || !dialog.current) return;
const panel = dialog.current;
const inertSiblings = new Map<HTMLElement, boolean>();
let branch: HTMLElement = panel.parentElement ?? panel;
while (branch.parentElement) {
for (const sibling of branch.parentElement.children) {
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
inertSiblings.set(sibling, sibling.inert);
sibling.inert = true;
}
branch = branch.parentElement;
if (branch === document.body) break;
}
const controls = () =>
Array.from(panel.querySelectorAll<HTMLElement>("button:not(:disabled)"));
controls()[0]?.focus();
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
setPendingPlan(null);
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);
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
if (dialogOpener.current?.isConnected) dialogOpener.current.focus();
};
}, [pendingMode]);
useEffect(() => {
if (pendingMode && busy) dialog.current?.focus();
}, [busy, pendingMode]);
useEffect(() => {
if (!focusEvolveWhenReady.current || pendingMode || state?.mode !== "ultra")
@@ -429,45 +292,13 @@ export function MemoryModeSettings({
aria-labelledby={`${id}-evolve-title`}
tabIndex={-1}
>
<div className="r6-evolve-heading">
<h3 id={`${id}-evolve-title`}>{text("记忆整合(Evolve)")}</h3>
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
</div>
<div className="r6-evolve-setting">
<div className="r6-evolve-setting-copy">
<h3>{text("每天自动整合")}</h3>
<p>
{text(
schedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", schedule.time)}
</p>
</div>
<R6Switch
label={text("每天自动整合")}
checked={schedule.enabled}
disabled={busy || maintenance}
onCheckedChange={(enabled) => void changeSchedule({ enabled })}
/>
{schedule.enabled ? (
<div className="r6-evolve-time">
<div>
<label htmlFor={`${id}-evolve-time`}>
{text("执行时间")}
</label>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<TimePicker
id={`${id}-evolve-time`}
label={text("执行时间")}
value={schedule.time}
disabled={busy || maintenance}
onChange={(time) => changeSchedule({ time })}
/>
</div>
) : null}
</div>
<UltraEvolveScheduleFields
schedule={schedule}
headingId={`${id}-evolve-title`}
disabled={busy || maintenance}
onEnabledChange={(enabled) => void changeSchedule({ enabled })}
onTimeChange={(time) => changeSchedule({ time })}
/>
<div className="r6-evolve-setting r6-evolve-setting--manual">
<div className="r6-evolve-setting-copy">
<h3>{text("立即整合一次")}</h3>
@@ -497,64 +328,19 @@ export function MemoryModeSettings({
</section>
) : null}
{pendingMode ? (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={dialog}
className="r6-settings-dialog r6-mode-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</h2>
<dl className="r6-mode-confirm-effects">
{(pendingPlan ? planRows(pendingPlan) : []).map((row) => (
<div key={`${row.label}:${row.content}`}>
<dt>{text(row.label)}</dt>
<dd>{text(row.content)}</dd>
</div>
))}
</dl>
{(pendingPlan?.blockers ?? []).map((blocker) => (
<p className="r6-settings-error" role="alert" key={blocker}>
{text(BLOCKER_COPY[blocker])}
</p>
))}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
<footer>
<R6Button
disabled={busy}
onClick={() => {
setPendingPlan(null);
setPendingMode(null);
}}
>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={Boolean(pendingPlan?.blockers.length)}
onClick={() => void commitMode()}
>
{text("切换到 {mode}").replace(
"{mode}",
text(MODE_COPY[pendingMode].title),
)}
</R6Button>
</footer>
</section>
</div>
{pendingMode && pendingPlan ? (
<MemoryModeConfirmDialog
mode={pendingMode}
plan={pendingPlan}
busy={busy}
error={error}
opener={dialogOpener.current}
onCancel={() => {
setPendingPlan(null);
setPendingMode(null);
}}
onConfirm={() => void commitMode()}
/>
) : null}
</section>
);
+15 -7
View File
@@ -1482,7 +1482,12 @@ describe("ModelSettings", () => {
...profile,
profile_id: "created",
revision: 1,
capabilities,
capabilities: {
...capabilities,
endpoint_reachable: false,
model_available: false,
embedding_available: false,
},
};
profiles = [];
create.mockImplementation(async () => {
@@ -1491,11 +1496,17 @@ describe("ModelSettings", () => {
});
probeProfile.mockResolvedValue({
profile_id: created.profile_id,
profile_revision: created.revision,
input_profile_revision: created.revision,
profile_revision: 2,
stages: [],
capabilities,
});
activate.mockResolvedValue({ ...created, is_active: true });
activate.mockResolvedValue({
...created,
revision: 2,
capabilities,
is_active: true,
});
render(<ModelSettings compactSingle onSetupComplete={onSetupComplete} />);
await user.type(screen.getByLabelText("配置名称"), "Current model");
@@ -1504,10 +1515,7 @@ describe("ModelSettings", () => {
await user.click(screen.getByRole("button", { name: "测试并完成配置" }));
await waitFor(() => expect(onSetupComplete).toHaveBeenCalledOnce());
expect(planActivation).toHaveBeenCalledWith(
created.profile_id,
created.revision,
);
expect(planActivation).toHaveBeenCalledWith(created.profile_id, 2);
expect(activate).toHaveBeenCalledWith(created.profile_id);
});
+15 -50
View File
@@ -27,6 +27,7 @@ import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
@@ -174,27 +175,6 @@ function validEndpoint(value: string): boolean {
}
}
function ready(
profile: ModelProfile,
probe: ModelProbeResult | null = null,
error: string | null = null,
): boolean {
return (
!error &&
(!probe ||
(probe.profile_id === profile.profile_id &&
probe.profile_revision === profile.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available)) &&
profile.credential_state !== "unavailable" &&
!(profile.has_configured_api_key && !profile.has_api_key) &&
profile.capabilities.endpoint_reachable &&
profile.capabilities.model_available &&
profile.capabilities.embedding_available
);
}
function endpointLabel(value: string | null): string {
if (!value) return "未提供接口地址";
try {
@@ -467,7 +447,11 @@ export function ModelSettings({
technicalTouched.current.clear();
};
const isReady = (profile: ModelProfile) =>
ready(profile, models.probeFor(profile), models.probeErrorFor(profile));
modelProfileReady(
profile,
models.probeFor(profile),
models.probeErrorFor(profile),
);
function editProfile(profile: ModelProfile) {
if (ownerLocked) return;
@@ -626,7 +610,7 @@ export function ModelSettings({
!active ||
active.profile_id !== profile.profile_id ||
!active.is_active ||
!ready(active, result)
!modelProfileReady(active, result)
) {
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
return false;
@@ -640,38 +624,15 @@ export function ModelSettings({
setSetupPhase("testing");
const result = await models.probeProfile(profile.profile_id);
if (!alive.current) return false;
if (
result &&
result.profile_id === profile.profile_id &&
result.profile_revision !== profile.revision
) {
// A newer revision used to be accepted here, and then activation turned
// on whatever the configuration had become. The user is told the
// settings they filled in passed, so this hands them a model they never
// tested. `ready` below cannot catch it either: by then the probe and
// the activated profile are both on the new revision and agree.
setSetupError(
result.profile_revision > profile.revision
? "这份配置在测试期间被改过,刚才测的是改动后的版本。配置已保存但未启用,请重新测试并启用。"
: "测试结果来自这份配置更早的版本。配置已保存但未启用,请重新测试并启用。",
);
return false;
}
if (
!result ||
result.profile_id !== profile.profile_id ||
result.profile_revision !== profile.revision ||
!result.capabilities.endpoint_reachable ||
!result.capabilities.model_available ||
!result.capabilities.embedding_available
) {
const testedProfile = profileFromProbe(profile, result);
if (!testedProfile || !modelProfileReady(testedProfile, result ?? null)) {
setSetupError(
"配置已保存,连接测试未通过。请核对配置后重试,历史对话尚未发送。",
);
setFields(probeFieldErrors(result));
return false;
}
return activateWithPlan(profile, result);
return activateWithPlan(testedProfile, result ?? null);
}
async function activateTested(profile: ModelProfile) {
@@ -1940,7 +1901,11 @@ function PresetProfileCard({
</button>
<button
type="button"
disabled={busy || !canEnable || !ready(profile, probe, probeError)}
disabled={
busy ||
!canEnable ||
!modelProfileReady(profile, probe, probeError)
}
onClick={onActivate}
>
{text("设为当前模型")}
@@ -10,13 +10,33 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
getModelProfiles: vi.fn().mockResolvedValue([]),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
planMemoryMode: vi.fn().mockResolvedValue({
current: "lite",
target: "pro",
effective_target: "pro",
capabilities: {
content_plane: "memory",
generation_required: true,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: ["active_model_required"],
}),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
@@ -136,6 +156,9 @@ it("moves from import to the actual third step without completing onboarding", a
);
await userEvent.click(screen.getByRole("button", { name: "进入第三步" }));
await userEvent.click(screen.getByRole("radio", { name: /pro/i }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
expect(screen.getByRole("heading", { name: "第三步模型页面" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
@@ -165,6 +188,9 @@ it("lets an empty first-time user enter without granting or importing a source",
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
@@ -188,6 +214,9 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
await waitFor(() => expect((skip as HTMLButtonElement).disabled).toBe(false));
await userEvent.click(skip);
await userEvent.click(screen.getByRole("radio", { name: /lite/i }));
await userEvent.click(
await screen.findByRole("button", { name: "确认选择" }),
);
await userEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await screen.findByRole("button", { name: "重试保存并进入" });
expect(finish).not.toHaveBeenCalled();
@@ -24,6 +24,12 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getModelProfiles: vi.fn(),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
@@ -31,7 +37,20 @@ vi.mock("./api", () => ({
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
planMemoryMode: vi.fn().mockResolvedValue({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
}),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
}));
@@ -422,6 +441,9 @@ it("translates completion recovery live without repeating completion writes", as
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
);
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(
await screen.findByRole("button", { name: "Confirmar selección" }),
);
fireEvent.click(screen.getByRole("button", { name: "Entrar con Lite" }));
expect(
await screen.findByRole("button", { name: "Reintentar guardar y entrar" }),
@@ -26,13 +26,32 @@ vi.mock("./api", () => ({
getImportJobs: vi.fn(),
connectSources: vi.fn(),
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
explicitly_configured: false,
}),
setMemoryMode: vi.fn().mockResolvedValue({ mode: "pro" }),
planMemoryMode: vi.fn().mockResolvedValue({ blockers: [] }),
planMemoryMode: vi.fn().mockResolvedValue({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
}),
putOnboardingProgress: vi.fn().mockResolvedValue({ model_profile_id: null }),
patchOnboardingProgress: vi.fn().mockResolvedValue({}),
getSourceConnection: vi.fn(),
@@ -159,8 +178,23 @@ beforeEach(() => {
} as never);
vi.mocked(api.setMemoryMode).mockResolvedValue({ mode: "pro" } as never);
vi.mocked(api.planMemoryMode).mockResolvedValue({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
} as never);
vi.mocked(api.getEvolveSchedule).mockResolvedValue({
enabled: true,
time: "03:00",
});
vi.mocked(api.putOnboardingProgress).mockResolvedValue(state);
vi.mocked(api.patchOnboardingProgress).mockResolvedValue(state);
localStorage.clear();
@@ -319,6 +353,7 @@ it("empty discovery shows zero and allows explicit entry without a connection re
);
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
@@ -371,6 +406,7 @@ it("partial acceptance preserves the successful job and failure through model-st
expect(api.completeOnboarding).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "进入应用" }));
fireEvent.click(screen.getByRole("radio", { name: /lite/i }));
fireEvent.click(await screen.findByRole("button", { name: "确认选择" }));
fireEvent.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
@@ -15,6 +15,12 @@ import type { ImportSourcesController } from "./use-import-sources";
vi.mock("./api", () => ({
completeOnboarding: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn(),
memoryModePlanUnsupported: vi.fn(() => false),
getMemoryMode: vi.fn().mockResolvedValue({
mode: "pro",
configured_mode: "pro",
+365 -35
View File
@@ -40,6 +40,7 @@ const getMemoryProcessing = vi.fn();
const previewSourceDirectory = vi.fn();
const saveSourceDirectory = vi.fn();
const getSourceImportReceipt = vi.fn();
let modelActive = false;
vi.mock("./api", () => ({
getHealth: vi.fn(),
@@ -52,6 +53,15 @@ vi.mock("./api", () => ({
setSourceSync: vi.fn(),
changeImportJob: vi.fn(),
getImportJob: vi.fn(),
getEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
setEvolveSchedule: vi.fn().mockResolvedValue({
enabled: true,
time: "03:00",
}),
memoryModePlanUnsupported: vi.fn(() => false),
apiErrorCode: vi.fn(() => null),
completeOnboarding: (...args: unknown[]) => completeOnboarding(...args),
setMemoryMode: (...args: unknown[]) => setMemoryMode(...args),
@@ -91,6 +101,8 @@ vi.mock("./OnboardingModelController", () => ({
<button
type="button"
onClick={() => {
modelActive = true;
getModelProfiles.mockResolvedValue([{ is_active: true }]);
onProfilePersisted("9be6d465-4cf2-4c72-93f7-15f3c012bc80");
onModelActiveConfirmed(
generationToken,
@@ -103,6 +115,8 @@ vi.mock("./OnboardingModelController", () => ({
<button
type="button"
onClick={() => {
modelActive = true;
getModelProfiles.mockResolvedValue([{ is_active: true }]);
onProfilePersisted("8be6d465-4cf2-4c72-93f7-15f3c012bc80");
onModelActiveConfirmed(
generationToken,
@@ -225,13 +239,20 @@ async function continueImport(user: ReturnType<typeof userEvent.setup>) {
}
async function openModelStep(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "选择记忆模式" }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
}
async function chooseLiteAndContinue(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
}
async function selectMode(
user: ReturnType<typeof userEvent.setup>,
name: RegExp,
) {
await user.click(screen.getByRole("radio", { name }));
await user.click(await screen.findByRole("button", { name: "确认选择" }));
}
function choice(name = "Claude Code") {
return screen.getByRole<HTMLInputElement>("checkbox", {
name: `导入 ${name} 历史`,
@@ -265,6 +286,7 @@ async function approve(user: ReturnType<typeof userEvent.setup>) {
describe("OnboardingFlow S1.3", () => {
beforeEach(() => {
vi.resetAllMocks();
modelActive = false;
resetConfirmedMemoryModeForTests();
window.localStorage.clear();
window.oneMemory = undefined;
@@ -273,15 +295,16 @@ describe("OnboardingFlow S1.3", () => {
status: "completed",
});
setMemoryMode.mockResolvedValue({ mode: "pro" });
planMemoryMode.mockResolvedValue({
planMemoryMode.mockImplementation(async (target) => ({
current: "lite",
target: "pro",
effective_target: "pro",
target,
effective_target: target,
capabilities: {},
effects: [],
warnings: [],
blockers: [],
});
blockers:
target !== "lite" && !modelActive ? ["active_model_required"] : [],
}));
putOnboardingProgress.mockImplementation(
async (update: Partial<OnboardingState>) => ({
...onboarding,
@@ -299,6 +322,7 @@ describe("OnboardingFlow S1.3", () => {
configured_mode: "pro",
explicitly_configured: false,
});
getModelProfiles.mockResolvedValue([]);
});
afterEach(() => {
cleanup();
@@ -491,7 +515,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(
await screen.findByRole("button", { name: "选择记忆模式" }),
);
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
const enter = screen.getByRole("button", { name: "使用 Lite 进入" });
expect(enter).toHaveProperty("disabled", false);
@@ -539,7 +563,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(
screen.getByRole("button", { name: "正在保存模式…" }),
@@ -583,7 +607,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const receipt = screen.getByRole("button", {
name: "模型启用回执已确认",
@@ -623,7 +647,7 @@ describe("OnboardingFlow S1.3", () => {
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(finish).not.toHaveBeenCalled();
expect(getModelProfiles).not.toHaveBeenCalled();
expect(getModelProfiles).toHaveBeenCalledOnce();
});
it("does not trust loaded metadata or select sources before automatic discovery resolves", async () => {
@@ -885,7 +909,7 @@ describe("OnboardingFlow S1.3", () => {
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
expect(onFinish).toHaveBeenCalledWith("sources");
expect(getModelProfiles).not.toHaveBeenCalled();
expect(getModelProfiles).toHaveBeenCalledOnce();
expect(getMemoryProcessing).not.toHaveBeenCalled();
expect(
screen.queryByRole("heading", { name: "配置一个模型,记忆才会生成" }),
@@ -905,7 +929,7 @@ describe("OnboardingFlow S1.3", () => {
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(onFinish).toHaveBeenCalledWith("sources");
expect(completeOnboarding).toHaveBeenCalledOnce();
@@ -928,25 +952,330 @@ describe("OnboardingFlow S1.3", () => {
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
expect(screen.getAllByRole("radio")).toHaveLength(2);
expect(screen.queryByRole("radio", { name: /ultra/i })).toBeNull();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
expect(screen.getByText("推荐").closest("label")?.textContent).toContain(
"Pro",
"pro",
);
expect(
screen.getByText("Ultra 可在完成首次设置后到设置页开启。"),
).toBeTruthy();
const modeStep = screen
.getByRole("navigation", { name: "首次设置进度" })
.querySelector('[aria-current="step"]');
expect(modeStep?.getAttribute("aria-current")).toBe("step");
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
expect(screen.getByRole("button", { name: "使用 Lite 进入" })).toBeTruthy();
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
expect(screen.getByRole("button", { name: "继续配置模型" })).toBeTruthy();
});
it("configures Ultra scheduling before model setup and completes after activation", async () => {
const user = userEvent.setup();
vi.mocked(api.getEvolveSchedule).mockResolvedValueOnce({
enabled: true,
time: "03:00",
});
vi.mocked(api.setEvolveSchedule).mockResolvedValueOnce({
enabled: true,
time: "03:00",
});
planMemoryMode.mockImplementation(async (target) => ({
current: "pro",
target,
effective_target: target,
capabilities: {
content_plane: "memory",
generation_required: target !== "lite",
graph_enabled: target === "ultra",
evolve_available: target === "ultra",
},
effects: target === "ultra" ? ["existing_memories_added_to_graph"] : [],
warnings: [],
blockers:
target !== "lite" && !modelActive ? ["active_model_required"] : [],
}));
setMemoryMode.mockResolvedValueOnce({ mode: "ultra" });
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /ultra/i }));
expect(
await screen.findByRole("heading", {
name: "选择深度记忆(ultra)?",
}),
).toBeTruthy();
expect(
screen.getByText("使用 pro 或 ultra 前需要先配置并启用生成模型。"),
).toBeTruthy();
await user.click(screen.getByRole("button", { name: "确认选择" }));
expect(
screen
.getByRole("switch", { name: "每天自动整合" })
.getAttribute("aria-checked"),
).toBe("true");
expect(
screen.getByRole("button", { name: "执行时间,03:00" }),
).toBeTruthy();
await user.click(screen.getByRole("button", { name: "继续" }));
expect(screen.getByRole("region", { name: "模型步骤边界" })).toBeTruthy();
expect(putOnboardingProgress).toHaveBeenCalledWith(
expect.objectContaining({
mode_intent: "ultra",
setup_step: "model",
}),
);
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("ultra"));
expect(api.setEvolveSchedule).toHaveBeenCalledWith({
enabled: true,
time: "03:00",
});
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
});
it("does not let a resumed Ultra model overwrite an unread schedule", async () => {
let resolveSchedule!: (value: { enabled: boolean; time: string }) => void;
vi.mocked(api.getEvolveSchedule).mockReturnValueOnce(
new Promise((resolve) => {
resolveSchedule = resolve;
}),
);
setMemoryMode.mockResolvedValueOnce({ mode: "ultra" });
vi.mocked(api.setEvolveSchedule).mockResolvedValueOnce({
enabled: false,
time: "05:30",
});
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={{
...onboarding,
mode_intent: "ultra",
setup_step: "model",
model_profile_id: "9be6d465-4cf2-4c72-93f7-15f3c012bc80",
}}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
);
expect(setMemoryMode).not.toHaveBeenCalled();
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(screen.getByRole("alert").textContent).toContain("正在读取记忆模式");
await act(async () =>
resolveSchedule({
enabled: false,
time: "05:30",
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
false,
),
);
expect(
screen
.getByRole("switch", { name: "每天自动整合" })
.getAttribute("aria-checked"),
).toBe("false");
await user.click(screen.getByRole("button", { name: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
expect(api.setEvolveSchedule).toHaveBeenCalledWith({
enabled: false,
time: "05:30",
});
});
it("keeps Ultra selected when schedule persistence fails and retries", async () => {
modelActive = true;
getModelProfiles.mockResolvedValue([{ is_active: true }]);
vi.mocked(api.getEvolveSchedule).mockResolvedValueOnce({
enabled: true,
time: "03:00",
});
vi.mocked(api.setEvolveSchedule)
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce({ enabled: true, time: "03:00" });
setMemoryMode.mockResolvedValue({ mode: "ultra" });
const user = userEvent.setup();
const finish = vi.fn();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={finish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /ultra/i);
await user.click(screen.getByRole("button", { name: "继续" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"自动整合设置未能保存",
);
expect(finish).not.toHaveBeenCalled();
expect(
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
).toBe(true);
await user.click(screen.getByRole("button", { name: "继续" }));
await waitFor(() =>
expect(finish).toHaveBeenCalledExactlyOnceWith("sources"),
);
expect(api.setEvolveSchedule).toHaveBeenCalledTimes(2);
});
it("retries a failed Ultra schedule read without losing the selection", async () => {
vi.mocked(api.getEvolveSchedule)
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce({ enabled: true, time: "04:15" });
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await selectMode(user, /ultra/i);
expect(screen.getByRole("alert").textContent).toContain(
"记忆模式状态暂时无法读取",
);
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
true,
);
await user.click(screen.getByRole("button", { name: "重试" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "执行时间,04:15" }),
).toBeTruthy(),
);
expect(
screen.getByRole<HTMLInputElement>("radio", { name: /ultra/i }).checked,
).toBe(true);
expect(screen.getByRole("button", { name: "继续" })).toHaveProperty(
"disabled",
false,
);
});
it("drops a late mode plan after leaving the mode step", async () => {
let resolvePlan!: (value: {
current: "pro";
target: "lite";
effective_target: "lite";
capabilities: {
content_plane: "raw";
generation_required: false;
graph_enabled: false;
evolve_available: false;
};
effects: [];
warnings: [];
blockers: [];
}) => void;
planMemoryMode.mockReturnValueOnce(
new Promise((resolve) => {
resolvePlan = resolve;
}),
);
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await act(async () =>
resolvePlan({
current: "pro",
target: "lite",
effective_target: "lite",
capabilities: {
content_plane: "raw",
generation_required: false,
graph_enabled: false,
evolve_available: false,
},
effects: [],
warnings: [],
blockers: [],
}),
);
expect(screen.queryByRole("dialog")).toBeNull();
expect(
screen.getByRole("heading", {
name: "对话正在汇入,记忆从这里开始",
}),
).toBeTruthy();
});
it("persists Lite mode before resumable progress and completion", async () => {
const order: string[] = [];
setMemoryMode.mockImplementationOnce(async () => {
order.push("mode");
return { mode: "lite" };
});
putOnboardingProgress.mockImplementationOnce(async (update) => {
order.push("progress");
return { ...onboarding, ...update };
});
completeOnboarding.mockImplementationOnce(async () => {
order.push("complete");
return { ...onboarding, status: "completed" };
});
const user = userEvent.setup();
render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
onFinish={vi.fn()}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
await waitFor(() =>
expect(order).toEqual(["mode", "progress", "complete"]),
);
});
it("does not let a late saved-mode read overwrite the user's new choice", async () => {
let resolveMode!: (value: {
mode: "pro";
@@ -970,7 +1299,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const lite = screen.getByRole<HTMLInputElement>("radio", { name: /lite/i });
await user.click(lite);
await selectMode(user, /lite/i);
await act(async () => {
resolveMode({
mode: "pro",
@@ -1004,7 +1333,7 @@ describe("OnboardingFlow S1.3", () => {
"disabled",
true,
);
await user.click(lite);
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect((await screen.findByRole("alert")).textContent).toContain(
@@ -1049,7 +1378,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
@@ -1057,7 +1386,7 @@ describe("OnboardingFlow S1.3", () => {
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledWith("pro"));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(setMemoryMode).toHaveBeenCalledTimes(1);
@@ -1103,7 +1432,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
@@ -1111,6 +1440,7 @@ describe("OnboardingFlow S1.3", () => {
await waitFor(() => expect(setMemoryMode).toHaveBeenCalledTimes(1));
await user.click(screen.getByRole("button", { name: "返回导入" }));
modelActive = false;
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
@@ -1156,7 +1486,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
@@ -1198,7 +1528,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const revisionBeforePut = confirmedMemoryModeSnapshot().revision;
await user.click(
@@ -1247,7 +1577,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(
screen.getByRole("button", { name: "模型启用回执已确认" }),
@@ -1287,7 +1617,7 @@ describe("OnboardingFlow S1.3", () => {
const firstTitle = screen.getByRole("heading", { name: "选择记忆模式" });
await waitFor(() => expect(document.activeElement).toBe(firstTitle));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
const returnedTitle = screen.getByRole("heading", {
@@ -1317,7 +1647,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
await waitFor(() => expect(finish).toHaveBeenCalledWith("sources"));
expect(screen.queryByRole("alert")).toBeNull();
@@ -1336,7 +1666,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await user.click(screen.getByRole("radio", { name: /pro/i }));
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
const lateReceipt = screen.getByRole("button", {
name: "模型启用回执已确认",
@@ -1385,7 +1715,7 @@ describe("OnboardingFlow S1.3", () => {
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
const pro = screen.getByRole<HTMLInputElement>("radio", { name: /pro/i });
await user.click(pro);
await selectMode(user, /pro/i);
await user.click(screen.getByRole("button", { name: "继续配置模型" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"模式未能保存",
@@ -1418,11 +1748,11 @@ describe("OnboardingFlow S1.3", () => {
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(screen.getByRole("radio", { name: /lite/i }));
await selectMode(user, /lite/i);
await user.click(screen.getByRole("button", { name: "使用 Lite 进入" }));
expect(completeOnboarding).toHaveBeenCalledOnce();
expect(onFinish).toHaveBeenCalledWith("sources");
expect(getModelProfiles).not.toHaveBeenCalled();
expect(getModelProfiles).toHaveBeenCalledOnce();
expect(getMemoryProcessing).not.toHaveBeenCalled();
});
+386 -126
View File
@@ -1,9 +1,14 @@
import type {
ConnectSourcesResponse,
EvolveSchedule,
ImportJob,
MemoryModePlan,
MemoryPipelineMode,
OnboardingModeIntent,
OnboardingState,
SourceResponse,
} from "@one-memory/contracts";
import { legacyMemoryModePlan } from "@one-memory/operations";
import { Check, X } from "lucide-react";
import {
type CSSProperties,
@@ -18,10 +23,14 @@ import {
import {
apiErrorCode,
completeOnboarding,
getEvolveSchedule,
getMemoryMode,
getModelProfiles,
memoryModePlanUnsupported,
patchOnboardingProgress,
planMemoryMode,
putOnboardingProgress,
setEvolveSchedule,
setMemoryMode,
} from "./api";
import { AppMark } from "./app-mark";
@@ -41,17 +50,23 @@ import {
hostWriteReason,
} from "./host-state";
import { MemoryModeChoiceList } from "./MemoryModeChoiceList";
import {
BLOCKER_COPY,
MemoryModeConfirmDialog,
MODE_COPY,
} from "./memory-mode-ui";
import { OnboardingModelController } from "./OnboardingModelController";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6ColdStartFrame } from "./R6ColdStartFrame";
import { R6Button } from "./R6Controls";
import { UltraEvolveScheduleFields } from "./UltraEvolveScheduleFields";
import { traceUiAction } from "./ui-trace";
import type { ImportSourcesController } from "./use-import-sources";
import { isDefiniteRefusal } from "./write-outcome";
import "./cold-start.css";
export type OnboardingDestination = "memory" | "sources" | "settings";
type OnboardingMemoryMode = "lite" | "pro";
type OnboardingMemoryMode = OnboardingModeIntent;
// Cold start neither reads nor writes installation intent.
export const PLUGIN_CONSENT_KEY = "one-memory.consent.plugins";
@@ -124,7 +139,9 @@ export function OnboardingFlow({
null,
);
const resumedStep =
state.mode_intent === "pro" && state.setup_step === "model"
state.mode_intent &&
state.mode_intent !== "lite" &&
state.setup_step === "model"
? "model"
: state.mode_intent
? "mode"
@@ -142,6 +159,21 @@ export function OnboardingFlow({
);
const modeSaving = modePhase !== "idle";
const [modeError, setModeError] = useState<string | null>(null);
const [knownMode, setKnownMode] = useState<MemoryPipelineMode>("pro");
const [pendingMode, setPendingMode] = useState<OnboardingMemoryMode | null>(
null,
);
const [pendingPlan, setPendingPlan] = useState<MemoryModePlan | null>(null);
const [modePlanBusy, setModePlanBusy] = useState(false);
const modeDialogOpener = useRef<HTMLElement | null>(null);
const modePlanOperation = useRef(0);
const [ultraSchedule, setUltraSchedule] = useState<EvolveSchedule>({
enabled: true,
time: "03:00",
});
const [scheduleLoaded, setScheduleLoaded] = useState(false);
const [scheduleReadFailed, setScheduleReadFailed] = useState(false);
const scheduleReadOperation = useRef(0);
const [modeFocusToken, setModeFocusToken] = useState(
resumedStep === "mode" ? 1 : 0,
);
@@ -383,13 +415,14 @@ export function OnboardingFlow({
const modeTicket = issueConfirmedModeTicket("onboarding");
void getMemoryMode().then(
(memoryMode) => {
if (alive.current)
if (alive.current) {
commitConfirmedMemoryMode(memoryMode.mode, modeTicket);
setKnownMode(memoryMode.mode);
}
if (
alive.current &&
!modeChoiceTouched.current &&
memoryMode.explicitly_configured &&
memoryMode.configured_mode !== "ultra"
memoryMode.explicitly_configured
)
setModeChoice(memoryMode.configured_mode);
},
@@ -402,6 +435,32 @@ export function OnboardingFlow({
};
}, []);
const refreshUltraSchedule = useCallback(async () => {
const operation = ++scheduleReadOperation.current;
setScheduleLoaded(false);
setScheduleReadFailed(false);
try {
const schedule = await getEvolveSchedule();
if (!alive.current || scheduleReadOperation.current !== operation) return;
if (!schedule) return;
setUltraSchedule(schedule);
setScheduleLoaded(true);
setScheduleReadFailed(false);
setModeError((current) =>
current === "记忆模式状态暂时无法读取,请重试。" ? null : current,
);
} catch {
if (alive.current && scheduleReadOperation.current === operation) {
setScheduleLoaded(false);
setScheduleReadFailed(true);
}
}
}, []);
useEffect(() => {
void refreshUltraSchedule();
}, [refreshUltraSchedule]);
useEffect(() => {
setSources(imports.sources);
}, [imports.sources]);
@@ -491,14 +550,17 @@ export function OnboardingFlow({
[onFinish],
);
const enterModelStep = useCallback(() => {
intentRef.current = "pro";
setupStepRef.current = "model";
const token = ++modelGeneration.current;
setModelGenerationToken(token);
setVisitedModel(true);
setSetupStep("model");
}, []);
const enterModelStep = useCallback(
(mode: Exclude<OnboardingMemoryMode, "lite"> = "pro") => {
intentRef.current = mode;
setupStepRef.current = "model";
const token = ++modelGeneration.current;
setModelGenerationToken(token);
setVisitedModel(true);
setSetupStep("model");
},
[],
);
const leaveModelStep = useCallback(
(
@@ -642,7 +704,12 @@ export function OnboardingFlow({
const requestModeChoice = useCallback(
(destination: OnboardingDestination) => {
if (finished.current) return;
if (setupStepRef.current === "model") leaveModelStep("pro", "mode");
modePlanOperation.current += 1;
setModePlanBusy(false);
setPendingMode(null);
setPendingPlan(null);
if (setupStepRef.current === "model")
leaveModelStep(intentRef.current === "ultra" ? "ultra" : "pro", "mode");
else {
intentRef.current = modeChoice;
setupStepRef.current = "mode";
@@ -657,6 +724,47 @@ export function OnboardingFlow({
[leaveModelStep, modeChoice],
);
const requestModeSelection = useCallback(
async (mode: OnboardingMemoryMode, opener: HTMLElement) => {
if (modePlanBusy || modeSaving || completionBusy) return;
const operation = ++modePlanOperation.current;
setModePlanBusy(true);
setModeError(null);
try {
let plan: MemoryModePlan;
try {
plan = await planMemoryMode(mode);
} catch (reason) {
if (!memoryModePlanUnsupported(reason)) throw reason;
const profiles = await getModelProfiles();
plan = legacyMemoryModePlan(knownMode, mode, {
hasActiveModel: profiles.some((profile) => profile.is_active),
});
}
if (
!alive.current ||
modePlanOperation.current !== operation ||
setupStepRef.current !== "mode"
)
return;
modeDialogOpener.current = opener;
setPendingMode(mode);
setPendingPlan(plan);
} catch {
if (
alive.current &&
modePlanOperation.current === operation &&
setupStepRef.current === "mode"
)
setModeError("模式切换影响暂时无法读取,请重试。");
} finally {
if (alive.current && modePlanOperation.current === operation)
setModePlanBusy(false);
}
},
[completionBusy, knownMode, modePlanBusy, modeSaving],
);
const saveModeChoice = useCallback(async () => {
if (!modeChoice || modeSaving || finished.current || completionBusy) return;
const operation = ++modeOperation.current;
@@ -666,17 +774,73 @@ export function OnboardingFlow({
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
if (modeChoice === "pro") {
intentRef.current = "pro";
if (modeChoice !== "lite") {
const target = modeChoice;
const [plan, profiles] = await Promise.all([
planMemoryMode(target),
getModelProfiles(),
]);
if (!ownsModeOperation()) return;
const hasActiveModel = profiles.some((profile) => profile.is_active);
if (target === "ultra" && !scheduleLoaded) {
setModeError(
scheduleReadFailed
? "记忆模式状态暂时无法读取,请重试。"
: "正在读取记忆模式…",
);
return;
}
const blocking = plan.blockers.filter(
(blocker) => blocker !== "active_model_required",
);
if (blocking.length > 0) {
setModeError(
blocking.map((blocker) => BLOCKER_COPY[blocker]).join(" "),
);
return;
}
intentRef.current = target;
setupStepRef.current = "mode";
const selectionStillCurrent = () =>
ownsModeOperation() &&
intentRef.current === "pro" &&
intentRef.current === target &&
setupStepRef.current === "mode";
if (
hasActiveModel &&
!plan.blockers.includes("active_model_required")
) {
const changed = await queueModeWrite(target, selectionStillCurrent);
if (!selectionStillCurrent()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
setKnownMode(changed.mode);
if (target === "ultra") {
try {
await setEvolveSchedule(ultraSchedule);
} catch {
if (selectionStillCurrent())
setModeError("自动整合设置未能保存,请重试。");
return;
}
if (!selectionStillCurrent()) return;
}
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: target,
setup_step: "mode",
model_profile_id: null,
}),
selectionStillCurrent,
);
if (!selectionStillCurrent()) return;
setModePhase("completing");
await leave(modeDestination);
return;
}
const savedProgress = await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
mode_intent: target,
setup_step: "model",
model_profile_id: modelProfileId.current,
}),
@@ -684,7 +848,7 @@ export function OnboardingFlow({
);
if (!selectionStillCurrent()) return;
modelProfileId.current = savedProgress.model_profile_id;
enterModelStep();
enterModelStep(target);
return;
}
leaveModelStep("lite", "mode", false);
@@ -692,6 +856,10 @@ export function OnboardingFlow({
ownsModeOperation() &&
intentRef.current === "lite" &&
setupStepRef.current === "mode";
const changed = await queueModeWrite("lite", liteStillCurrent);
if (!liteStillCurrent()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
setKnownMode(changed.mode);
await queueProgressWrite(
() =>
putOnboardingProgress({
@@ -702,9 +870,6 @@ export function OnboardingFlow({
liteStillCurrent,
);
if (!liteStillCurrent()) return;
const changed = await queueModeWrite("lite", liteStillCurrent);
if (!liteStillCurrent()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
setModePhase("completing");
await leave(modeDestination);
} catch (reason) {
@@ -727,6 +892,9 @@ export function OnboardingFlow({
finishOnce,
queueModeWrite,
queueProgressWrite,
scheduleLoaded,
scheduleReadFailed,
ultraSchedule,
]);
const persistModelProfile = useCallback(
@@ -744,14 +912,25 @@ export function OnboardingFlow({
[queueProgressWrite],
);
const confirmProMode = useCallback(
const confirmModelMode = useCallback(
async (token: number, ownerProfileId: string) => {
const confirmationOwner = `${token}:${ownerProfileId}`;
const target = intentRef.current;
if (target !== "pro" && target !== "ultra") return;
if (target === "ultra" && !scheduleLoaded) {
leaveModelStep("ultra", "mode");
setModeError(
scheduleReadFailed
? "记忆模式状态暂时无法读取,请重试。"
: "正在读取记忆模式…",
);
return;
}
const confirmationOwner = `${token}:${ownerProfileId}:${target}`;
const ownsModelStep = () =>
alive.current &&
!finished.current &&
modelGeneration.current === token &&
intentRef.current === "pro" &&
intentRef.current === target &&
setupStepRef.current === "model" &&
modelProfileId.current === ownerProfileId;
if (
@@ -768,16 +947,29 @@ export function OnboardingFlow({
setModeError(null);
const modeTicket = issueConfirmedModeTicket("onboarding");
try {
const plan = await planMemoryMode("pro");
const plan = await planMemoryMode(target);
if (!ownsConfirmation()) return;
if (plan.blockers.length > 0) throw new Error(plan.blockers.join(","));
const changed = await queueModeWrite("pro", ownsConfirmation);
const changed = await queueModeWrite(target, ownsConfirmation);
if (!ownsConfirmation()) return;
commitConfirmedMemoryMode(changed.mode, modeTicket);
setKnownMode(changed.mode);
if (target === "ultra") {
try {
await setEvolveSchedule(ultraSchedule);
} catch {
if (ownsConfirmation()) {
leaveModelStep("ultra", "mode");
setModeError("自动整合设置未能保存,请重试。");
}
return;
}
if (!ownsConfirmation()) return;
}
await queueProgressWrite(
() =>
putOnboardingProgress({
mode_intent: "pro",
mode_intent: target,
setup_step: "model",
model_profile_id: ownerProfileId,
}),
@@ -790,7 +982,7 @@ export function OnboardingFlow({
if (isCompletedProgressConflict(reason)) {
finishOnce(modeDestination);
} else if (ownsConfirmation() && !isGenerationInvalid(reason))
setModeError("Pro 模式未能确认保存。模型配置已保留,请重试。");
setModeError("模式切换未能确认,仍显示上次确认的模式。");
} finally {
if (proConfirmationOwner.current === confirmationOwner)
proConfirmationOwner.current = null;
@@ -801,9 +993,13 @@ export function OnboardingFlow({
completionBusy,
finishOnce,
leave,
leaveModelStep,
modeDestination,
queueModeWrite,
queueProgressWrite,
scheduleLoaded,
scheduleReadFailed,
ultraSchedule,
],
);
@@ -1051,104 +1247,166 @@ export function OnboardingFlow({
/>
</div>
{setupStep === "mode" && (
<R6ColdStartFrame
step={3}
title={text("选择记忆模式")}
eyebrow={text("最后一步 · 决定如何处理对话")}
focusToken={modeFocusToken}
footer={
<>
<R6Button
variant="quiet"
disabled={modeSaving || completionBusy}
onClick={() => {
setModeError(null);
intentRef.current = modeChoice;
setupStepRef.current = "review";
setSetupStep("review");
void queueProgressWrite(() =>
patchOnboardingProgress({
mode_intent: modeChoice,
setup_step: "review",
}),
).catch((reason) => {
if (!isCompletedProgressConflict(reason) && alive.current)
setModeError(
"恢复进度未能保存。你的选择已保留,请重试。",
);
});
}}
>
{text("返回导入")}
</R6Button>
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving}
disabled={!modeChoice || completionBusy}
onClick={() => void saveModeChoice()}
>
{text(
modePhase === "saving"
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
)}
</R6Button>
</>
}
>
<section
className="onboarding-r6-mode"
aria-labelledby="onboarding-memory-mode-title"
<>
<R6ColdStartFrame
step={3}
title={text("选择记忆模式")}
eyebrow={text("最后一步 · 决定如何处理对话")}
focusToken={modeFocusToken}
footer={
<>
<R6Button
variant="quiet"
disabled={modeSaving || completionBusy}
onClick={() => {
modePlanOperation.current += 1;
setModePlanBusy(false);
setPendingMode(null);
setPendingPlan(null);
setModeError(null);
intentRef.current = modeChoice;
setupStepRef.current = "review";
setSetupStep("review");
void queueProgressWrite(() =>
patchOnboardingProgress({
mode_intent: modeChoice,
setup_step: "review",
}),
).catch((reason) => {
if (
!isCompletedProgressConflict(reason) &&
alive.current
)
setModeError(
"恢复进度未能保存。你的选择已保留,请重试。",
);
});
}}
>
{text("返回导入")}
</R6Button>
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving || modePlanBusy}
disabled={
!modeChoice ||
completionBusy ||
modePlanBusy ||
(modeChoice === "ultra" && !scheduleLoaded)
}
onClick={() => void saveModeChoice()}
>
{text(
modePhase === "saving"
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: modeChoice === "ultra"
? "继续"
: "继续",
)}
</R6Button>
</>
}
>
<div className="onboarding-r6-mode-intro">
<p id="onboarding-memory-mode-title">
{text("之后可在设置中更改。")}
</p>
</div>
<MemoryModeChoiceList
aria-label={text("记忆模式")}
modes={[
{
mode: "lite",
title: "Lite",
description: text(
"无需模型,只保存并检索原始对话,不生成提炼记忆。",
),
},
{
mode: "pro",
title: "Pro",
description: text(
"使用模型理解对话,提取并整理可持续生长的记忆。",
),
},
]}
selected={modeChoice}
disabled={modeSaving || completionBusy}
recommended={{ mode: "pro", badge: text("推荐") }}
onChange={(mode) => {
<section
className="onboarding-r6-mode"
aria-labelledby="onboarding-memory-mode-title"
>
<div className="onboarding-r6-mode-intro">
<p id="onboarding-memory-mode-title">
{text("之后可在设置中更改。")}
</p>
</div>
<MemoryModeChoiceList
aria-label={text("记忆模式")}
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
mode,
title: text(MODE_COPY[mode].title),
description: text(MODE_COPY[mode].description),
}))}
selected={modeChoice}
disabled={modeSaving || completionBusy || modePlanBusy}
recommended={{ mode: "pro", badge: text("推荐") }}
onChange={(mode, control) =>
void requestModeSelection(
mode as OnboardingMemoryMode,
control,
)
}
/>
{modeChoice === "ultra" && scheduleLoaded ? (
<section
className="r6-evolve-settings onboarding-r6-evolve-settings"
aria-label={text("记忆整合(Evolve)")}
>
<UltraEvolveScheduleFields
schedule={ultraSchedule}
disabled={modeSaving || !scheduleLoaded}
onEnabledChange={(enabled) =>
setUltraSchedule((current) => ({
...current,
enabled,
}))
}
onTimeChange={async (time) => {
setUltraSchedule((current) => ({ ...current, time }));
return true;
}}
/>
</section>
) : null}
{modeChoice === "ultra" && scheduleReadFailed ? (
<div className="onboarding-r6-schedule-recovery">
<p role="alert">
{text("记忆模式状态暂时无法读取,请重试。")}
</p>
<R6Button
className="onboarding-r6-schedule-retry"
onClick={() => void refreshUltraSchedule()}
>
{text("重试")}
</R6Button>
</div>
) : null}
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
</p>
)}
</section>
</R6ColdStartFrame>
{pendingMode && pendingPlan ? (
<MemoryModeConfirmDialog
mode={pendingMode}
plan={pendingPlan}
busy={modePlanBusy}
opener={modeDialogOpener.current}
allowedBlockers={["active_model_required"]}
onCancel={() => {
modePlanOperation.current += 1;
setModePlanBusy(false);
setPendingMode(null);
setPendingPlan(null);
}}
onConfirm={() => {
modePlanOperation.current += 1;
setModePlanBusy(false);
modeChoiceTouched.current = true;
setModeChoice(mode as OnboardingMemoryMode);
intentRef.current = pendingMode;
setModeChoice(pendingMode);
setModeError(null);
setPendingMode(null);
setPendingPlan(null);
}}
/>
<p className="onboarding-r6-mode-ultra-note">
{text("Ultra 可在完成首次设置后到设置页开启。")}
</p>
{(modeError || error) && (
<p className="onboarding-r6-mode-error" role="alert">
{text(modeError ?? error ?? "")}
</p>
)}
</section>
</R6ColdStartFrame>
) : null}
</>
)}
{visitedModel && (
<div hidden={setupStep !== "model"}>
@@ -1156,7 +1414,8 @@ export function OnboardingFlow({
generationToken={modelGenerationToken}
isGenerationCurrent={(token) =>
modelGeneration.current === token &&
intentRef.current === "pro" &&
intentRef.current !== "lite" &&
intentRef.current !== null &&
setupStepRef.current === "model"
}
initialProfileId={modelProfileId.current}
@@ -1169,7 +1428,7 @@ export function OnboardingFlow({
void persistModelProfile(profileId)
}
onModelActiveConfirmed={(token, ownerProfileId) =>
void confirmProMode(token, ownerProfileId)
void confirmModelMode(token, ownerProfileId)
}
sourceNames={sourceIds.map(
(id) =>
@@ -1184,10 +1443,11 @@ export function OnboardingFlow({
)}
onBack={() => {
setModeError(null);
leaveModelStep("pro", "mode");
const target = intentRef.current === "ultra" ? "ultra" : "pro";
leaveModelStep(target, "mode");
void queueProgressWrite(() =>
putOnboardingProgress({
mode_intent: "pro",
mode_intent: target,
setup_step: "mode",
model_profile_id: modelProfileId.current,
}),
@@ -23,7 +23,17 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
max_concurrency: 4,
context_window: null,
has_api_key: true,
credential_state: "available" as
| "available"
| "missing"
| "unavailable",
is_active: true,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
structured_output: false,
},
},
],
pendingProbe: null as null | {
@@ -57,7 +67,7 @@ const { embedding, getModelProfiles, models, step } = vi.hoisted(() => ({
supported: false as boolean | null,
planActivation: vi.fn(),
startPlannedSwitch: vi.fn(),
shouldUseLegacyActivation: () => true,
shouldUseLegacyActivation: (() => true) as () => boolean,
clearPlan: vi.fn(),
clearError: vi.fn(),
},
@@ -123,6 +133,7 @@ beforeEach(() => {
models.creationMissing = null;
embedding.current = null;
embedding.supported = false;
embedding.shouldUseLegacyActivation = () => true;
step.current = null;
});
@@ -164,6 +175,32 @@ it("resumes and confirms the active profile explicitly owned by onboarding", asy
expect(enabled).toHaveBeenCalledWith(1, "active-profile");
});
it("does not complete with an active profile whose credential is unavailable", async () => {
models.profiles = [
{
...activeProfile,
credential_state: "unavailable",
},
];
const enabled = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId="active-profile"
onBack={vi.fn()}
onModelActiveConfirmed={enabled}
/>,
);
await waitFor(() => expect(step.current).not.toBeNull());
expect(enabled).not.toHaveBeenCalled();
await userEvent.click(
screen.getByRole("button", { name: "继续使用当前模型" }),
);
expect(enabled).not.toHaveBeenCalled();
expect(screen.getByText(/模型凭据或连接状态已变化/)).toBeTruthy();
});
it("hydrates a persisted inactive profile without creating a duplicate", async () => {
models.profiles = [{ ...activeProfile, is_active: false }];
const active = vi.fn();
@@ -206,7 +243,7 @@ it("recovers a pending creation and persists its profile id", async () => {
});
it("reconciles a pending probe instead of saving or creating again", async () => {
const inactive = { ...activeProfile, is_active: false };
const inactive = { ...activeProfile, revision: 4, is_active: false };
models.profiles = [inactive];
models.pendingProbe = {
operationId: "probe-operation",
@@ -215,7 +252,8 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
};
models.probeProfile.mockResolvedValueOnce({
profile_id: "active-profile",
profile_revision: 3,
input_profile_revision: 3,
profile_revision: 4,
capabilities: {
endpoint_reachable: true,
model_available: true,
@@ -238,10 +276,76 @@ it("reconciles a pending probe instead of saving or creating again", async () =>
await waitFor(() =>
expect(models.probeProfile).toHaveBeenCalledWith("active-profile"),
);
await waitFor(() =>
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
);
expect(currentStep().canSaveAndEnable).toBe(true);
expect(models.update).not.toHaveBeenCalled();
expect(models.create).not.toHaveBeenCalled();
});
it("passes the first test when Core advances the tested profile revision", async () => {
const inactive = { ...activeProfile, revision: 1, is_active: false };
models.profiles = [inactive];
models.update.mockResolvedValueOnce(inactive);
models.probeProfile.mockResolvedValueOnce({
profile_id: inactive.profile_id,
input_profile_revision: 1,
profile_revision: 2,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
stages: [{ ok: false }],
});
models.activate.mockResolvedValueOnce({
...inactive,
revision: 2,
is_active: true,
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
});
embedding.supported = true;
embedding.current = { writes_paused: false };
embedding.shouldUseLegacyActivation = () => false;
embedding.planActivation.mockResolvedValueOnce({ needed: false });
const confirmed = vi.fn();
render(
<OnboardingModelController
sourceNames={[]}
initialProfileId={inactive.profile_id}
onBack={vi.fn()}
onModelActiveConfirmed={confirmed}
/>,
);
await waitFor(() =>
expect(
(currentStep().profile as { profile_id: string } | null)?.profile_id,
).toBe(inactive.profile_id),
);
await act(async () => {
await (currentStep().onTest as () => Promise<void>)();
});
await waitFor(() =>
expect(screen.getByText("模型连接测试通过,可以启用。")).toBeTruthy(),
);
expect(currentStep().canSaveAndEnable).toBe(true);
expect(currentStep().profile).toMatchObject({ revision: 2 });
expect(getModelProfiles).not.toHaveBeenCalled();
await act(async () => {
await (currentStep().onSaveAndEnable as () => Promise<void>)();
});
expect(embedding.planActivation).toHaveBeenCalledWith(inactive.profile_id, 2);
expect(models.activate).toHaveBeenCalledWith(inactive.profile_id);
expect(confirmed).toHaveBeenCalledWith(1, inactive.profile_id);
});
it("keeps an active resumed profile on the model step while writes are paused", async () => {
embedding.supported = true;
embedding.current = { writes_paused: true };
@@ -1,16 +1,15 @@
import type {
ModelProbeResult,
ModelProfile,
ModelProfileInput,
ModelProfilePatch,
} from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { getModelProfiles } from "./api";
import { useCallback, useEffect, useRef, useState } from "react";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
presetForGeneration,
validateGenerationModel,
} from "./GenerationModelPrimitives";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -41,23 +40,6 @@ const initial: OnboardingModelDraft = {
context_window: "",
};
export function probeMatchesProfile(
original: ModelProfile,
current: ModelProfile | undefined,
probe: ModelProbeResult | undefined | null,
): current is ModelProfile {
return Boolean(
current &&
probe &&
probe.profile_id === original.profile_id &&
probe.profile_revision === current.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available &&
probe.stages.every((stage) => stage.ok),
);
}
export function OnboardingModelController({
sourceNames,
importedConversationCount = 0,
@@ -112,6 +94,17 @@ export function OnboardingModelController({
>(),
);
const autoConfirmed = useRef<string | null>(null);
const confirmReadyProfile = useCallback(
(profile: ModelProfile, owner = generationToken) => {
if (!isGenerationCurrent(owner) || !modelProfileReady(profile)) {
setError("模型凭据或连接状态已变化,请重新测试后再完成首次设置。");
return false;
}
onModelActiveConfirmed?.(owner, profile.profile_id);
return true;
},
[generationToken, isGenerationCurrent, onModelActiveConfirmed],
);
useEffect(() => {
const profile = initialProfileId
@@ -152,21 +145,19 @@ export function OnboardingModelController({
useEffect(() => {
if (
!saved?.is_active ||
!isGenerationCurrent(generationToken) ||
(embeddingSwitch.supported !== false && !embeddingSwitch.current) ||
embeddingSwitch.current?.writes_paused
)
return;
const key = `${generationToken}:${saved.profile_id}`;
if (autoConfirmed.current === key) return;
autoConfirmed.current = key;
onModelActiveConfirmed?.(generationToken, saved.profile_id);
if (confirmReadyProfile(saved, generationToken))
autoConfirmed.current = key;
}, [
embeddingSwitch.current,
embeddingSwitch.supported,
confirmReadyProfile,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
]);
@@ -182,8 +173,7 @@ export function OnboardingModelController({
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
if (saved && isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
if (saved) confirmReadyProfile(saved, generationToken);
} else if (finished.state === "rolled_back") {
flight.current = false;
setSwitchPendingJobId(null);
@@ -192,9 +182,8 @@ export function OnboardingModelController({
}
}, [
embeddingSwitch.current?.last_finished,
confirmReadyProfile,
generationToken,
isGenerationCurrent,
onModelActiveConfirmed,
saved,
switchPendingJobId,
]);
@@ -338,11 +327,10 @@ export function OnboardingModelController({
setPhase("testing");
const result = await models.probeProfile(profile.profile_id);
if (!isGenerationCurrent(owner)) return;
const current = (await getModelProfiles()).find(
(item) => item.profile_id === profile.profile_id,
const current = profileFromProbe(profile, result);
const passed = Boolean(
current && modelProfileReady(current, result ?? null),
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(profile, current, result);
if (current) setSaved(current);
setProbe({
state: passed ? "passed" : "failed",
@@ -378,11 +366,10 @@ export function OnboardingModelController({
try {
const result = await models.probeProfile(saved.profile_id);
if (!isGenerationCurrent(owner)) return;
const current = (await getModelProfiles()).find(
(item) => item.profile_id === saved.profile_id,
const current = profileFromProbe(saved, result);
const passed = Boolean(
current && modelProfileReady(current, result ?? null),
);
if (!isGenerationCurrent(owner)) return;
const passed = probeMatchesProfile(saved, current, result);
if (current) setSaved(current);
setProbe({
state: passed ? "passed" : "failed",
@@ -408,8 +395,7 @@ export function OnboardingModelController({
setError("模型写入暂时关闭。请等待索引维护完成后重试。");
return;
}
if (isGenerationCurrent(generationToken))
onModelActiveConfirmed?.(generationToken, saved.profile_id);
confirmReadyProfile(saved, generationToken);
return;
}
const owner = generationToken;
@@ -433,9 +419,15 @@ export function OnboardingModelController({
}
const active = await models.activate(saved.profile_id);
if (!isGenerationCurrent(owner)) return;
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
if (
!active?.is_active ||
active.profile_id !== saved.profile_id ||
active.revision !== saved.revision ||
!modelProfileReady(active)
)
throw new Error("model_activation_unconfirmed");
trace.done("succeeded");
onModelActiveConfirmed?.(owner, active.profile_id);
confirmReadyProfile(active, owner);
} catch {
trace.done("unknown", { reason: "model_enable_unconfirmed" });
setError("模型未能确认启用,请在设置中核对。");
@@ -0,0 +1,63 @@
import type { EvolveSchedule } from "@one-memory/contracts";
import { useId } from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Switch } from "./R6Controls";
import { TimePicker } from "./TimePicker";
export function UltraEvolveScheduleFields({
schedule,
headingId,
disabled = false,
onEnabledChange,
onTimeChange,
}: {
schedule: EvolveSchedule;
headingId?: string;
disabled?: boolean;
onEnabledChange(enabled: boolean): void;
onTimeChange(time: string): Promise<boolean>;
}) {
const { text } = useOnboardingTranslation();
const id = useId();
return (
<>
<div className="r6-evolve-heading">
<h3 id={headingId}>{text("记忆整合(Evolve)")}</h3>
<p>{text("定期重新整理已有记忆,补充更稳定的关联和结论。")}</p>
</div>
<div className="r6-evolve-setting">
<div className="r6-evolve-setting-copy">
<h3>{text("每天自动整合")}</h3>
<p>
{text(
schedule.enabled
? "每天 {time} 左右自动整合,使用这台设备的本地时间;休眠错过后会补跑。"
: "已关闭。重新开启后仍按 {time} 运行;已生成的结果会保留。",
).replace("{time}", schedule.time)}
</p>
</div>
<R6Switch
label={text("每天自动整合")}
checked={schedule.enabled}
disabled={disabled}
onCheckedChange={onEnabledChange}
/>
{schedule.enabled ? (
<div className="r6-evolve-time">
<div>
<label htmlFor={`${id}-time`}>{text("执行时间")}</label>
<p>{text("使用这台设备的本地时间。")}</p>
</div>
<TimePicker
id={`${id}-time`}
label={text("执行时间")}
value={schedule.time}
disabled={disabled}
onChange={onTimeChange}
/>
</div>
) : null}
</div>
</>
);
}
+232
View File
@@ -0,0 +1,232 @@
import type { MemoryModePlan, MemoryPipelineMode } from "@one-memory/contracts";
import { useEffect, useRef } from "react";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button } from "./R6Controls";
export const MODE_COPY: Record<
MemoryPipelineMode,
{ title: string; description: string }
> = {
lite: {
title: "基础记忆(lite)",
description: "保存原始对话 · 不调用生成模型 · 搜索原始会话",
},
pro: {
title: "智能记忆(pro)",
description: "沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆",
},
ultra: {
title: "深度记忆(ultra)",
description: "智能记忆 + 知识图谱 · 可自动 Evolve · 增强关联召回",
},
};
export const BLOCKER_COPY: Record<MemoryModePlan["blockers"][number], string> =
{
active_model_required: "使用 pro 或 ultra 前需要先配置并启用生成模型。",
embedding_maintenance_in_progress:
"正在检查或切换向量模型,完成后才能切换记忆模式。",
memory_lifecycle_busy: "记忆维护正在进行,完成后才能切换模式。",
graph_store_unavailable: "当前安装缺少知识图谱组件,无法开启 ultra。",
};
export function memoryModePlanRows(plan: MemoryModePlan) {
const rows = [
{
label: "新对话",
content:
plan.capabilities.content_plane === "raw"
? "之后只保存原始对话,不再生成新记忆。"
: plan.capabilities.graph_enabled
? "新对话会整理成记忆并写入知识图谱。"
: "新对话会由模型整理成记忆。",
},
{
label: "模型调用与费用",
content: plan.capabilities.generation_required
? plan.capabilities.evolve_available
? "会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。"
: "会调用当前生成模型,可能产生服务商费用。"
: "不调用生成模型,不再产生这部分费用。",
},
{
label: "搜索范围",
content:
plan.capabilities.content_plane === "raw"
? "切换后搜索原始对话。"
: plan.capabilities.graph_enabled
? "搜索沉淀记忆,并用知识图谱补充关联。"
: "搜索沉淀后的记忆。",
},
];
if (plan.effects.includes("earlier_raw_not_extracted"))
rows.push({
label: "已有数据",
content: "已经处理完的旧对话不会补做;原始对话全部保留。",
});
if (plan.effects.includes("higher_mode_memories_hidden"))
rows.push({
label: "已有数据",
content:
plan.effective_target === "pro"
? "普通沉淀记忆仍可使用;深度记忆新增的更高层记忆和图谱数据继续保留,但在智能记忆下不显示、不参与召回。原始对话不会删除。"
: "已有记忆继续保留,但在基础记忆下不显示、不参与召回;原始对话不会删除。",
});
if (plan.effects.includes("existing_memories_added_to_graph"))
rows.push({
label: "知识图谱",
content: "已有记忆会在后台加入知识图谱,期间仍可使用。",
});
if (plan.effects.includes("evolve_stopped"))
rows.push({
label: "每天自动整合",
content: "每天自动整合会停止,已经生成的数据不会删除。",
});
if (plan.capabilities.evolve_available)
rows.push({
label: "切换后",
content: "可设置每天自动整合时间,也可以随时立即整合一次。",
});
return rows;
}
export function MemoryModeConfirmDialog({
mode,
plan,
busy,
error,
opener,
allowedBlockers = [],
onCancel,
onConfirm,
}: {
mode: MemoryPipelineMode;
plan: MemoryModePlan;
busy: boolean;
error?: string;
opener?: HTMLElement | null;
allowedBlockers?: readonly MemoryModePlan["blockers"][number][];
onCancel(): void;
onConfirm(): void;
}) {
const { text } = useOnboardingTranslation();
const panel = useRef<HTMLElement>(null);
const busyRef = useRef(busy);
const onCancelRef = useRef(onCancel);
busyRef.current = busy;
onCancelRef.current = onCancel;
const blocking = plan.blockers.filter(
(blocker) => !allowedBlockers.includes(blocker),
);
useEffect(() => {
const element = panel.current;
if (!element) return;
const inertSiblings = new Map<HTMLElement, boolean>();
let branch: HTMLElement = element.parentElement ?? element;
while (branch.parentElement) {
for (const sibling of branch.parentElement.children) {
if (!(sibling instanceof HTMLElement) || sibling === branch) continue;
inertSiblings.set(sibling, sibling.inert);
sibling.inert = true;
}
branch = branch.parentElement;
if (branch === document.body) break;
}
const controls = () =>
Array.from(
element.querySelectorAll<HTMLElement>("button:not(:disabled)"),
);
controls()[0]?.focus();
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busyRef.current) {
event.preventDefault();
onCancelRef.current();
return;
}
if (event.key !== "Tab") return;
const items = controls();
const first = items[0];
const last = items.at(-1);
if (!first || !last) {
event.preventDefault();
element.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
element.addEventListener("keydown", handleKey);
return () => {
element.removeEventListener("keydown", handleKey);
for (const [sibling, wasInert] of inertSiblings) sibling.inert = wasInert;
if (opener?.isConnected) opener.focus();
};
}, [opener]);
useEffect(() => {
if (busy) panel.current?.focus();
}, [busy]);
return (
<div className="r6-settings-dialog-layer" role="presentation">
<section
ref={panel}
className="r6-settings-dialog r6-mode-confirm-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="memory-mode-confirm-title"
tabIndex={-1}
>
<h2 id="memory-mode-confirm-title">
{text("切换到 {mode}?").replace(
"{mode}",
text(MODE_COPY[mode].title),
)}
</h2>
<dl className="r6-mode-confirm-effects">
{memoryModePlanRows(plan).map((row) => (
<div key={`${row.label}:${row.content}`}>
<dt>{text(row.label)}</dt>
<dd>{text(row.content)}</dd>
</div>
))}
</dl>
{plan.blockers.map((blocker) => (
<p
className={
blocking.includes(blocker)
? "r6-settings-error"
: "r6-settings-note"
}
role={blocking.includes(blocker) ? "alert" : "status"}
key={blocker}
>
{text(BLOCKER_COPY[blocker])}
</p>
))}
{error ? (
<p className="r6-settings-error" role="alert">
{text(error)}
</p>
) : null}
<footer>
<R6Button disabled={busy} onClick={onCancel}>
{text("取消")}
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={blocking.length > 0}
onClick={onConfirm}
>
{text("切换模式")}
</R6Button>
</footer>
</section>
</div>
);
}
@@ -0,0 +1,79 @@
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
import { expect, it } from "vitest";
import { modelProfileReady, profileFromProbe } from "./model-profile-readiness";
const profile: ModelProfile = {
profile_id: "saved-1",
name: "Saved model",
provider_kind: "openai_compatible",
base_url: "https://models.example/v1",
model: "model-1",
embedding_model: null,
embedding_provider_kind: null,
embedding_base_url: null,
embedding_dimensions: null,
context_window: null,
disable_thinking: false,
has_api_key: true,
timeout_seconds: 60,
max_concurrency: 1,
revision: 1,
is_active: false,
created_at: "2026-09-14T00:00:00Z",
updated_at: "2026-09-14T00:00:00Z",
capabilities: {
endpoint_reachable: false,
model_available: false,
embedding_available: false,
structured_output: false,
},
};
function probe(
inputRevision: number | null | undefined = 1,
outputRevision = 2,
): ModelProbeResult {
return {
profile_id: profile.profile_id,
input_profile_revision: inputRevision,
profile_revision: outputRevision,
stages: [],
capabilities: {
endpoint_reachable: true,
model_available: true,
embedding_available: true,
structured_output: false,
},
};
}
it("accepts a measured one-revision bump before or after profile refresh", () => {
const receipt = probe();
const beforeRefresh = profileFromProbe(profile, receipt);
const afterRefresh = profileFromProbe({ ...profile, revision: 2 }, receipt);
expect(beforeRefresh).toMatchObject({ revision: 2 });
expect(afterRefresh).toMatchObject({ revision: 2 });
expect(modelProfileReady(beforeRefresh as ModelProfile, receipt)).toBe(true);
});
it("rejects a bump without input evidence, a skipped revision and another profile", () => {
expect(profileFromProbe(profile, probe(null, 2))).toBeNull();
expect(profileFromProbe(profile, probe(1, 3))).toBeNull();
expect(
profileFromProbe(profile, {
...probe(),
profile_id: "another-profile",
}),
).toBeNull();
});
it("keeps unavailable credentials from becoming ready", () => {
const receipt = probe();
const tested = profileFromProbe(
{ ...profile, credential_state: "unavailable" },
receipt,
);
expect(tested).not.toBeNull();
expect(modelProfileReady(tested as ModelProfile, receipt)).toBe(false);
});
@@ -0,0 +1,48 @@
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
export function profileFromProbe(
profile: ModelProfile,
probe: ModelProbeResult | null | undefined,
): ModelProfile | null {
if (!probe || probe.profile_id !== profile.profile_id) return null;
const inputRevision = probe.input_profile_revision;
if (!Number.isSafeInteger(probe.profile_revision)) return null;
if (inputRevision === undefined || inputRevision === null) {
if (probe.profile_revision !== profile.revision) return null;
} else {
if (
!Number.isSafeInteger(inputRevision) ||
(probe.profile_revision !== inputRevision &&
probe.profile_revision !== inputRevision + 1) ||
(profile.revision !== inputRevision &&
profile.revision !== probe.profile_revision)
)
return null;
}
return {
...profile,
revision: probe.profile_revision,
capabilities: probe.capabilities,
};
}
export function modelProfileReady(
profile: ModelProfile,
probe: ModelProbeResult | null = null,
error: string | null = null,
): boolean {
return (
!error &&
(!probe ||
(probe.profile_id === profile.profile_id &&
probe.profile_revision === profile.revision &&
probe.capabilities.endpoint_reachable &&
probe.capabilities.model_available &&
probe.capabilities.embedding_available)) &&
profile.credential_state !== "unavailable" &&
!(profile.has_configured_api_key && !profile.has_api_key) &&
profile.capabilities.endpoint_reachable &&
profile.capabilities.model_available &&
profile.capabilities.embedding_available
);
}
+35 -2
View File
@@ -719,8 +719,8 @@
color: #416e5c;
font-size: 13px;
}
.generation-model-error {
color: #a24646 !important;
.onboarding-model-r6 .generation-model-error {
color: #a24646;
}
.onboarding-model-recovery-actions {
margin-top: 12px;
@@ -728,6 +728,19 @@
.onboarding-model-feedback {
margin-top: 8px;
}
.onboarding-model-r6 .onboarding-r6-foot {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto;
align-items: center;
gap: 20px 32px;
}
.onboarding-model-r6 .onboarding-model-footer-actions {
flex-wrap: nowrap;
margin-left: 0;
}
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
min-width: 132px;
}
@media (min-width: 761px) {
.onboarding-model-r6 .onboarding-r6-body {
overflow-y: auto;
@@ -751,6 +764,26 @@
flex-basis: 100%;
}
}
@media (max-width: 900px) {
.onboarding-model-r6 .onboarding-r6-foot {
grid-template-columns: minmax(0, 1fr);
}
.onboarding-model-r6 .onboarding-model-footer-actions {
width: 100%;
flex-wrap: wrap;
}
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button {
flex: 1 1 160px;
}
}
@media (max-width: 600px) {
.onboarding-model-r6 .onboarding-model-footer-actions .r6-button,
.onboarding-model-r6
.onboarding-model-footer-actions
.r6-button[data-variant="primary"] {
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.generation-technical-toggle,
.generation-model-field :is(input, select) {
+38
View File
@@ -612,6 +612,44 @@
line-height: 20px;
text-align: center;
}
.onboarding-r6-evolve-settings {
margin-top: 0;
padding: 18px 20px 4px;
border: 1px solid #cfdae2;
border-radius: 12px;
background: rgb(255 255 255 / 58%);
}
.onboarding-r6-evolve-settings .r6-evolve-heading {
margin-bottom: 2px;
}
.onboarding-r6-evolve-settings .r6-evolve-setting {
grid-template-columns: minmax(0, 1fr) auto;
padding-bottom: 12px;
border-bottom: 0;
}
.onboarding-r6-evolve-settings .r6-evolve-setting-copy {
min-width: 0;
}
.onboarding-r6-evolve-settings .r6-evolve-time {
grid-template-columns: minmax(0, 1fr) 180px;
}
.onboarding-r6-evolve-settings .r6-evolve-time label {
color: #304757;
font-size: 13px;
font-weight: 600;
line-height: 22px;
}
.onboarding-r6-schedule-recovery {
display: grid;
justify-items: center;
gap: 10px;
color: #944242;
font-size: 13px;
line-height: 20px;
}
.onboarding-r6-schedule-recovery p {
margin: 0;
}
.onboarding-r6-mode-error {
min-height: 20px;
margin: -6px 0 0;
+23 -7
View File
@@ -318,22 +318,38 @@ it("records a failed test only for the attempted revision and rejects a mismatch
expect(result.current.profiles[0].capabilities).toEqual(profile.capabilities);
});
it("shows a probe-induced new revision only after the matching profile refresh", async () => {
it("commits a probe-induced revision before the follow-up refresh returns", async () => {
vi.mocked(api.getModelProfiles).mockResolvedValueOnce([profile]);
const { result } = renderHook(useModelProfiles);
await waitFor(() => expect(result.current.profiles).toHaveLength(1));
const updated = { ...profile, revision: 2, embedding_dimensions: 512 };
const updated = {
...profile,
revision: 2,
embedding_dimensions: 512,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
};
const receipt = {
...probeResult(updated),
input_profile_revision: profile.revision,
};
const delayed = deferred<ModelProfile[]>();
vi.mocked(api.getModelProfiles).mockReturnValueOnce(delayed.promise);
vi.mocked(api.probeModelProfile).mockResolvedValueOnce(probeResult(updated));
vi.mocked(api.probeModelProfile).mockResolvedValueOnce(receipt);
await act(async () => {
await result.current.probeProfile(profile.profile_id);
});
expect(result.current.probeFor(profile)).toBeNull();
expect(result.current.profiles[0]).toMatchObject({
revision: 2,
capabilities: receipt.capabilities,
});
expect(result.current.probeFor(result.current.profiles[0])).toEqual(receipt);
await act(async () => {
delayed.resolve([updated]);
});
expect(result.current.probeFor(result.current.profiles[0])).toEqual(
probeResult(updated),
);
expect(result.current.probeFor(result.current.profiles[0])).toEqual(receipt);
});
+2 -6
View File
@@ -19,6 +19,7 @@ import {
updateModelProfile,
} from "./api";
import { modelErrorText } from "./model-error-text";
import { profileFromProbe } from "./model-profile-readiness";
import {
clearPendingModelCreation,
readPendingModelCreation,
@@ -314,12 +315,7 @@ export function useModelProfiles() {
})]: result,
}));
setProfiles((previous) =>
previous.map((item) =>
item.profile_id === result.profile_id &&
item.revision === result.profile_revision
? { ...item, capabilities: result.capabilities }
: item,
),
previous.map((item) => profileFromProbe(item, result) ?? item),
);
},
(reason) =>
@@ -412,16 +412,18 @@ mode. A change rebuilds the SDK handles the same way. Contract:
The App's product rules are:
- A new installation explicitly chooses lite or pro. Choosing lite persists
the mode and completes onboarding without a generation model. Choosing pro
opens model setup and completes onboarding only after activation succeeds.
- A new installation explicitly chooses lite, pro or ultra. Choosing lite
persists the mode and completes onboarding without a generation model.
Choosing pro or ultra opens model setup when no model is active and completes
onboarding only after activation succeeds.
- Lite always ingests authorized imported and live conversations into L1 raw,
even when model sedimentation is paused. That pause controls generation-model
work in pro/ultra; it never turns raw conversation storage off.
- Existing installations keep the default pro behaviour; an absent
`memory_mode` is never inferred from model availability.
- Ultra is enabled only from Settings. Its nightly schedule is on by default
at 03:00 local time, and Settings also exposes a manual evolve action.
- Ultra's nightly schedule is configured during first-run selection or later
from Settings. It is on by default at 03:00 local time. Settings also exposes
a manual evolve action; onboarding does not run evolve immediately.
- The memory page shows raw conversations in lite and advanced memories in
pro/ultra. Higher-mode memories remain stored while lite is active but are
neither shown nor served by lite search.
+1 -1
View File
@@ -259,7 +259,7 @@ export interface ConnectSourcesResponse {
request_id?: string | null;
}
export type OnboardingModeIntent = "lite" | "pro";
export type OnboardingModeIntent = "lite" | "pro" | "ultra";
export type OnboardingSetupStep = "review" | "mode" | "model";
export interface OnboardingProgressUpdate {
+42 -42
View File
@@ -55,11 +55,11 @@ const modeChineseMessages = {
"使用 pro 或 ultra 前需要先配置并启用生成模型。":
"使用 pro 或 ultra 前需要先配置并启用生成模型。",
"正在检查或切换向量模型,完成后才能切换记忆模式。":
"正在检查或切换向量模型,完成后才能切换记忆模式。",
"正在检查或切换向量模型,完成后才能选择记忆模式。",
"当前安装缺少知识图谱组件,无法开启 ultra。":
"当前安装缺少知识图谱组件,无法开启 ultra。",
"当前安装缺少知识图谱组件,无法选择 ultra。",
"模式切换未能确认,仍显示上次确认的模式。":
"模式切换未能确认,仍显示上次确认的模式。",
"模式选择未能确认,仍显示上次确认的模式。",
"自动整合设置未能保存,请重试。": "自动整合设置未能保存,请重试。",
"正在检查或切换向量模型,完成后才能整合记忆。":
"正在检查或切换向量模型,完成后才能整合记忆。",
@@ -71,7 +71,7 @@ const modeChineseMessages = {
"开发环境当前强制使用 ultra,保存的选择暂不会生效。":
"开发环境当前强制使用 ultra,保存的选择暂不会生效。",
"正在检查或切换向量模型,完成前不能切换模式或手动整合。":
"正在检查或切换向量模型,完成前不能切换模式或手动整合。",
"正在检查或切换向量模型,完成前不能选择模式或手动整合。",
"正在把已有高级记忆加入知识图谱…": "正在把已有高级记忆加入知识图谱…",
每天自动整合: "每天自动整合",
"每天 {time} 自动运行 evolve,设备休眠错过后会补跑。":
@@ -83,10 +83,10 @@ const modeChineseMessages = {
"上次整合:{time}": "上次整合:{time}",
尚未整合: "尚未整合",
立即整合: "立即整合",
"切换到 {mode}?": "切换到{mode}?",
"切换到 {mode}": "切换到{mode}",
"切换到 {mode}?": "选择{mode}?",
"切换到 {mode}": "选择{mode}",
"原始对话在所有模式下都会保留。": "原始对话在所有模式下都会保留。",
切换模式: "切换模式",
切换模式: "确认选择",
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
"保存原始对话 · 不调用生成模型 · 搜索原始会话",
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
@@ -98,18 +98,18 @@ const modeChineseMessages = {
搜索范围: "搜索范围",
已有数据: "已有数据",
知识图谱: "知识图谱",
切换后: "切换后",
切换后: "选择后",
"之后只保存原始对话,不再生成新记忆。":
"之后只保存原始对话,不再生成新记忆。",
"新对话会整理成记忆并写入知识图谱。": "新对话会整理成记忆并写入知识图谱。",
"新对话会由模型整理成记忆。": "新对话会由模型整理成记忆。",
"会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。":
"会调用当前生成模型;自动整合可能产生额外费用。切换本身不会立即整合。",
"会调用当前生成模型;自动整合可能产生额外费用。选择模式本身不会立即整合。",
"会调用当前生成模型,可能产生服务商费用。":
"会调用当前生成模型,可能产生服务商费用。",
"不调用生成模型,不再产生这部分费用。":
"不调用生成模型,不再产生这部分费用。",
"切换后搜索原始对话。": "切换后搜索原始对话。",
"切换后搜索原始对话。": "选择后搜索原始对话。",
"搜索沉淀记忆,并用知识图谱补充关联。":
"搜索沉淀记忆,并用知识图谱补充关联。",
"搜索沉淀后的记忆。": "搜索沉淀后的记忆。",
@@ -122,8 +122,8 @@ const modeChineseMessages = {
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。":
"普通沉淀记忆仍可使用;更高层记忆和图谱数据继续保留,但不参与当前召回;每天自动整合会停止。",
"记忆维护正在进行,完成后才能切换模式。":
"记忆维护正在进行,完成后才能切换模式。",
"模式切换影响暂时无法读取,请重试。": "模式切换影响暂时无法读取,请重试。",
"记忆维护正在进行,完成后才能选择模式。",
"模式切换影响暂时无法读取,请重试。": "模式选择影响暂时无法读取,请重试。",
"停止调用生成模型;每天自动整合也会停止。":
"停止调用生成模型;每天自动整合也会停止。",
"停止调用生成模型,不再产生这部分费用。":
@@ -281,11 +281,11 @@ export const modeEnglishMessages: ModeMessages = {
"上次整合:{time}": "Last consolidated: {time}",
尚未整合: "Not yet consolidated",
立即整合: "Consolidate now",
"切换到 {mode}?": "Switch to {mode}?",
"切换到 {mode}": "Switch to {mode}",
"切换到 {mode}?": "Choose {mode}?",
"切换到 {mode}": "Choose {mode}",
"原始对话在所有模式下都会保留。":
"Original conversations are kept in every mode.",
切换模式: "Switch mode",
切换模式: "Confirm choice",
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
"Save original conversations · No generation model calls · Search original conversations",
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
@@ -485,11 +485,11 @@ export const modeSpanishMessages: ModeMessages = {
"上次整合:{time}": "Última consolidación: {time}",
尚未整合: "Aún sin consolidar",
立即整合: "Consolidar ahora",
"切换到 {mode}?": "¿Cambiar a {mode}?",
"切换到 {mode}": "Cambiar a {mode}",
"切换到 {mode}?": "¿Elegir {mode}?",
"切换到 {mode}": "Elegir {mode}",
"原始对话在所有模式下都会保留。":
"Las conversaciones originales se conservan en todos los modos.",
切换模式: "Cambiar modo",
切换模式: "Confirmar selección",
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
"Guarda conversaciones originales · Sin llamadas al modelo generativo · Busca conversaciones originales",
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
@@ -682,10 +682,10 @@ export const modeTraditionalChineseMessages: ModeMessages = {
"上次整合:{time}": "上次整合:{time}",
尚未整合: "尚未整合",
立即整合: "立即整合",
"切换到 {mode}?": "切換到{mode}?",
"切换到 {mode}": "切換到{mode}",
"切换到 {mode}?": "選擇{mode}?",
"切换到 {mode}": "選擇{mode}",
"原始对话在所有模式下都会保留。": "原始對話會在所有模式下保留。",
切换模式: "切換模式",
切换模式: "確認選擇",
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
"儲存原始對話 · 不呼叫生成模型 · 搜尋原始對話",
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
@@ -833,10 +833,10 @@ export const modeJapaneseMessages: ModeMessages = {
"上次整合:{time}": "前回の統合:{time}",
尚未整合: "まだ統合されていません",
立即整合: "今すぐ統合",
"切换到 {mode}?": "{mode} に切り替えますか?",
"切换到 {mode}": "{mode} に切り替える",
"切换到 {mode}?": "{mode} を選択しますか?",
"切换到 {mode}": "{mode} を選択",
"原始对话在所有模式下都会保留。": "元の会話はどのモードでも保持されます。",
切换模式: "モードを切り替える",
切换模式: "選択を確定",
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
"元の会話を保存 · 生成モデルを呼び出さない · 元の会話を検索",
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
@@ -1033,11 +1033,11 @@ export const modeGermanMessages: ModeMessages = {
"上次整合:{time}": "Zuletzt konsolidiert: {time}",
尚未整合: "Noch nicht konsolidiert",
立即整合: "Jetzt konsolidieren",
"切换到 {mode}?": "Zu {mode} wechseln?",
"切换到 {mode}": "Zu {mode} wechseln",
"切换到 {mode}?": "{mode} auswählen?",
"切换到 {mode}": "{mode} auswählen",
"原始对话在所有模式下都会保留。":
"Originalunterhaltungen bleiben in jedem Modus erhalten.",
切换模式: "Modus wechseln",
切换模式: "Auswahl bestätigen",
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
"Originalunterhaltungen speichern · Keine Aufrufe des generativen Modells · Originalunterhaltungen durchsuchen",
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
@@ -1237,11 +1237,11 @@ export const modePortugueseMessages: ModeMessages = {
"上次整合:{time}": "Última consolidação: {time}",
尚未整合: "Ainda não consolidado",
立即整合: "Consolidar agora",
"切换到 {mode}?": "Mudar para {mode}?",
"切换到 {mode}": "Mudar para {mode}",
"切换到 {mode}?": "Escolher {mode}?",
"切换到 {mode}": "Escolher {mode}",
"原始对话在所有模式下都会保留。":
"As conversas originais são mantidas em todos os modos.",
切换模式: "Mudar de modo",
切换模式: "Confirmar escolha",
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
"Guardar conversas originais · Sem chamadas ao modelo generativo · Pesquisar conversas originais",
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
@@ -1443,11 +1443,11 @@ export const modeFrenchMessages: ModeMessages = {
"上次整合:{time}": "Dernière consolidation : {time}",
尚未整合: "Pas encore consolidé",
立即整合: "Consolider maintenant",
"切换到 {mode}?": "Passer à {mode} ?",
"切换到 {mode}": "Passer à {mode}",
"切换到 {mode}?": "Choisir {mode} ?",
"切换到 {mode}": "Choisir {mode}",
"原始对话在所有模式下都会保留。":
"Les conversations originales sont conservées dans tous les modes.",
切换模式: "Changer de mode",
切换模式: "Confirmer le choix",
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
"Enregistrer les conversations originales · Aucun appel au modèle génératif · Rechercher les conversations originales",
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
@@ -1645,10 +1645,10 @@ export const modeKoreanMessages: ModeMessages = {
"上次整合:{time}": "마지막 통합: {time}",
尚未整合: "아직 통합되지 않음",
立即整合: "지금 통합",
"切换到 {mode}?": "{mode}(으)로 전환할까요?",
"切换到 {mode}": "{mode}(으)로 전환",
"切换到 {mode}?": "{mode}을(를) 선택할까요?",
"切换到 {mode}": "{mode} 선택",
"原始对话在所有模式下都会保留。": "원본 대화는 모든 모드에서 유지됩니다.",
切换模式: "모드 전환",
切换模式: "선택 확인",
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
"원본 대화 저장 · 생성 모델 호출 없음 · 원본 대화 검색",
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
@@ -1844,11 +1844,11 @@ export const modeRussianMessages: ModeMessages = {
"上次整合:{time}": "Последнее объединение: {time}",
尚未整合: "Ещё не объединялись",
立即整合: "Объединить сейчас",
"切换到 {mode}?": "Переключиться на {mode}?",
"切换到 {mode}": "Переключиться на {mode}",
"切换到 {mode}?": "Выбрать {mode}?",
"切换到 {mode}": "Выбрать {mode}",
"原始对话在所有模式下都会保留。":
"Исходные диалоги сохраняются во всех режимах.",
切换模式: "Переключить режим",
切换模式: "Подтвердить выбор",
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
"Сохранять исходные диалоги · Не вызывать генеративную модель · Искать в исходных диалогах",
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
@@ -2048,10 +2048,10 @@ export const modeIndonesianMessages: ModeMessages = {
"上次整合:{time}": "Terakhir dikonsolidasikan: {time}",
尚未整合: "Belum dikonsolidasikan",
立即整合: "Konsolidasikan sekarang",
"切换到 {mode}?": "Beralih ke {mode}?",
"切换到 {mode}": "Beralih ke {mode}",
"切换到 {mode}?": "Pilih {mode}?",
"切换到 {mode}": "Pilih {mode}",
"原始对话在所有模式下都会保留。": "Percakapan asli disimpan di semua mode.",
切换模式: "Beralih mode",
切换模式: "Konfirmasi pilihan",
"保存原始对话 · 不调用生成模型 · 搜索原始会话":
"Simpan percakapan asli · Tanpa panggilan model generatif · Cari percakapan asli",
"沉淀高级记忆 · 调用生成模型 · 搜索沉淀记忆":
+1 -1
View File
@@ -239,7 +239,7 @@ describe("GUI messages", () => {
"上次整合:11/3/2026, 4:00:00 PM",
"Last consolidated: 11/3/2026, 4:00:00 PM",
],
["切换到 ultra?", "Switch to ultra?"],
["切换到 ultra?", "Choose ultra?"],
] as const;
for (const [source, expected] of cases) {
expect(isMessageKey(source)).toBe(true);