Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe8c53f170 | ||
|
|
2f9e028c50 |
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 }],
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: "仍进入应用页" }));
|
||||
|
||||
@@ -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("模型未能确认启用,请在设置中核对。");
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user