Compare commits

...
Author SHA1 Message Date
kristinheandCursor 4910fa1995 feat(memhub): offer all memory modes during onboarding
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 02:34:39 +08:00
17 changed files with 1277 additions and 474 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>
);
@@ -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,
}),
@@ -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>
);
}
+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;
@@ -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);