Compare commits

...
32 changed files with 1784 additions and 874 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
@@ -379,8 +379,8 @@ it("uses user-facing connection confirmation copy without implementation narrati
expect(
screen.queryByText(/选择草稿|按各应用回执|本页任务控制尚未接通/),
).toBeNull();
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(screen.getByText(/之后仍可在设置中更改/)).toBeTruthy();
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
expect(screen.queryByText(/之后仍可在设置中更改/)).toBeNull();
});
it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
@@ -398,11 +398,7 @@ it.each(["paused", "failed", "cancelled", "interrupted"] as const)(
expect(
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
).toBeTruthy();
expect(
screen.getByText(/已保存的对话还在/, {
selector: ".first-import-r6-footer-note p",
}),
).toBeTruthy();
expect(screen.queryByText(/已保存的对话还在/)).toBeNull();
},
);
@@ -439,9 +435,7 @@ it.each(["stale", "revoked", "missing"])(
onContinue={vi.fn()}
/>,
);
if (kind === "missing")
expect(screen.getByText("导入在后台继续,无需等待。")).toBeTruthy();
else expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
expect(screen.queryByText("导入在后台继续,无需等待。")).toBeNull();
expect(
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
).toBeTruthy();
@@ -199,8 +199,8 @@ it("keeps the saved count when authority or freshness changes, without a convers
expect(view.container.querySelector('[data-running="true"]')).toBeTruthy();
expect(screen.getByText("已保存 7 条消息")).toBeTruthy();
expect(
screen.getAllByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
).toHaveLength(1);
screen.queryByText("正在重新核对来源;已保存数量仍按任务回执显示。"),
).toBeNull();
view.update({
sourceSnapshot: { confirmedAt: "2026-09-14T00:00:02Z", stale: false },
jobs: [{ ...job, stored: 20 }],
+67 -118
View File
@@ -148,31 +148,6 @@ export function FirstImportReview({
const canResume = batch.some((job) =>
["paused", "interrupted", "failed"].includes(job.status),
);
const allCompleted =
batch.length > 0 &&
ids.every((id) => batch.some((job) => job.source_id === id)) &&
batch.every((job) => job.status === "completed") &&
!Object.keys(failedSources).length;
const allPaused =
batch.length > 0 && batch.every((job) => job.status === "paused");
const importing =
!stale &&
batch.some(
(job) =>
["pending", "running"].includes(job.status) &&
sources.some(
(source) => source.source_id === job.source_id && source.authorized,
),
);
const displayImporting =
importing ||
(!stale &&
!allCompleted &&
!allPaused &&
batch.length === 0 &&
ids.some((id) =>
sources.some((source) => source.source_id === id && source.authorized),
));
const title = "对话正在汇入,记忆从这里开始";
const hostEligible = importHostEligible(sources, ids);
const draftReady = !!connectionDraft && !!onConnectionDraftChange;
@@ -282,24 +257,73 @@ export function FirstImportReview({
<R6ColdStartFrame
step={2}
title={text(title)}
eyebrow={text("导入进行中 · 顺手连接应用")}
footer={
<>
<div className="first-import-r6-footer-note">
<p>
{text(
allCompleted
? "对话已保存,记忆尚待生成。"
: displayImporting
? "导入在后台继续,无需等待。"
: stale
? "正在重新核对来源;已保存数量仍按任务回执显示。"
: allPaused
? "本次历史导入已暂停,已保存的对话还在。"
: "导入等待处理,已保存的对话还在。",
<div className="first-import-r6-footer-actions">
{changing ? (
<R6Button
onClick={() => {
stopBatch.current = true;
}}
>
{text("停止后续操作")}
</R6Button>
) : onChangeJob && (canPause || canResume) ? (
<R6Button
disabled={busy || (stale && !canPause)}
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
>
{canPause ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
</p>
{(error || actionMessage) && (
{text(canPause ? "暂停导入" : "继续导入")}
</R6Button>
) : null}
{recovery ? (
<>
<R6Button
disabled={busy}
onClick={() => {
stopBatch.current = true;
recovery.onSkip();
}}
>
{text(
recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台",
)}
</R6Button>
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave(recovery.destination)}
>
{text("重试保存并进入")}
</R6Button>
</>
) : (
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave("settings")}
>
{text("选择记忆模式")}
</R6Button>
)}
</div>
}
>
<div className="first-import-r6-layout">
<section
className="first-import-r6-main"
aria-label={text("导入与原文")}
>
{(error || actionMessage) && (
<div className="first-import-r6-feedback">
<p
role={
error?.includes("应用连接") ||
@@ -318,83 +342,8 @@ export function FirstImportReview({
>
{error ? text(error) : actionMessage}
</p>
)}
</div>
<div className="first-import-r6-footer-actions">
{changing ? (
<R6Button
onClick={() => {
stopBatch.current = true;
}}
>
{text("停止后续操作")}
</R6Button>
) : onChangeJob && (canPause || canResume) ? (
<R6Button
disabled={busy || (stale && !canPause)}
onClick={() => void changeBatch(canPause ? "pause" : "resume")}
>
{canPause ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
{text(canPause ? "暂停导入" : "继续导入")}
</R6Button>
) : null}
{recovery ? (
<>
<R6Button
disabled={busy}
onClick={() => {
stopBatch.current = true;
recovery.onSkip();
}}
>
{text(
recovery.destination === "sources"
? "仍进入应用页"
: recovery.destination === "settings"
? "仍进入设置"
: "仍进入工作台",
)}
</R6Button>
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave(recovery.destination)}
>
{text("重试保存并进入")}
</R6Button>
</>
) : (
<R6Button
variant="primary"
disabled={busy}
onClick={() => leave("settings")}
>
{text("选择记忆模式")}
</R6Button>
)}
</div>
</>
}
>
<div className="first-import-r6-layout">
<section
className="first-import-r6-main"
aria-label={text("导入与原文")}
>
<p className="first-import-r6-intro">
{text(
allCompleted
? "本次导入已结束。已保存的对话无需重复导入。"
: "对话先保存在本机。你可以同时设置后续连接。",
)}
</p>
<p className="first-import-r6-subintro">
{text("下一步选择记忆模式;之后仍可在设置中更改。")}
</p>
</div>
)}
{historyWindowLimited && (
<p className="first-import-r6-history-window" role="status">
{t("onboardingHistoryWindow")}
@@ -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,13 +156,15 @@ 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();
expect(finish).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "返回第二步" }));
await userEvent.click(screen.getByRole("button", { name: "返回导入" }));
expect(screen.getByText("Import accepted")).toBeTruthy();
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(completeOnboarding).not.toHaveBeenCalled();
});
@@ -161,10 +183,13 @@ it("lets an empty first-time user enter without granting or importing a source",
onFinish={finish}
/>,
);
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
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"),
@@ -184,10 +209,13 @@ it("retains explicit retry or enter when saving the skipped onboarding fails", a
onFinish={finish}
/>,
);
const skip = await screen.findByRole("button", { name: "稍后添加,先进入" });
const skip = await screen.findByRole("button", { name: "选择记忆模式" });
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();
@@ -273,7 +301,9 @@ it("freezes the explicitly submitted source list when a later source arrives", a
resolveRefresh = resolve;
}),
);
await userEvent.click(screen.getByRole("button", { name: "开始导入" }));
await userEvent.click(
await screen.findByRole("button", { name: "开始导入" }),
);
const lateSources = [source("workbuddy"), source("codex")];
rendered.rerender(view({ ...imports, sources: lateSources }));
expect(checkbox("codex").checked).toBe(false);
@@ -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({}),
}));
@@ -192,10 +211,10 @@ it("translates discovery, progress and privacy without importing or authorizing
}),
).toBeTruthy();
expect(
screen.getByText(
screen.queryByText(
"Antes de confirmar, solo se comprueba la información de las aplicaciones. No se leen conversaciones ni se instalan conexiones.",
),
).toBeTruthy();
).toBeNull();
expect(
screen.getByText("/fixture/cursor · Ubicación predeterminada"),
).toBeTruthy();
@@ -270,10 +289,10 @@ it("translates import review and preserves independent permission disclosure", a
}),
).toBeTruthy();
expect(
screen.getByText(
screen.queryByText(
"A continuación, elige un modo de memoria. Podrás cambiarlo más adelante en Configuración.",
),
).toBeTruthy();
).toBeNull();
expect(
screen.getByRole("region", {
name: "Progreso de importación de las aplicaciones",
@@ -305,8 +324,8 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
}),
).toBeTruthy();
expect(
screen.getByText(/A continuación, elige un modo de memoria/),
).toBeTruthy();
screen.queryByText(/A continuación, elige un modo de memoria/),
).toBeNull();
expect(
screen.getByText(/Solo para las apps seleccionadas y compatibles/),
).toBeTruthy();
@@ -314,7 +333,7 @@ it("uses compact Spanish actions and preserves Chinese copy and permissions", as
fireEvent.click(screen.getByText("中文"));
expect(screen.getByRole("button", { name: "选择记忆模式" })).toBeTruthy();
expect(navigation.textContent).toContain("连接应用");
expect(screen.getByText(/下一步选择记忆模式/)).toBeTruthy();
expect(screen.queryByText(/下一步选择记忆模式/)).toBeNull();
expect(next).not.toHaveBeenCalled();
expect(draft).not.toHaveBeenCalled();
});
@@ -406,6 +425,12 @@ it("translates completion recovery live without repeating completion writes", as
new Error("fixture-offline"),
);
const imports = controller();
imports.sources = [];
vi.mocked(imports.refresh).mockResolvedValue({
sources: [],
jobs: [],
health: null as never,
});
const finish = vi.fn();
render(
<LanguageProvider>
@@ -415,13 +440,16 @@ it("translates completion recovery live without repeating completion writes", as
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
).toHaveProperty("disabled", false),
);
fireEvent.click(
screen.getByRole("button", { name: "Entrar y añadir más tarde" }),
screen.getByRole("button", { name: "Elegir el modo de memoria" }),
);
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();
@@ -313,12 +347,13 @@ it("keeps the single-source submit frozen when another source arrives during fin
it("empty discovery shows zero and allows explicit entry without a connection request", async () => {
await ready();
await waitFor(() => expect(count()).toBe("0"));
expect(screen.getByRole("button", { name: "开始导入" })).toHaveProperty(
expect(screen.getByRole("button", { name: "选择记忆模式" })).toHaveProperty(
"disabled",
true,
false,
);
fireEvent.click(screen.getByRole("button", { name: "稍后添加,先进入" }));
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"),
@@ -363,14 +398,9 @@ it("partial acceptance preserves the successful job and failure through model-st
"accepted-job:completed",
);
fireEvent.click(screen.getByRole("button", { name: "继续配置模型" }));
fireEvent.click(await screen.findByRole("button", { name: "返回导入" }));
expect(screen.getByLabelText("保留任务").textContent).toBe(
"accepted-job:completed",
);
expect(screen.getByLabelText("失败来源").textContent).toBe("cursor");
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"),
+11 -5
View File
@@ -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",
@@ -119,7 +125,7 @@ it("renders the final shell, double title, step one, branded rows and exact sour
expect(
screen.getByRole("heading", { name: "把熟悉的背景,带到这里" }),
).toBeTruthy();
expect(screen.getByText("你的记忆,从这里接上")).toBeTruthy();
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
expect(
screen
.getByRole("navigation", { name: "首次设置进度" })
@@ -127,8 +133,8 @@ it("renders the final shell, double title, step one, branded rows and exact sour
).toContain("导入历史");
expect(screen.getByRole("heading", { name: "本机 AI 应用" })).toBeTruthy();
expect(
screen.getByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
).toBeTruthy();
screen.queryByText("确认前只检查应用信息,不读取对话,也不安装连接。"),
).toBeNull();
expect(screen.queryByRole("button", { name: "导入历史包" })).toBeNull();
expect(screen.getByText("/fixture/workbuddy · 默认位置")).toBeTruthy();
expect(
@@ -343,7 +349,7 @@ it("custom source cancel does not grant or select and accepted import retains re
expect(
(screen.getByLabelText("导入 codex 历史") as HTMLInputElement).checked,
).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
await screen.findByText("已受理 workbuddy");
expect(vi.mocked(imports.connectForOnboarding).mock.calls[0]?.[0]).toEqual([
"workbuddy",
@@ -367,7 +373,7 @@ it("does not submit authorization when the first-step owner leaves during final
done = resolve;
}),
);
fireEvent.click(screen.getByRole("button", { name: "开始导入" }));
fireEvent.click(await screen.findByRole("button", { name: "开始导入" }));
view.unmount();
await act(async () =>
done({
+374 -52
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,
@@ -205,7 +219,7 @@ function imports(
const READ_CONSENT = "允许读取以上来源的本次历史";
const START = "开始导入";
const SKIP = "稍后添加,先进入";
const SKIP = "选择记忆模式";
async function openApps(_user?: ReturnType<typeof userEvent.setup>) {
await waitFor(() =>
expect(
@@ -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();
@@ -446,15 +470,12 @@ describe("OnboardingFlow S1.3", () => {
modelBoundary,
);
await user.click(screen.getByRole("button", { name: "返回导入" }));
await user.click(screen.getByRole("button", { name: "返回导入" }));
await waitFor(() =>
expect(screen.getByRole("alert").textContent).toContain(
"开始导入的结果尚未确认",
),
);
expect(
screen.getByRole("heading", { name: "对话正在汇入,记忆从这里开始" }),
).toBeTruthy();
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
});
it("accepts Lite once while the import submission is still in flight", async () => {
@@ -491,7 +512,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 +560,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 +604,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 +644,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 +906,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 +926,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();
@@ -927,26 +948,324 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: SKIP }));
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.getByText("推荐").closest("label")?.textContent).toContain(
"Pro",
);
expect(
screen.getByText("Ultra 可在完成首次设置后到设置页开启。"),
).toBeTruthy();
expect(screen.queryByText("之后可在设置中更改。")).toBeNull();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
expect(screen.queryByText("推荐")).toBeNull();
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();
const view = 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 }));
view.unmount();
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();
});
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 +1289,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 +1323,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 +1368,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 +1376,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 +1422,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 +1430,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 +1476,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 +1518,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 +1567,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 +1607,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 +1637,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 +1656,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 +1705,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,17 +1738,17 @@ 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();
});
it("skipping after a selection does not grant, install, sync or connect", async () => {
const user = userEvent.setup();
const controller = imports();
const controller = imports({ sources: [] });
const onFinish = vi.fn();
render(
<OnboardingFlow
@@ -1437,7 +1757,7 @@ describe("OnboardingFlow S1.3", () => {
onFinish={onFinish}
/>,
);
await choose(user);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
@@ -1493,6 +1813,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: "重新检测位置" }));
expect(choice()).toHaveProperty("disabled", false);
await waitFor(() => expect(choice()).toHaveProperty("checked", true));
await user.click(choice());
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
@@ -1517,6 +1838,7 @@ describe("OnboardingFlow S1.3", () => {
expect(screen.getByRole("alert")).toBeTruthy();
expect(controller.connectForOnboarding).not.toHaveBeenCalled();
expect(onFinish).not.toHaveBeenCalled();
await user.click(choice());
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
expect(onFinish).toHaveBeenCalledWith("sources");
@@ -1599,9 +1921,9 @@ describe("OnboardingFlow S1.3", () => {
await user.click(choice());
await user.click(screen.getByRole("button", { name: "重新检测位置" }));
expect(choice()).toHaveProperty("checked", false);
expect(screen.getByRole("button", { name: START })).toHaveProperty(
expect(screen.getByRole("button", { name: SKIP })).toHaveProperty(
"disabled",
true,
false,
);
});
@@ -2408,10 +2730,9 @@ describe("OnboardingFlow S1.3", () => {
"disabled",
true,
);
expect(screen.getByRole("button", { name: SKIP })).toHaveProperty(
"disabled",
true,
);
expect(
screen.getByRole("button", { name: "正在开始导入…" }),
).toHaveProperty("disabled", true);
expect(controller.refresh).toHaveBeenCalledTimes(2);
await act(async () => resolve());
expect(controller.connectForOnboarding).toHaveBeenCalledOnce();
@@ -2754,7 +3075,7 @@ describe("OnboardingFlow S1.3", () => {
it("lets the user leave after completion cannot be persisted without reconnecting", async () => {
const user = userEvent.setup();
completeOnboarding.mockRejectedValue(new Error("disk unavailable"));
const controller = imports();
const controller = imports({ sources: [] });
const onFinish = vi.fn();
render(
<OnboardingFlow
@@ -2763,6 +3084,7 @@ describe("OnboardingFlow S1.3", () => {
onFinish={onFinish}
/>,
);
await openApps();
await user.click(screen.getByRole("button", { name: SKIP }));
await chooseLiteAndContinue(user);
await user.click(screen.getByRole("button", { name: "仍进入应用页" }));
+342 -125
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,
],
);
@@ -952,6 +1148,8 @@ export function OnboardingFlow({
}
const count = selected.length;
const continueWithoutImport =
busy === null && locationCheck !== "checking" && count === 0;
// D51 item 3: only a *confirmed* snapshot may show a number. A failed
// detection must read 未完成 even when sources are listed — otherwise the
// number branch answered first and the summary claimed a count while the
@@ -1051,41 +1249,22 @@ 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>
<>
<R6ColdStartFrame
step={3}
title={text("选择记忆模式")}
focusToken={modeFocusToken}
footer={
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving}
disabled={!modeChoice || completionBusy}
pending={modeSaving || modePlanBusy}
disabled={
!modeChoice ||
completionBusy ||
modePlanBusy ||
(modeChoice === "ultra" && !scheduleLoaded)
}
onClick={() => void saveModeChoice()}
>
{text(
@@ -1100,55 +1279,95 @@ export function OnboardingFlow({
: "继续",
)}
</R6Button>
</>
}
>
<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
<section
className="onboarding-r6-mode"
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) => {
>
<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}
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 +1375,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 +1389,7 @@ export function OnboardingFlow({
void persistModelProfile(profileId)
}
onModelActiveConfirmed={(token, ownerProfileId) =>
void confirmProMode(token, ownerProfileId)
void confirmModelMode(token, ownerProfileId)
}
sourceNames={sourceIds.map(
(id) =>
@@ -1184,10 +1404,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,
}),
@@ -1242,16 +1463,8 @@ export function OnboardingFlow({
<R6ColdStartFrame
step={1}
title={text("把熟悉的背景,带到这里")}
eyebrow={text("你的记忆,从这里接上")}
footer={
<>
<R6Button
variant="quiet"
disabled={locked}
onClick={() => requestModeChoice("sources")}
>
{text("稍后添加,先进入")}
</R6Button>
{error && (
<p className="onboarding-r6-error" role="alert">
{error && text(error)}
@@ -1260,10 +1473,20 @@ export function OnboardingFlow({
<R6Button
className="onboarding-r6-submit"
variant="primary"
disabled={locked || count === 0}
onClick={() => void startImport()}
disabled={locked}
onClick={() =>
continueWithoutImport
? requestModeChoice("sources")
: void startImport()
}
>
{text(busy === "submitting" ? "正在开始导入…" : "开始导入")}
{text(
continueWithoutImport
? "选择记忆模式"
: busy === "submitting"
? "正在开始导入…"
: "开始导入",
)}
</R6Button>
</>
}
@@ -1273,12 +1496,6 @@ export function OnboardingFlow({
className="onboarding-r6-apps"
aria-labelledby="cold-start-phase"
>
<div className="onboarding-r6-intro">
<p>
{text("不用从头介绍自己。从常用的 App 开始,把已有背景带过来。")}
</p>
<p>{text("确认前只检查应用信息,不读取对话,也不安装连接。")}</p>
</div>
<div className="onboarding-r6-list-heading">
<h2 id="cold-start-phase" ref={heading} tabIndex={-1}>
{text("本机 AI 应用")}
@@ -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("模型未能确认启用,请在设置中核对。");
+36 -2
View File
@@ -48,6 +48,40 @@ it("sizes the import action to its label and wraps footer content", () => {
);
});
it("uses content-driven shell, discovery and mode geometry", () => {
const stage = declaration("onboarding-r6.css", ".onboarding-r6-stage");
const shell = declaration("onboarding-r6.css", ".onboarding-r6-shell");
const discovery = declaration(
"onboarding-r6.css",
".onboarding-r6-discovery",
);
const heading = declaration(
"onboarding-r6.css",
".onboarding-r6-list-heading",
);
const mode = declaration("onboarding-r6.css", ".onboarding-r6-mode");
expect(stage).toMatch(/min-height:\s*0/);
expect(stage).not.toMatch(/min-height:\s*852px/);
expect(shell).toMatch(/height:\s*100%/);
expect(shell).not.toMatch(/(?:^|;)\s*height:\s*820px/);
expect(discovery).toMatch(/height:\s*100%/);
expect(heading).toMatch(/position:\s*static/);
expect(mode).toMatch(/width:\s*100%/);
});
it("does not reserve an empty eyebrow row", () => {
render(
<LanguageProvider>
<R6ColdStartFrame step={1} title="Fixture" footer={null}>
<p>Fixture body</p>
</R6ColdStartFrame>
</LanguageProvider>,
);
expect(screen.getByRole("heading", { name: "Fixture" })).toBeTruthy();
expect(screen.queryByText("你的记忆,从这里接上")).toBeNull();
});
it("keeps progress cards content-sized and the step-two footer in normal flow", () => {
const card = declaration(
"r6-import-progress.css",
@@ -62,10 +96,10 @@ it("keeps progress cards content-sized and the step-two footer in normal flow",
),
).toMatch(/flex:\s*0 0 auto/);
expect(declaration("first-import-r6.css", ".first-import-r6-layout")).toMatch(
/height:\s*auto/,
/height:\s*100%/,
);
expect(
declaration("first-import-r6.css", ".first-import-r6-footer-note"),
declaration("first-import-r6.css", ".first-import-r6-feedback"),
).not.toMatch(/max-height|overflow/);
expect(declaration("onboarding-r6.css", ".onboarding-r6-body")).toMatch(
/overflow-y:\s*auto/,
+2 -2
View File
@@ -13,7 +13,7 @@ export function R6ColdStartFrame({
}: {
step: 1 | 2 | 3;
title: string;
eyebrow: string;
eyebrow?: string | null;
children: ReactNode;
footer: ReactNode;
focusToken?: number;
@@ -39,7 +39,7 @@ export function R6ColdStartFrame({
<section className="onboarding-r6-shell" data-step={step}>
<header className="onboarding-r6-head">
<div>
<p>{eyebrow}</p>
{eyebrow ? <p>{eyebrow}</p> : null}
<h1
ref={titleRef}
tabIndex={focusToken === undefined ? undefined : -1}
@@ -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>
</>
);
}
+15 -22
View File
@@ -3,8 +3,9 @@
display: grid;
grid-template-columns: minmax(0, 738px) 278px;
gap: 30px;
min-height: 100%;
height: auto;
min-height: 0;
height: 100%;
align-items: stretch;
}
.first-import-r6-main {
min-width: 0;
@@ -12,20 +13,8 @@
display: flex;
flex-direction: column;
}
.first-import-r6-intro {
margin: 0;
color: #5a6169;
font-size: 13px;
line-height: 25px;
}
.first-import-r6-subintro {
margin: 1px 0 20px;
color: #63707b;
font-size: 12px;
line-height: 24px;
}
.first-import-r6-history-window {
margin: -10px 0 12px;
margin: 0 0 12px;
padding: 8px 10px;
border: 1px solid #ead7a7;
border-radius: 8px;
@@ -39,7 +28,7 @@
}
.first-import-r6-feed {
flex: 0 0 auto;
margin-top: 26px;
margin-top: 20px;
overflow: visible;
scrollbar-width: thin;
overscroll-behavior: contain;
@@ -101,6 +90,7 @@
overflow: auto;
scrollbar-width: thin;
overscroll-behavior: contain;
align-self: stretch;
}
.first-import-r6-eyebrow {
margin: 0 0 14px;
@@ -223,20 +213,22 @@
.first-import-r6-options details p {
margin: 6px 0;
}
.first-import-r6-footer-note {
flex: 1 1 240px;
min-width: 0;
.first-import-r6-feedback {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgb(255 255 255 / 50%);
}
.first-import-r6-footer-note p {
.first-import-r6-feedback p {
margin: 0;
font-size: 12px;
line-height: 20px;
color: #63707b;
}
.first-import-r6-footer-note [role="alert"] {
.first-import-r6-feedback [role="alert"] {
color: #944242;
}
.first-import-r6-footer-note .first-import-r6-partial-success {
.first-import-r6-feedback .first-import-r6-partial-success {
color: #245f50;
font-weight: 600;
}
@@ -248,6 +240,7 @@
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-left: auto;
}
.first-import-r6-footer-actions > [data-variant="primary"] {
min-width: min(160px, 100%);
+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) {
+68 -84
View File
@@ -115,14 +115,16 @@
}
.onboarding-r6-stage {
flex: 1;
min-height: 852px;
padding: 32px 24px 2px;
min-height: 0;
display: flex;
padding: 24px;
}
.onboarding-r6-shell {
min-height: 0;
width: 1120px;
max-width: 100%;
height: 820px;
height: 100%;
max-height: 820px;
margin: 0 auto;
display: flex;
flex-direction: column;
@@ -137,7 +139,7 @@
inset 0 -1px 0 #adc0d22e;
}
.onboarding-r6-head {
min-height: 123.7px;
min-height: 0;
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
@@ -195,6 +197,7 @@
background: #ffffff38;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
border-radius: 0 0 25px 25px;
}
@@ -224,27 +227,21 @@
}
}
.onboarding-r6-discovery {
height: 560.2px;
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 738px) 278px;
gap: 30px;
}
.onboarding-r6-apps {
position: relative;
min-width: 0;
}
.onboarding-r6-intro {
margin: 0;
font-size: 13px;
line-height: 24.05px;
color: #5a6169;
}
.onboarding-r6-intro p {
margin: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
}
.onboarding-r6-list-heading {
position: absolute;
inset: 96px 0 auto;
position: static;
height: 30px;
display: flex;
align-items: center;
@@ -277,8 +274,10 @@
cursor: default;
}
.onboarding-r6-list-area {
position: absolute;
inset: 142px 0 auto;
position: static;
min-height: 0;
display: flex;
flex-direction: column;
}
.onboarding-r6-list-status .onboarding-r6-quiet:disabled {
color: #4e6478;
@@ -288,7 +287,9 @@
padding: 0;
margin: 0;
list-style: none;
height: 330px;
min-height: 0;
height: auto;
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
@@ -451,44 +452,6 @@
content: "↓";
margin-right: 6px;
}
.onboarding-r6-import {
position: absolute;
top: 498px;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: #6a7988;
}
/* 143:2085 puts 导入历史包 first and the "没有本机历史?" sentence after it; the
markup keeps the sentence first so it stays the accessible label order for
the control that follows. Reordering visually only, rather than moving the
DOM, keeps that reading order intact. */
.onboarding-r6-import > p {
order: 2;
}
.onboarding-r6-import > .history-import,
.onboarding-r6-import > .history-import-trigger {
order: 1;
}
.onboarding-r6-import > p {
margin: 0;
}
.onboarding-r6-import .history-import-trigger {
font: inherit;
border: 0;
border-radius: 0;
background: transparent;
color: #4e6478;
padding: 5px 11px;
min-height: 30px;
box-shadow: none;
}
.onboarding-r6-import .history-import-trigger > svg {
display: none;
}
.onboarding-r6-summary {
border-radius: 22px;
border: 1px solid #ffffffe0;
@@ -500,6 +463,8 @@
display: flex;
flex-direction: column;
gap: 11px;
align-self: stretch;
min-height: 0;
}
.onboarding-r6-summary p {
margin: 0;
@@ -572,8 +537,8 @@
border: 0;
}
.onboarding-r6-mode {
width: min(720px, 100%);
margin: 0 auto;
width: 100%;
margin: 0;
display: flex;
flex-direction: column;
gap: 20px;
@@ -612,6 +577,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;
@@ -763,17 +766,11 @@
flex-direction: column;
}
.onboarding-r6-apps {
min-height: 590px;
min-height: 0;
}
.onboarding-r6-list-heading {
top: 110px;
}
.onboarding-r6-list-area {
top: 154px;
}
.onboarding-r6-import {
top: 552px;
flex-wrap: wrap;
.onboarding-r6-list {
height: min(330px, 45vh);
flex: none;
}
.onboarding-r6-row > label {
padding: 14px 10px;
@@ -815,20 +812,7 @@
}
.onboarding-r6-discovery > .onboarding-r6-apps {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 30px minmax(0, 1fr) auto;
gap: 12px;
}
.onboarding-r6-apps > .onboarding-r6-list-heading,
.onboarding-r6-apps > .onboarding-r6-list-area,
.onboarding-r6-apps > .onboarding-r6-import {
position: static;
min-height: 0;
}
.onboarding-r6-apps > .onboarding-r6-list-area {
display: flex;
flex-direction: column;
grid-template-rows: auto minmax(0, 1fr);
}
.onboarding-r6-list-area > .onboarding-r6-list {
min-height: 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);