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