Compare commits

...
Author SHA1 Message Date
kristinheandCursor fe8c53f170 style(memhub): simplify responsive onboarding
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 10:46:28 +08:00
kristinhe1111 2f9e028c50 fix(memhub): share model probe readiness 2026-11-05 03:12:34 +08:00
22 changed files with 599 additions and 489 deletions

No files matched your search

@@ -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")}
+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("设为当前模型")}
@@ -164,8 +164,7 @@ it("moves from import to the actual third step without completing onboarding", a
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();
});
@@ -184,7 +183,7 @@ 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 }));
@@ -210,7 +209,7 @@ 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 }));
@@ -302,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);
@@ -211,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();
@@ -289,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",
@@ -324,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();
@@ -333,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();
});
@@ -425,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>
@@ -434,11 +440,11 @@ 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(
@@ -347,11 +347,11 @@ 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 进入" }));
@@ -398,13 +398,7 @@ 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 进入" }));
@@ -125,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: "首次设置进度" })
@@ -133,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(
@@ -349,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",
@@ -373,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({
+17 -23
View File
@@ -219,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(
@@ -334,7 +334,7 @@ describe("OnboardingFlow S1.3", () => {
it("D41 opens source choice with metadata discovery but no content or authorization requests", async () => {
const controller = imports();
render(
const view = render(
<OnboardingFlow
state={onboarding}
imports={controller}
@@ -470,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 () => {
@@ -951,7 +948,7 @@ describe("OnboardingFlow S1.3", () => {
await user.click(screen.getByRole("button", { name: SKIP }));
expect(screen.getByRole("heading", { name: "选择记忆模式" })).toBeTruthy();
expect(screen.getByText("之后可在设置中更改。")).toBeTruthy();
expect(screen.queryByText("之后可在设置中更改。")).toBeNull();
expect(screen.getAllByRole("radio")).toHaveLength(3);
expect(screen.getByRole("radio", { name: /ultra/i })).toBeTruthy();
expect(screen.getByText("推荐").closest("label")?.textContent).toContain(
@@ -1208,7 +1205,7 @@ describe("OnboardingFlow S1.3", () => {
}),
);
const user = userEvent.setup();
render(
const view = render(
<OnboardingFlow
state={onboarding}
imports={imports({ sources: [] })}
@@ -1219,7 +1216,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 user.click(screen.getByRole("button", { name: "返回导入" }));
view.unmount();
await act(async () =>
resolvePlan({
current: "pro",
@@ -1238,11 +1235,6 @@ describe("OnboardingFlow S1.3", () => {
);
expect(screen.queryByRole("dialog")).toBeNull();
expect(
screen.getByRole("heading", {
name: "对话正在汇入,记忆从这里开始",
}),
).toBeTruthy();
});
it("persists Lite mode before resumable progress and completion", async () => {
@@ -1758,7 +1750,7 @@ describe("OnboardingFlow S1.3", () => {
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
@@ -1767,7 +1759,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");
@@ -1823,6 +1815,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");
@@ -1847,6 +1840,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");
@@ -1929,9 +1923,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,
);
});
@@ -2738,10 +2732,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();
@@ -3084,7 +3077,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
@@ -3093,6 +3086,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: "仍进入应用页" }));
+40 -82
View File
@@ -1148,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
@@ -1251,78 +1253,38 @@ export function OnboardingFlow({
<R6ColdStartFrame
step={3}
title={text("选择记忆模式")}
eyebrow={text("最后一步 · 决定如何处理对话")}
focusToken={modeFocusToken}
footer={
<>
<R6Button
variant="quiet"
disabled={modeSaving || completionBusy}
onClick={() => {
modePlanOperation.current += 1;
setModePlanBusy(false);
setPendingMode(null);
setPendingPlan(null);
setModeError(null);
intentRef.current = modeChoice;
setupStepRef.current = "review";
setSetupStep("review");
void queueProgressWrite(() =>
patchOnboardingProgress({
mode_intent: modeChoice,
setup_step: "review",
}),
).catch((reason) => {
if (
!isCompletedProgressConflict(reason) &&
alive.current
)
setModeError(
"恢复进度未能保存。你的选择已保留,请重试。",
);
});
}}
>
{text("返回导入")}
</R6Button>
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving || modePlanBusy}
disabled={
!modeChoice ||
completionBusy ||
modePlanBusy ||
(modeChoice === "ultra" && !scheduleLoaded)
}
onClick={() => void saveModeChoice()}
>
{text(
modePhase === "saving"
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: modeChoice === "ultra"
? "继续"
: "继续",
)}
</R6Button>
</>
<R6Button
className="onboarding-r6-submit"
variant="primary"
pending={modeSaving || modePlanBusy}
disabled={
!modeChoice ||
completionBusy ||
modePlanBusy ||
(modeChoice === "ultra" && !scheduleLoaded)
}
onClick={() => void saveModeChoice()}
>
{text(
modePhase === "saving"
? "正在保存模式…"
: modePhase === "completing"
? "正在完成首次设置…"
: modeChoice === "lite"
? "使用 Lite 进入"
: modeChoice === "pro"
? "继续配置模型"
: "继续",
)}
</R6Button>
}
>
<section
className="onboarding-r6-mode"
aria-labelledby="onboarding-memory-mode-title"
aria-label={text("记忆模式")}
>
<div className="onboarding-r6-mode-intro">
<p id="onboarding-memory-mode-title">
{text("之后可在设置中更改。")}
</p>
</div>
<MemoryModeChoiceList
aria-label={text("记忆模式")}
modes={(["lite", "pro", "ultra"] as const).map((mode) => ({
@@ -1502,16 +1464,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)}
@@ -1520,10 +1474,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>
</>
}
@@ -1533,12 +1497,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}
+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%);
@@ -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) {
+30 -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;
@@ -801,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;
@@ -853,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) =>