Compare commits

...
Author SHA1 Message Date
kristinhe 23b74f91bc Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center 2026-11-03 19:41:41 +08:00
kristinhe a8b92d21e1 Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center 2026-11-03 19:35:22 +08:00
kristinheandCursor 1663c9424e fix(memhub): align model center with R6 visuals
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 19:31:00 +08:00
kristinhe f754f8bc0f Merge remote-tracking branch 'origin/feat/memhub-mode-ui' into feat/memhub-model-center
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	memhub/apps/web/src/OnboardingModelController.tsx
#	memhub/apps/web/src/R6Settings.tsx
2026-11-03 18:38:24 +08:00
kristinheandCursor 2155c4c773 feat(memhub): add safe model center
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-03 18:36:08 +08:00
24 changed files with 3992 additions and 573 deletions

No files matched your search

+4
View File
@@ -68,6 +68,10 @@ vi.mock("./api", () => ({
startEvolve: vi.fn(),
apiErrorCode: vi.fn().mockReturnValue(null),
getSources: vi.fn().mockResolvedValue(sources),
getCurrentEmbeddingSwitch: vi
.fn()
.mockRejectedValue(new Error("unsupported")),
embeddingSwitchUnsupported: vi.fn().mockReturnValue(true),
...mutations,
}));
vi.mock("./use-import-sources", () => ({
+5
View File
@@ -25,6 +25,7 @@ import { useApplicationsLanguage } from "./applications-language";
import { CallHistory } from "./CallHistory";
import { DebugLogPanel } from "./DebugLogPanel";
import { DesktopFrame } from "./DesktopFrame";
import { EmbeddingSwitchGlobalStatus } from "./EmbeddingSwitchExperience";
import { useGuiLanguage } from "./gui-language";
import { HealthPanel } from "./HealthPanel";
import { ImportProgressStrip } from "./ImportProgressStrip";
@@ -309,6 +310,10 @@ function AppContent() {
ref={content}
data-view={displayedView}
>
<EmbeddingSwitchGlobalStatus
onConfigureSearch={() => navigate("settings")}
onDiagnostics={() => navigate("health")}
/>
{/* First run hands the user in here while the import is still going.
* Without this the work would be invisible from every screen except
* the one they were no longer required to sit on. */}
@@ -0,0 +1,238 @@
// @vitest-environment jsdom
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
} from "@one-memory/contracts";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
const cancelCurrentSwitch = vi.fn();
let current: EmbeddingSwitchCurrent | null = null;
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current,
busy: null,
cancelCurrentSwitch,
}),
}));
vi.mock("./api", () => ({
deleteRetiredEmbeddingSpace: vi.fn(),
}));
import {
EmbeddingSwitchConfirmDialog,
EmbeddingSwitchGlobalStatus,
} from "./EmbeddingSwitchExperience";
const NOW = "2026-11-03T09:00:00Z";
const space = (id: string | null, model: string) => ({
space_id: id,
model,
dims: model === "old-search" ? 512 : 1024,
collection_base: id ? "memory" : "",
vectors: id ? 8421 : null,
});
const plan: EmbeddingSwitchPlan = {
needed: true,
reason: "embedding_identity_changed",
plan_token: "token",
source: space("space-old", "old-search"),
target: space(null, "new-search"),
target_profile_id: "profile-1",
target_revision: 2,
target_provider_kind: "openai_compatible",
target_local: false,
sources: [
{
space_id: "space-old",
collection_base: "memory",
dims: 512,
vectors: 8421,
unmerged: null,
},
],
total_vectors: 8421,
users: 1,
graph_vectors: null,
estimate_s: 120,
requests: 43,
disk_bytes_needed: 72 * 1024 * 1024,
disk_bytes_free: 1024 * 1024 * 1024,
retention_days: 14,
blockers: [],
};
const job: EmbeddingSwitchJob = {
job_id: "job-1",
kind: "switch",
state: "reembedding",
source: space("space-old", "old-search"),
target: space("space-new", "new-search"),
stray_space_ids: [],
target_profile_id: "profile-1",
target_revision: 2,
requested_by: "app",
progress: {
phase: "vectors",
done: 4884,
total: 8421,
percent: 58,
embedded: 4884,
reused: 0,
kept: 0,
role: "memory",
source_index: 0,
sources: 1,
rate_per_s: 90,
eta_s: 52,
updated_at: NOW,
},
cancel_requested: false,
error_code: null,
attempt: 1,
created_at: NOW,
updated_at: NOW,
finished_at: null,
};
afterEach(() => {
cleanup();
current = null;
vi.clearAllMocks();
});
it("explains the index update in user terms before exposing technical details", async () => {
const onStart = vi.fn();
render(
<EmbeddingSwitchConfirmDialog
plan={plan}
busy={false}
errorCode={null}
onCancel={vi.fn()}
onStart={onStart}
/>,
);
expect(
screen.getByRole("heading", { name: "更新记忆搜索索引?" }),
).toBeTruthy();
expect(screen.getByText("8,421 条")).toBeTruthy();
expect(screen.getByText("约 2 分钟")).toBeTruthy();
expect(screen.getByText(/失败或取消时仍使用原来的模型/)).toBeTruthy();
await userEvent.click(screen.getByText("查看技术详情"));
expect(screen.getByText("512 → 1024")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "开始更新索引" }));
expect(onStart).toHaveBeenCalledTimes(1);
});
it("blocks start when Core reports a prerequisite", () => {
render(
<EmbeddingSwitchConfirmDialog
plan={{ ...plan, blockers: ["insufficient_disk_space"] }}
busy={false}
errorCode={null}
onCancel={vi.fn()}
onStart={vi.fn()}
/>,
);
expect(screen.getByText(/剩余空间不足/)).toBeTruthy();
expect(screen.getByRole("button", { name: "开始更新索引" })).toHaveProperty(
"disabled",
true,
);
});
it("keeps a running switch visible and cancellable outside settings", async () => {
current = {
schema_version: 1,
phase: "running",
writes_paused: true,
scan_state: "done",
scan_error: null,
job,
last_finished: null,
};
render(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={vi.fn()}
onDiagnostics={vi.fn()}
/>,
);
expect(screen.getByText("正在更新记忆索引")).toBeTruthy();
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
"58",
);
await userEvent.click(screen.getByRole("button", { name: "取消更新" }));
expect(cancelCurrentSwitch).toHaveBeenCalledTimes(1);
});
it("shows startup index inspection without blocking the rest of the app", () => {
current = {
schema_version: 1,
phase: "scanning",
writes_paused: true,
scan_state: "scanning",
scan_error: null,
job: null,
last_finished: null,
};
render(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={vi.fn()}
onDiagnostics={vi.fn()}
/>,
);
expect(screen.getByText("正在检查本机的记忆搜索索引")).toBeTruthy();
expect(screen.getByText(/搜索仍可使用/)).toBeTruthy();
});
it("explains rollback safety and gives recovery actions", async () => {
current = {
schema_version: 1,
phase: "running",
writes_paused: true,
scan_state: "done",
scan_error: null,
job,
last_finished: null,
};
const onConfigureSearch = vi.fn();
const onDiagnostics = vi.fn();
const view = render(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={onConfigureSearch}
onDiagnostics={onDiagnostics}
/>,
);
current = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: {
...job,
state: "rolled_back",
error_code: "embedding_service_failed",
progress: { ...job.progress, phase: "done" },
finished_at: NOW,
},
};
view.rerender(
<EmbeddingSwitchGlobalStatus
onConfigureSearch={onConfigureSearch}
onDiagnostics={onDiagnostics}
/>,
);
await waitFor(() => expect(screen.getByText(/记忆没有丢失/)).toBeTruthy());
await userEvent.click(screen.getByRole("button", { name: "检查连接" }));
expect(onConfigureSearch).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,441 @@
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
} from "@one-memory/contracts";
import { ChevronDown, CircleAlert, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { deleteRetiredEmbeddingSpace } from "./api";
import { R6Button } from "./R6Controls";
import { useEmbeddingSwitch } from "./use-embedding-switch";
import "./embedding-switch.css";
function count(value: number | null | undefined) {
return new Intl.NumberFormat().format(value ?? 0);
}
function duration(seconds: number | null) {
if (seconds === null) return "暂时无法估算";
if (seconds < 60) return `约 ${Math.max(1, Math.round(seconds))} 秒`;
return `约 ${Math.max(1, Math.round(seconds / 60))} 分钟`;
}
function size(bytes: number) {
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
return `${Math.ceil(bytes / 1024 / 1024)} MB`;
}
const blockerCopy: Record<string, string> = {
embedding_probe_required: "请先测试新的搜索模型连接。",
embedding_dimensions_unknown:
"还无法确认新搜索模型的索引规格,请重新测试连接。",
embedding_switch_requires_migration:
"旧版本数据仍在升级,请等待升级完成后再更换。",
sdk_reembed_unavailable: "当前安装版本还不能安全更新记忆索引。",
insufficient_disk_space: "本机剩余空间不足,无法安全建立新索引。",
embedding_switch_running: "另一次索引更新正在进行。",
memory_lifecycle_busy: "记忆正在整合,请等待完成后再更换搜索模型。",
};
const failureCopy: Record<string, string> = {
writes_did_not_drain: "等待正在进行的记忆整理结束超时。",
embedding_service_failed: "搜索模型服务连续请求失败。",
embedding_dimensions_mismatch: "搜索服务返回的索引规格与测试结果不一致。",
embedding_verification_failed: "新的记忆索引没有通过完整性校验。",
target_profile_changed: "更新期间搜索模型配置发生了变化。",
embedding_switch_cancelled: "索引更新已取消。",
model_secret_unavailable: "无法读取搜索服务的 API Key。",
secure_storage_unavailable: "系统安全存储暂时不可用。",
embedding_switch_clients_busy: "记忆服务一直被占用,暂时无法完成切换。",
};
export function EmbeddingSwitchConfirmDialog({
plan,
busy,
errorCode,
onCancel,
onStart,
}: {
plan: EmbeddingSwitchPlan;
busy: boolean;
errorCode: string | null;
onCancel(): void;
onStart(): void;
}) {
const panel = useRef<HTMLElement>(null);
const blockers = plan.blockers.map(
(code) => blockerCopy[code] ?? "当前状态暂时不允许更换搜索模型。",
);
useEffect(() => {
const opener =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
panel.current
?.querySelector<HTMLButtonElement>("button:not(:disabled)")
?.focus();
return () => {
if (opener?.isConnected) opener.focus();
};
}, []);
return (
<div
className="r6-settings-dialog-layer embedding-switch-dialog-layer"
role="presentation"
>
<section
ref={panel}
className="r6-settings-dialog embedding-switch-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="embedding-switch-title"
onKeyDown={(event) => {
if (event.key === "Escape" && !busy) onCancel();
if (event.key !== "Tab" || !panel.current) return;
const controls = [
...panel.current.querySelectorAll<HTMLElement>(
"button:not(:disabled), summary, input:not(:disabled), select:not(:disabled)",
),
];
const first = controls[0];
const last = controls.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}}
>
<header>
<div>
<h2 id="embedding-switch-title">更新记忆搜索索引?</h2>
<p>
不同搜索模型建立的索引不能混用。为了继续找到已有记忆,需要重新建立索引。
</p>
</div>
</header>
<dl className="embedding-switch-impact">
<div>
<dt>已有记忆</dt>
<dd>
{count(plan.total_vectors)} 条
{plan.users && plan.users > 1
? ` · ${count(plan.users)} 位用户`
: ""}
</dd>
</div>
<div>
<dt>预计耗时</dt>
<dd>{duration(plan.estimate_s)}</dd>
</div>
<div>
<dt>远程请求</dt>
<dd>
{plan.target_local
? "不会产生"
: plan.requests === null
? "按服务商计费"
: `约 ${count(plan.requests)} 次`}
</dd>
</div>
</dl>
<div className="embedding-switch-safety">
<strong>更新期间仍可搜索记忆</strong>
<p>
MemHub
会继续使用原来的搜索模型。新对话仍会保存,更新完成后再整理成记忆。
</p>
<strong>失败不会影响已有数据</strong>
<p>
只有全部更新并校验通过后才会切换;失败或取消时仍使用原来的模型。
</p>
</div>
<details className="embedding-switch-details">
<summary>
<ChevronDown size={14} aria-hidden="true" />
查看技术详情
</summary>
<dl>
<div>
<dt>当前搜索模型</dt>
<dd>{plan.source.model ?? "旧版本索引"}</dd>
</div>
<div>
<dt>新的搜索模型</dt>
<dd>{plan.target.model ?? "待确认"}</dd>
</div>
<div>
<dt>索引维度</dt>
<dd>
{plan.source.dims ?? "未知"} → {plan.target.dims ?? "未知"}
</dd>
</div>
<div>
<dt>额外磁盘空间</dt>
<dd>{size(plan.disk_bytes_needed)}</dd>
</div>
<div>
<dt>旧索引保留</dt>
<dd>{plan.retention_days} 天</dd>
</div>
</dl>
</details>
{blockers.length > 0 ? (
<div className="embedding-switch-alert" role="alert">
<CircleAlert size={16} aria-hidden="true" />
<span>{blockers.join(" ")}</span>
</div>
) : null}
{errorCode ? (
<div className="embedding-switch-alert" role="alert">
<CircleAlert size={16} aria-hidden="true" />
<span>
{errorCode === "embedding_switch_plan_stale"
? "记忆状态发生了变化,请重新检查更换影响。"
: "索引更新暂时无法开始,请稍后重试。"}
</span>
</div>
) : null}
<footer>
<R6Button disabled={busy} onClick={onCancel}>
暂不更换
</R6Button>
<R6Button
variant="primary"
pending={busy}
disabled={blockers.length > 0}
onClick={onStart}
>
开始更新索引
</R6Button>
</footer>
</section>
</div>
);
}
function jobTitle(job: EmbeddingSwitchJob) {
if (job.kind === "merge") return "正在整理旧版本留下的搜索索引";
if (job.state === "preparing") return "正在准备更新记忆索引";
if (job.state === "reembedding") return "正在更新记忆索引";
if (job.state === "verifying") return "正在校验新的记忆索引";
if (job.state === "committing") return "正在启用新的搜索模型";
if (job.state === "failed") return "更新没有完成,正在恢复原设置";
return "记忆索引更新";
}
function canCancel(job: EmbeddingSwitchJob) {
return ["preparing", "reembedding", "verifying"].includes(job.state);
}
function ActiveSwitchBanner({
current,
onCancel,
cancelling,
}: {
current: EmbeddingSwitchCurrent;
onCancel(): void;
cancelling: boolean;
}) {
const job = current.job;
if (!job && current.phase !== "scanning") return null;
const percent = job?.progress.percent ?? 0;
return (
<aside className="embedding-switch-strip" aria-live="polite">
<div className="embedding-switch-strip-copy">
<strong>{job ? jobTitle(job) : "正在检查本机的记忆搜索索引"}</strong>
<span>
{job?.state === "failed"
? "原来的搜索模型仍在使用,记忆没有丢失。"
: job
? `${Math.round(percent)}%${
job.progress.eta_s === null
? ""
: ` · ${duration(job.progress.eta_s)}`
}`
: "完成前暂不整理新记忆,搜索仍可使用。"}
</span>
</div>
{job ? (
<div
className="embedding-switch-progress"
role="progressbar"
aria-label="记忆索引更新进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<span style={{ width: `${Math.max(2, percent)}%` }} />
</div>
) : null}
{job && canCancel(job) ? (
<R6Button variant="quiet" pending={cancelling} onClick={onCancel}>
取消更新
</R6Button>
) : null}
</aside>
);
}
export function EmbeddingSwitchGlobalStatus({
onConfigureSearch,
onDiagnostics,
}: {
onConfigureSearch(): void;
onDiagnostics(): void;
}) {
const controller = useEmbeddingSwitch();
const [recent, setRecent] = useState<EmbeddingSwitchJob | null>(null);
const [cleaning, setCleaning] = useState(false);
const [cleanupError, setCleanupError] = useState(false);
const previousJob = useRef<string | null>(null);
const alive = useRef(true);
useEffect(
() => () => {
alive.current = false;
},
[],
);
useEffect(() => {
const open = controller.current?.job?.job_id ?? null;
if (
previousJob.current &&
!open &&
controller.current?.last_finished?.job_id === previousJob.current
) {
setRecent(controller.current.last_finished);
}
previousJob.current = open;
}, [controller.current]);
return (
<>
{controller.current ? (
<ActiveSwitchBanner
current={controller.current}
cancelling={controller.busy === "cancel"}
onCancel={() => void controller.cancelCurrentSwitch()}
/>
) : null}
{controller.refreshFailed && controller.supported === true ? (
<aside className="embedding-switch-outcome is-error" role="status">
<div>
<strong>索引状态暂时无法读取</strong>
<span>当前显示可能不是最新状态,可以继续使用已有记忆。</span>
</div>
<R6Button variant="quiet" onClick={() => void controller.refresh()}>
重新读取
</R6Button>
</aside>
) : null}
{controller.current &&
["failed", "unavailable"].includes(controller.current.scan_state) ? (
<aside className="embedding-switch-outcome is-error" role="alert">
<div>
<strong>旧版本搜索索引检查未完成</strong>
<span>
{controller.current.writes_paused
? "新的记忆整理暂时暂停,已有记忆仍可搜索。"
: "记忆整理已经恢复;可以继续使用,诊断中保留了失败原因。"}
</span>
</div>
<R6Button variant="quiet" onClick={onDiagnostics}>
查看诊断
</R6Button>
</aside>
) : null}
{recent?.state === "done" ? (
<aside
className="embedding-switch-outcome"
role="status"
aria-live="polite"
>
<div>
<strong>搜索模型已更换</strong>
<span>暂停期间保存的新对话正在整理成记忆。</span>
</div>
<div className="embedding-switch-outcome-actions">
{recent.source.space_id ? (
<R6Button
variant="quiet"
pending={cleaning}
onClick={() => {
if (!recent.source.space_id) return;
setCleaning(true);
setCleanupError(false);
void deleteRetiredEmbeddingSpace(recent.source.space_id)
.then(() => {
if (alive.current) setRecent(null);
})
.catch(() => {
if (alive.current) setCleanupError(true);
})
.finally(() => {
if (alive.current) setCleaning(false);
});
}}
>
清理旧索引
</R6Button>
) : null}
<R6Button
variant="quiet"
aria-label="关闭完成提示"
onClick={() => setRecent(null)}
>
<X size={16} aria-hidden="true" />
</R6Button>
</div>
</aside>
) : null}
{cleanupError ? (
<p className="embedding-switch-cleanup-error" role="alert">
旧索引暂时无法清理,MemHub 会在保留期结束后自动重试。
</p>
) : null}
{recent?.state === "rolled_back" ? (
<aside
className="embedding-switch-outcome is-error"
role="alert"
aria-live="assertive"
>
<div>
<strong>索引更新没有完成</strong>
<span>
{failureCopy[recent.error_code ?? ""]
? `${failureCopy[recent.error_code ?? ""]} 仍在使用原来的搜索模型,记忆没有丢失。`
: "仍在使用原来的搜索模型,记忆没有丢失。"}
</span>
</div>
<div className="embedding-switch-outcome-actions">
<R6Button variant="quiet" onClick={onDiagnostics}>
查看诊断
</R6Button>
<R6Button variant="quiet" onClick={onConfigureSearch}>
检查连接
</R6Button>
<R6Button
variant="quiet"
aria-label="关闭失败提示"
onClick={() => setRecent(null)}
>
<X size={16} aria-hidden="true" />
</R6Button>
</div>
</aside>
) : null}
</>
);
}
+8 -2
View File
@@ -1494,7 +1494,7 @@ function message(reason: unknown, t: Translate): string {
reason.message === "memory_answer_context_changed"
? reason.message
: apiErrorCode(reason);
const labels: Record<string, Parameters<Translate>[0]> = {
const labels: Record<string, string> = {
memory_answer_context_changed:
"memory:记忆内容或使用权限已变化,回答已停止。请重新提问。",
memory_source_revoked: "memory:这条记忆的来源授权已撤回,暂时不能读取。",
@@ -1507,8 +1507,14 @@ function message(reason: unknown, t: Translate): string {
memory_request_conflict: "memory:这次操作与已完成的请求不一致。",
request_validation_failed: "memory:输入内容不符合要求。",
local_store_unavailable: "memory:本地记忆库正忙,请稍后重试。",
embedding_unavailable:
"memory:搜索服务暂时不可用,无法检索记忆。请到设置中检查记忆搜索配置。",
embedding_switch_in_progress:
"memory:正在更新记忆索引,完成后才能修改或删除记忆。搜索仍可使用。",
embedding_space_profile_missing:
"memory:当前搜索模型配置已不存在,请到设置中重新配置记忆搜索。",
};
if (code && labels[code]) return t(labels[code]);
if (code && labels[code]) return t(labels[code] as Parameters<Translate>[0]);
// No code at all means the request never reached the core — usually because
// it is still starting. Saying only "failed" left the one case that resolves
// itself looking like the ones that do not.
+187 -1
View File
@@ -1,6 +1,12 @@
// @vitest-environment jsdom
import type { ModelProbeResult, ModelProfile } from "@one-memory/contracts";
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
ModelProbeResult,
ModelProfile,
} from "@one-memory/contracts";
import {
act,
cleanup,
@@ -66,6 +72,10 @@ const probeProfile = vi.fn();
const activate = vi.fn();
const remove = vi.fn();
const update = vi.fn();
const planActivation = vi.fn();
const startPlannedSwitch = vi.fn();
let switchPlan: EmbeddingSwitchPlan | null = null;
let switchCurrent: EmbeddingSwitchCurrent | null = null;
it("explains an insufficient model balance with the required recovery", () => {
expect(probeLabel("insufficient_balance")).toBe(
@@ -114,6 +124,71 @@ const preset: ModelProfile = {
has_api_key: false,
disable_thinking: true,
};
const migrationPlan: EmbeddingSwitchPlan = {
needed: true,
reason: "embedding_identity_changed",
plan_token: "plan-token",
source: {
space_id: "old-space",
model: "old-search",
dims: 512,
collection_base: "memory",
vectors: 12,
},
target: {
space_id: null,
model: "new-search",
dims: 1024,
collection_base: "",
vectors: null,
},
target_profile_id: profile.profile_id,
target_revision: profile.revision,
target_provider_kind: "openai_compatible",
target_local: false,
sources: [],
total_vectors: 12,
users: 1,
graph_vectors: null,
estimate_s: 10,
requests: 1,
disk_bytes_needed: 1024,
disk_bytes_free: 4096,
retention_days: 14,
blockers: [],
};
const oldFinishedJob: EmbeddingSwitchJob = {
job_id: "old-job",
kind: "switch",
state: "done",
source: migrationPlan.source,
target: { ...migrationPlan.target, space_id: "new-space", vectors: 12 },
stray_space_ids: [],
target_profile_id: profile.profile_id,
target_revision: profile.revision,
requested_by: "app",
progress: {
phase: "done",
done: 12,
total: 12,
percent: 100,
embedded: 12,
reused: 0,
kept: 0,
role: null,
source_index: 0,
sources: 1,
rate_per_s: null,
eta_s: 0,
updated_at: "2026-11-03T09:00:00Z",
},
cancel_requested: false,
error_code: null,
attempt: 1,
created_at: "2026-11-03T09:00:00Z",
updated_at: "2026-11-03T09:00:01Z",
finished_at: "2026-11-03T09:00:01Z",
};
/** Re-renders on demand so a cleared journal is picked up, like a real refresh. */
function ProbeRecoveryHarness({
@@ -151,6 +226,19 @@ vi.mock("./use-model-profiles", () => ({
update,
}),
}));
vi.mock("./use-embedding-switch", () => ({
useEmbeddingSwitch: () => ({
current: switchCurrent,
plan: switchPlan,
busy: null,
errorCode: null,
planActivation,
startPlannedSwitch,
shouldUseLegacyActivation: () => false,
clearPlan: vi.fn(),
clearError: vi.fn(),
}),
}));
describe("ModelSettings", () => {
it("exposes distinct edit, add and test actions from the same owner without activating", async () => {
@@ -187,8 +275,14 @@ describe("ModelSettings", () => {
probes = {};
pendingProbe = null;
profiles = [profile];
switchPlan = null;
switchCurrent = null;
create.mockResolvedValue(profile);
update.mockResolvedValue({ ...preset, has_api_key: true, revision: 2 });
planActivation.mockResolvedValue({
needed: false,
reason: "same_embedding",
});
});
it("submits a generation-only profile and leaves embedding to the builtin", async () => {
@@ -399,6 +493,62 @@ describe("ModelSettings", () => {
});
});
it("focuses generation without exposing or patching the search lane", async () => {
update.mockResolvedValue(profile);
render(<ModelSettings focusSection="generation" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByRole("heading", { name: "记忆整理" })).toBeTruthy();
expect(screen.queryByLabelText("搜索模型")).toBeNull();
expect(screen.getByLabelText("生成模型名")).toHaveProperty(
"value",
profile.model,
);
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
});
});
it("does not repair a hidden legacy search endpoint while editing generation", async () => {
const legacy = {
...profile,
embedding_provider_kind: "openai_compatible" as const,
embedding_model: "remote-search",
embedding_base_url: null,
};
profiles = [legacy];
update.mockResolvedValue(legacy);
render(<ModelSettings focusSection="generation" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
await userEvent.type(screen.getByLabelText("生成模型名"), "-new");
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
model: "memory-model-new",
});
});
it("focuses search on the recommended builtin without exposing or patching generation", async () => {
update.mockResolvedValue(profile);
render(<ModelSettings focusSection="embedding" />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
expect(screen.getByRole("heading", { name: "记忆搜索" })).toBeTruthy();
expect(screen.queryByLabelText("生成模型名")).toBeNull();
expect(screen.getByLabelText("搜索模型")).toHaveProperty("value", "local");
expect(screen.getByText(/无需密钥/)).toBeTruthy();
expect(screen.getByText("技术类型:Embedding")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "保存修改" }));
expect(update).toHaveBeenCalledExactlyOnceWith(profile.profile_id, {
expected_revision: profile.revision,
});
});
it("does not replace an edit draft with a newer external profile revision", async () => {
const view = render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "编辑配置" }));
@@ -661,9 +811,45 @@ describe("ModelSettings", () => {
expect(container.textContent).not.toContain("secret-value");
await user.click(screen.getByRole("button", { name: "设为当前模型" }));
expect(planActivation).toHaveBeenCalledWith("profile-1", 1);
expect(activate).toHaveBeenCalledWith("profile-1");
});
it("asks before activation when the search index must be rebuilt", async () => {
profiles = [
{
...profile,
capabilities: {
...profile.capabilities,
endpoint_reachable: true,
model_available: true,
embedding_available: true,
},
},
];
planActivation.mockImplementationOnce(async () => {
switchPlan = migrationPlan;
return migrationPlan;
});
switchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: oldFinishedJob,
};
render(<ModelSettings />);
await userEvent.click(screen.getByRole("button", { name: "设为当前模型" }));
expect(
await screen.findByRole("heading", { name: "更新记忆搜索索引?" }),
).toBeTruthy();
expect(activate).not.toHaveBeenCalled();
});
it("hands a successful save to its card, clears only the key, and never tests or activates", async () => {
profiles = [];
const user = userEvent.setup();
+504 -348
View File
@@ -16,9 +16,11 @@ import {
useRef,
useState,
} from "react";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import { OptionMenu } from "./OptionMenu";
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { useEmbeddingSwitch } from "./use-embedding-switch";
import { useModelProfiles } from "./use-model-profiles";
import "./model-setup-next.css";
@@ -216,6 +218,8 @@ export interface ModelSettingsActions {
test(profileId: string): Promise<unknown>;
}
export type ModelSettingsFocusSection = "generation" | "embedding";
/** What the owner asked for before the fence said no. */
type ModelOpenIntent =
| { kind: "add" }
@@ -225,15 +229,18 @@ export function ModelSettings({
controlsRef,
onProfilesChanged,
compactSingle = false,
focusSection,
onSetupComplete,
}: {
controlsRef?: Ref<ModelSettingsActions>;
onProfilesChanged?(): void;
compactSingle?: boolean;
focusSection?: ModelSettingsFocusSection;
onSetupComplete?(): void;
} = {}) {
const { text } = useOnboardingTranslation();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [provider, setProvider] = useState<ProviderKind>("openai_compatible");
const [providerPreset, setProviderPreset] = useState("custom");
const [advancedEmbedding, setAdvancedEmbedding] = useState(false);
@@ -263,6 +270,9 @@ export function ModelSettings({
null,
);
const [setupError, setSetupError] = useState<string | null>(null);
const [switchTarget, setSwitchTarget] = useState<ModelProfile | null>(null);
const [switchJobId, setSwitchJobId] = useState<string | null>(null);
const settledSwitchJob = useRef<string | null>(null);
const flight = useRef(false);
const alive = useRef(true);
const queuedOpen = useRef<ModelOpenIntent | null>(null);
@@ -274,6 +284,41 @@ export function ModelSettings({
alive.current = false;
};
}, []);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
if (
!switchTarget ||
!switchJobId ||
!finished ||
finished.job_id !== switchJobId ||
settledSwitchJob.current === finished.job_id
)
return;
settledSwitchJob.current = finished.job_id;
if (finished.state === "done") {
void models.refreshProfiles();
setSetupError(null);
setSwitchTarget(null);
setSwitchJobId(null);
onProfilesChanged?.();
return;
}
if (finished.state === "rolled_back") {
setSetupError(
finished.error_code === "embedding_switch_cancelled"
? "索引更新已取消,仍在使用原来的搜索模型。"
: "索引更新没有完成,仍在使用原来的搜索模型,记忆没有丢失。",
);
setSwitchTarget(null);
setSwitchJobId(null);
}
}, [
embeddingSwitch.current?.last_finished,
models.refreshProfiles,
onProfilesChanged,
switchJobId,
switchTarget,
]);
useLayoutEffect(() => {
if (saved) savedCard.current?.focus({ preventScroll: true });
}, [saved]);
@@ -452,6 +497,43 @@ export function ModelSettings({
onProfilesChanged?.();
}, [profileSnapshot, onProfilesChanged]);
async function activateWithPlan(
profile: ModelProfile,
result: ModelProbeResult | null,
): Promise<boolean> {
setSetupPhase("activating");
const switchPlan = embeddingSwitch.shouldUseLegacyActivation()
? null
: await embeddingSwitch.planActivation(
profile.profile_id,
profile.revision,
);
if (!alive.current) return false;
if (!switchPlan && !embeddingSwitch.shouldUseLegacyActivation()) {
setSetupError("暂时无法检查搜索模型更换影响,配置已保存,请稍后重试。");
return false;
}
if (switchPlan?.needed) {
setSwitchTarget(profile);
setSetupPhase(null);
return false;
}
const active = await models.activate(profile.profile_id);
if (!alive.current) return false;
if (
!active ||
active.profile_id !== profile.profile_id ||
!active.is_active ||
!ready(active, result)
) {
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
return false;
}
if (profile.name !== PRESET_PROFILE_NAME) setSaved(active);
onSetupComplete?.();
return true;
}
async function finishSetup(profile: ModelProfile): Promise<boolean> {
setSetupPhase("testing");
const result = await models.probeProfile(profile.profile_id);
@@ -487,21 +569,24 @@ export function ModelSettings({
setFields(probeFieldErrors(result));
return false;
}
setSetupPhase("activating");
const active = await models.activate(profile.profile_id);
if (!alive.current) return false;
if (
!active ||
active.profile_id !== profile.profile_id ||
!active.is_active ||
!ready(active, result)
) {
setSetupError("配置已保存,尚未确认启用成功。请核对配置后重试。");
return false;
return activateWithPlan(profile, result);
}
async function activateTested(profile: ModelProfile) {
const result = models.probeFor(profile);
if (!isReady(profile) || flight.current || locked) return;
flight.current = true;
setSaving(true);
setSetupError(null);
try {
await activateWithPlan(profile, result);
} finally {
flight.current = false;
if (alive.current) {
setSaving(false);
setSetupPhase(null);
}
}
if (profile.name !== PRESET_PROFILE_NAME) setSaved(active);
onSetupComplete?.();
return true;
}
async function resumeSetup(profile: ModelProfile, key?: string) {
@@ -572,12 +657,14 @@ export function ModelSettings({
)
return;
const errors: Partial<Record<FieldName, string>> = {};
if (!model.trim()) errors.model = "请填写生成模型名。";
if (!validEndpoint(baseUrl))
errors["base-url"] = "请填写完整的 http:// 或 https:// 接口地址。";
const credentialError = keyError(apiKey, keyRequired);
if (credentialError) errors["api-key"] = credentialError;
if (embeddingMode === "custom") {
if (focusSection !== "embedding") {
if (!model.trim()) errors.model = "请填写生成模型名。";
if (!validEndpoint(baseUrl))
errors["base-url"] = "请填写完整的 http:// 或 https:// 接口地址。";
const credentialError = keyError(apiKey, keyRequired);
if (credentialError) errors["api-key"] = credentialError;
}
if (focusSection !== "generation" && embeddingMode === "custom") {
if (!embeddingModel.trim())
errors["embedding-model"] = "请填写向量模型名。";
if (!validEndpoint(embeddingBaseUrl))
@@ -619,14 +706,16 @@ export function ModelSettings({
? { expected_revision: editing.revision }
: { expected_revision: 0 };
if (editing) {
if (input.name !== editing.name) patch.name = input.name;
if (input.model !== editing.model) patch.model = input.model;
if (input.base_url !== editing.base_url)
patch.base_url = input.base_url;
if (input.disable_thinking !== editing.disable_thinking)
patch.disable_thinking = input.disable_thinking;
if (apiKey) patch.api_key = apiKey;
if (embeddingMode === "custom") {
if (focusSection !== "embedding") {
if (input.name !== editing.name) patch.name = input.name;
if (input.model !== editing.model) patch.model = input.model;
if (input.base_url !== editing.base_url)
patch.base_url = input.base_url;
if (input.disable_thinking !== editing.disable_thinking)
patch.disable_thinking = input.disable_thinking;
if (apiKey) patch.api_key = apiKey;
}
if (focusSection !== "generation" && embeddingMode === "custom") {
if (embeddingModel !== editing.embedding_model)
patch.embedding_model = embeddingModel;
if (embeddingProvider !== editing.embedding_provider_kind)
@@ -636,6 +725,7 @@ export function ModelSettings({
if (embeddingBaseUrl !== (editing.embedding_base_url ?? ""))
patch.embedding_base_url = embeddingBaseUrl;
} else if (
focusSection !== "generation" &&
embeddingModeChanged &&
editing.embedding_provider_kind !== BUILTIN_EMBEDDING_KIND
) {
@@ -699,11 +789,23 @@ export function ModelSettings({
<div className="section-heading">
<div>
<p className="section-index">02</p>
<h2 id="models-title">{text("记忆模型")}</h2>
<h2 id="models-title">
{text(
focusSection === "generation"
? "记忆整理"
: focusSection === "embedding"
? "记忆搜索"
: "记忆模型",
)}
</h2>
</div>
<p>
{text(
"只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2 分开选模型。",
focusSection === "generation"
? "配置用于理解会话、整理记忆的模型。"
: focusSection === "embedding"
? "推荐使用本地内置搜索;需要连接自定义服务时再展开配置。"
: "只需配置生成模型。向量嵌入已内置在本机,不为 System 1、System 2 分开选模型。",
)}
</p>
</div>
@@ -956,326 +1058,359 @@ export function ModelSettings({
{text("刷新配置列表")}
</button>
)}
<div className="model-form-field">
{text("服务商预设")}
<OptionMenu
ariaLabel={text("服务商预设")}
describedBy="model-preset-help"
value={providerPreset}
disabled={formLocked}
options={[
...PROVIDER_PRESETS.map((item) => ({
value: item.id,
label: item.label,
})),
{ value: "custom", label: text("自定义兼容服务") },
]}
onChange={(value) => {
const next = PROVIDER_PRESETS.find((item) => item.id === value);
setProviderPreset(value);
if (!next) return;
setProvider(next.provider);
setBaseUrl(next.baseUrl);
setModel(next.model);
setApiKey("");
setDisableThinking(true);
if (editing) {
setName(next.label);
setNameEdited(true);
} else if (!nameEdited) {
setName(suggestedName(next.model, next.provider));
}
setFields({});
setSaveError(null);
}}
/>
<small id="model-preset-help">
{text(
"只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。 请对照服务商的模型列表核对后再保存。",
)}
</small>
</div>
<label>
{text("协议")}
<select
name="provider"
aria-label={text("协议")}
aria-describedby="model-provider-help"
value={provider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as ProviderKind;
setProvider(next);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(model, next));
}}
>
{/* Only what the backend will actually activate. These used to be
* listed and disabled, which put two dead ends on screen and made
* the reader parse a parenthesis to find out they were dead. */}
{PROVIDERS.filter((item) => item.activatable).map((item) => (
<option key={item.kind} value={item.kind}>
{text(item.label)}
</option>
))}
</select>
<small id="model-provider-help">
{text("与模型服务提供的接口协议保持一致。")}
</small>
</label>
<label>
{text("配置名称")}
<input
name="name"
aria-label={text("配置名称")}
aria-describedby="model-name-help"
autoComplete="off"
maxLength={128}
value={name}
disabled={formLocked}
onChange={(event) => {
setNameEdited(true);
setName(event.target.value);
}}
/>
<small id="model-name-help">
{text("用于区分配置,不影响模型请求。")}
</small>
</label>
<label>
{text("生成模型名")}
<input
name="model"
aria-label={text("生成模型名")}
aria-describedby={`model-identifier-help${fields.model ? " model-field-error" : ""}`}
aria-invalid={!!fields.model}
autoComplete="off"
required
maxLength={256}
value={model}
disabled={formLocked}
onChange={(event) => {
setModel(event.target.value);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(event.target.value));
}}
placeholder={text("例如:gpt-4o-mini…")}
/>
<small id="model-identifier-help">
{text("填写服务方提供的完整模型标识。")}
</small>
{fields.model && (
<small id="model-field-error" className="model-field-error">
{text(fields.model)}
</small>
)}
</label>
{/* Disclosure only changes visibility; mode determines what is saved. */}
<div className="model-advanced">
<button
type="button"
className="quiet"
aria-expanded={advancedEmbedding}
disabled={formLocked}
onClick={() => setAdvancedEmbedding(!advancedEmbedding)}
>
{text("高级:自行配置向量模型")}
</button>
{advancedEmbedding ? (
<>
<label>
{text("向量配置模式")}
<select
name="embedding-mode"
value={embeddingMode}
disabled={formLocked}
onChange={(event) => {
setEmbeddingMode(
event.target.value as "local" | "custom",
);
setEmbeddingModeChanged(true);
setFields((previous) => ({
...previous,
"embedding-model": undefined,
"embedding-base-url": undefined,
}));
}}
>
<option value="local">{text("本地内置")}</option>
<option value="custom">{text("自定义向量模型")}</option>
</select>
</label>
{embeddingMode === "custom" ? (
<>
<label>
{text("向量模型名")}
<input
name="embedding-model"
autoComplete="off"
required
maxLength={256}
value={embeddingModel}
disabled={formLocked}
aria-invalid={!!fields["embedding-model"]}
aria-describedby={
fields["embedding-model"]
? "model-embedding-error"
: undefined
}
onChange={(event) =>
setEmbeddingModel(event.target.value)
}
placeholder={text("例如:text-embedding-3-small…")}
/>
{fields["embedding-model"] && (
<small
id="model-embedding-error"
className="model-field-error"
>
{text(fields["embedding-model"])}
</small>
)}
</label>
<label>
{text("向量模型来源")}
<select
name="embedding-provider"
value={embeddingProvider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as
| "openai_compatible"
| "ollama";
setEmbeddingProvider(next);
}}
>
<option value="ollama">{text("本机 Ollama")}</option>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 API Key)")}
</option>
</select>
</label>
<label>
{text("向量模型 Base URL")}
<input
name="embedding-base-url"
type="url"
inputMode="url"
autoComplete="off"
required
value={embeddingBaseUrl}
disabled={formLocked}
placeholder={
embeddingProvider === "ollama"
? "http://127.0.0.1:11434"
: baseUrl
}
aria-invalid={!!fields["embedding-base-url"]}
aria-describedby={
fields["embedding-base-url"]
? "model-embedding-url-error"
: undefined
}
onChange={(event) =>
setEmbeddingBaseUrl(event.target.value)
}
/>
{fields["embedding-base-url"] && (
<small
id="model-embedding-url-error"
className="model-field-error"
>
{text(fields["embedding-base-url"])}
</small>
)}
</label>
{embeddingProvider === "openai_compatible" ? (
<small>
{text("为避免凭据外泄,向量地址必须与生成模型同源。")}
</small>
) : null}
</>
) : (
<small>
{text("使用内置本地向量模型;不会发送内容给远程向量服务。")}
{focusSection !== "embedding" && (
<>
<div className="model-form-field">
{text("服务商预设")}
<OptionMenu
ariaLabel={text("服务商预设")}
describedBy="model-preset-help"
value={providerPreset}
disabled={formLocked}
options={[
...PROVIDER_PRESETS.map((item) => ({
value: item.id,
label: item.label,
})),
{ value: "custom", label: text("自定义兼容服务") },
]}
onChange={(value) => {
const next = PROVIDER_PRESETS.find(
(item) => item.id === value,
);
setProviderPreset(value);
if (!next) return;
setProvider(next.provider);
setBaseUrl(next.baseUrl);
setModel(next.model);
setApiKey("");
setDisableThinking(true);
if (editing) {
setName(next.label);
setNameEdited(true);
} else if (!nameEdited) {
setName(suggestedName(next.model, next.provider));
}
setFields({});
setSaveError(null);
}}
/>
<small id="model-preset-help">
{text(
"只是帮你填好接口地址和一个模型名,不代表这个模型当前可用。 请对照服务商的模型列表核对后再保存。",
)}
</small>
</div>
<label>
{text("协议")}
<select
name="provider"
aria-label={text("协议")}
aria-describedby="model-provider-help"
value={provider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as ProviderKind;
setProvider(next);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(model, next));
}}
>
{/* Only what the backend will actually activate. These used to be
* listed and disabled, which put two dead ends on screen and made
* the reader parse a parenthesis to find out they were dead. */}
{PROVIDERS.filter((item) => item.activatable).map((item) => (
<option key={item.kind} value={item.kind}>
{text(item.label)}
</option>
))}
</select>
<small id="model-provider-help">
{text("与模型服务提供的接口协议保持一致。")}
</small>
</label>
<label>
{text("配置名称")}
<input
name="name"
aria-label={text("配置名称")}
aria-describedby="model-name-help"
autoComplete="off"
maxLength={128}
value={name}
disabled={formLocked}
onChange={(event) => {
setNameEdited(true);
setName(event.target.value);
}}
/>
<small id="model-name-help">
{text("用于区分配置,不影响模型请求。")}
</small>
</label>
<label>
{text("生成模型名")}
<input
name="model"
aria-label={text("生成模型名")}
aria-describedby={`model-identifier-help${fields.model ? " model-field-error" : ""}`}
aria-invalid={!!fields.model}
autoComplete="off"
required
maxLength={256}
value={model}
disabled={formLocked}
onChange={(event) => {
setModel(event.target.value);
setProviderPreset("custom");
if (!nameEdited) setName(suggestedName(event.target.value));
}}
placeholder={text("例如:gpt-4o-mini…")}
/>
<small id="model-identifier-help">
{text("填写服务方提供的完整模型标识。")}
</small>
{fields.model && (
<small id="model-field-error" className="model-field-error">
{text(fields.model)}
</small>
)}
</>
) : null}
{!advancedEmbedding && embeddingMode === "custom" ? (
<p className="model-help">
{text("自定义向量")}: {embeddingModel || text("尚未填写模型")} ·{" "}
{embeddingBaseUrl || text("尚未填写地址")}
</p>
) : null}
</div>
<label>
Base URL
<input
name="base-url"
aria-label="Base URL"
aria-describedby={`model-url-help${fields["base-url"] ? " model-url-error" : ""}`}
aria-invalid={!!fields["base-url"]}
type="url"
inputMode="url"
autoComplete="off"
required={provider !== "hy_default"}
disabled={formLocked || provider === "hy_default"}
value={baseUrl}
onChange={(event) => {
setBaseUrl(event.target.value);
setProviderPreset("custom");
}}
/>
<small id="model-url-help">
{text("填写服务接口地址,不是聊天网页地址。")}
</small>
{fields["base-url"] && (
<small id="model-url-error" className="model-field-error">
{text(fields["base-url"])}
</small>
)}
</label>
<label>
API Key{keyRequired ? "" : text("(可选)")}
<input
name="api-key"
aria-label={`API Key${keyRequired ? "" : text("(可选)")}`}
aria-describedby={`model-key-help${fields["api-key"] ? " model-key-error" : ""}`}
aria-invalid={!!fields["api-key"]}
type="password"
autoComplete="off"
required={keyRequired}
value={apiKey}
disabled={formLocked}
onChange={(event) => setApiKey(event.target.value)}
placeholder={text("保存后不会再次显示…")}
/>
<small id="model-key-help">
{text(
editing && !endpointMoved
? "留空保留已保存密钥;填写新密钥后需重新测试和启用。"
: editing && keyRequired
? "换了服务商后需要填写新的 API Key,已保存的密钥不会带到新地址。"
: "保存在系统安全存储中,不随安装包分发。",
</label>
</>
)}
{/* Disclosure only changes visibility; mode determines what is saved. */}
{focusSection !== "generation" && (
<div className="model-advanced">
{focusSection === "embedding" && (
<div className="model-search-intro">
<strong>{text("搜索方式")}</strong>
<p>
{text(
"本地内置适合大多数人:无需密钥,内容不会发送到远程向量服务。",
)}
</p>
</div>
)}
</small>
{fields["api-key"] && (
<small id="model-key-error" className="model-field-error">
{text(fields["api-key"])}
</small>
)}
</label>
<label>
<input
name="disable-thinking"
type="checkbox"
checked={disableThinking}
disabled={formLocked}
onChange={(event) => setDisableThinking(event.target.checked)}
/>
{text("关闭思考链(向支持的厂商下发 thinking-off 参数)")}
</label>
{focusSection !== "embedding" && (
<button
type="button"
className="quiet"
aria-expanded={advancedEmbedding}
disabled={formLocked}
onClick={() => setAdvancedEmbedding(!advancedEmbedding)}
>
{text("高级:自行配置向量模型")}
</button>
)}
{focusSection === "embedding" || advancedEmbedding ? (
<>
<label>
{text(
focusSection === "embedding"
? "搜索模型"
: "向量配置模式",
)}
<select
name="embedding-mode"
value={embeddingMode}
disabled={formLocked}
onChange={(event) => {
setEmbeddingMode(
event.target.value as "local" | "custom",
);
setEmbeddingModeChanged(true);
setFields((previous) => ({
...previous,
"embedding-model": undefined,
"embedding-base-url": undefined,
}));
}}
>
<option value="local">{text("本地内置(推荐)")}</option>
<option value="custom">{text("自定义搜索模型")}</option>
</select>
</label>
<small>{text("技术类型:Embedding")}</small>
{embeddingMode === "custom" ? (
<>
<label>
{text("向量模型名")}
<input
name="embedding-model"
autoComplete="off"
required
maxLength={256}
value={embeddingModel}
disabled={formLocked}
aria-invalid={!!fields["embedding-model"]}
aria-describedby={
fields["embedding-model"]
? "model-embedding-error"
: undefined
}
onChange={(event) =>
setEmbeddingModel(event.target.value)
}
placeholder={text("例如:text-embedding-3-small…")}
/>
{fields["embedding-model"] && (
<small
id="model-embedding-error"
className="model-field-error"
>
{text(fields["embedding-model"])}
</small>
)}
</label>
<label>
{text("向量模型来源")}
<select
name="embedding-provider"
value={embeddingProvider}
disabled={formLocked}
onChange={(event) => {
const next = event.target.value as
| "openai_compatible"
| "ollama";
setEmbeddingProvider(next);
}}
>
<option value="ollama">{text("本机 Ollama")}</option>
<option value="openai_compatible">
{text("同源 OpenAI-compatible(共用 API Key)")}
</option>
</select>
</label>
<label>
{text("向量模型 Base URL")}
<input
name="embedding-base-url"
type="url"
inputMode="url"
autoComplete="off"
required
value={embeddingBaseUrl}
disabled={formLocked}
placeholder={
embeddingProvider === "ollama"
? "http://127.0.0.1:11434"
: baseUrl
}
aria-invalid={!!fields["embedding-base-url"]}
aria-describedby={
fields["embedding-base-url"]
? "model-embedding-url-error"
: undefined
}
onChange={(event) =>
setEmbeddingBaseUrl(event.target.value)
}
/>
{fields["embedding-base-url"] && (
<small
id="model-embedding-url-error"
className="model-field-error"
>
{text(fields["embedding-base-url"])}
</small>
)}
</label>
{embeddingProvider === "openai_compatible" ? (
<small>
{text("为避免凭据外泄,向量地址必须与生成模型同源。")}
</small>
) : null}
</>
) : (
<small>
{text(
"使用内置本地向量模型;不会发送内容给远程向量服务。",
)}
</small>
)}
</>
) : null}
{focusSection !== "embedding" &&
!advancedEmbedding &&
embeddingMode === "custom" ? (
<p className="model-help">
{text("自定义向量")}: {embeddingModel || text("尚未填写模型")}{" "}
· {embeddingBaseUrl || text("尚未填写地址")}
</p>
) : null}
</div>
)}
{focusSection !== "embedding" && (
<>
<label>
Base URL
<input
name="base-url"
aria-label="Base URL"
aria-describedby={`model-url-help${fields["base-url"] ? " model-url-error" : ""}`}
aria-invalid={!!fields["base-url"]}
type="url"
inputMode="url"
autoComplete="off"
required={provider !== "hy_default"}
disabled={formLocked || provider === "hy_default"}
value={baseUrl}
onChange={(event) => {
setBaseUrl(event.target.value);
setProviderPreset("custom");
}}
/>
<small id="model-url-help">
{text("填写服务接口地址,不是聊天网页地址。")}
</small>
{fields["base-url"] && (
<small id="model-url-error" className="model-field-error">
{text(fields["base-url"])}
</small>
)}
</label>
<label>
API Key{keyRequired ? "" : text("(可选)")}
<input
name="api-key"
aria-label={`API Key${keyRequired ? "" : text("(可选)")}`}
aria-describedby={`model-key-help${fields["api-key"] ? " model-key-error" : ""}`}
aria-invalid={!!fields["api-key"]}
type="password"
autoComplete="off"
required={keyRequired}
value={apiKey}
disabled={formLocked}
onChange={(event) => setApiKey(event.target.value)}
placeholder={text("保存后不会再次显示…")}
/>
<small id="model-key-help">
{text(
editing && !endpointMoved
? "留空保留已保存密钥;填写新密钥后需重新测试和启用。"
: editing && keyRequired
? "换了服务商后需要填写新的 API Key,已保存的密钥不会带到新地址。"
: "保存在系统安全存储中,不随安装包分发。",
)}
</small>
{fields["api-key"] && (
<small id="model-key-error" className="model-field-error">
{text(fields["api-key"])}
</small>
)}
</label>
<label>
<input
name="disable-thinking"
type="checkbox"
checked={disableThinking}
disabled={formLocked}
onChange={(event) => setDisableThinking(event.target.checked)}
/>
{text("关闭思考链(向支持的厂商下发 thinking-off 参数)")}
</label>
</>
)}
<div className="form-submit">
{!compactSingle && (
<button
@@ -1397,7 +1532,7 @@ export function ModelSettings({
<button
type="button"
disabled={locked || !isReady(profile)}
onClick={() => models.activate(profile.profile_id)}
onClick={() => void activateTested(profile)}
>
{text("设为当前模型")}
</button>
@@ -1421,6 +1556,27 @@ export function ModelSettings({
))}
</div>
)}
{embeddingSwitch.plan?.needed && switchTarget ? (
<EmbeddingSwitchConfirmDialog
plan={embeddingSwitch.plan}
busy={embeddingSwitch.busy === "start"}
errorCode={embeddingSwitch.errorCode}
onCancel={() => {
embeddingSwitch.clearPlan();
embeddingSwitch.clearError();
setSwitchTarget(null);
setSwitchJobId(null);
}}
onStart={() => {
void embeddingSwitch.startPlannedSwitch().then((job) => {
if (!job || !alive.current) return;
setSwitchJobId(job.job_id);
setSetupError(null);
onSetupComplete?.();
});
}}
/>
) : null}
</section>
);
}
+136 -72
View File
@@ -5,6 +5,7 @@ import type {
} from "@one-memory/contracts";
import { useEffect, useRef, useState } from "react";
import { getModelProfiles } from "./api";
import { EmbeddingSwitchConfirmDialog } from "./EmbeddingSwitchExperience";
import {
type OnboardingModelDraft,
OnboardingModelStep,
@@ -12,6 +13,7 @@ import {
import { useOnboardingTranslation } from "./onboarding-language";
import { PROVIDER_PRESETS } from "./provider-presets";
import { traceUiAction } from "./ui-trace";
import { useEmbeddingSwitch } from "./use-embedding-switch";
import { useModelProfiles } from "./use-model-profiles";
const defaultPreset = PROVIDER_PRESETS.find(
@@ -68,6 +70,7 @@ export function OnboardingModelController({
}) {
const { text, t } = useOnboardingTranslation();
const models = useModelProfiles();
const embeddingSwitch = useEmbeddingSwitch();
const [draft, setDraft] = useState(initial);
const [advanced, setAdvanced] = useState(false);
const [saved, setSaved] = useState<ModelProfile | null>(null);
@@ -82,6 +85,9 @@ export function OnboardingModelController({
state: "passed" | "failed" | "stale";
message: string;
} | null>(null);
const [switchPendingJobId, setSwitchPendingJobId] = useState<string | null>(
null,
);
const flight = useRef(false);
const draftTouched = useRef(false);
@@ -115,6 +121,27 @@ export function OnboardingModelController({
});
}, [models.profiles, saved]);
useEffect(() => {
const finished = embeddingSwitch.current?.last_finished;
if (
!switchPendingJobId ||
!finished ||
finished.job_id !== switchPendingJobId
)
return;
if (finished.state === "done") {
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
onEnabled?.();
} else if (finished.state === "rolled_back") {
flight.current = false;
setSwitchPendingJobId(null);
setPhase("idle");
setError("索引更新没有完成,仍在使用原来的搜索模型。请检查连接后重试。");
}
}, [embeddingSwitch.current?.last_finished, onEnabled, switchPendingJobId]);
function edit(patch: Partial<OnboardingModelDraft>) {
if (flight.current) return;
draftTouched.current = true;
@@ -273,6 +300,18 @@ export function OnboardingModelController({
setError(null);
const trace = traceUiAction("memory", "submit");
try {
const plan = embeddingSwitch.shouldUseLegacyActivation()
? null
: await embeddingSwitch.planActivation(
saved.profile_id,
saved.revision,
);
if (!plan && !embeddingSwitch.shouldUseLegacyActivation())
throw new Error("embedding_switch_plan_unconfirmed");
if (plan?.needed) {
trace.done("succeeded", { reason: "embedding_switch_confirmation" });
return;
}
const active = await models.activate(saved.profile_id);
if (!active?.is_active) throw new Error("model_activation_unconfirmed");
trace.done("succeeded");
@@ -287,77 +326,102 @@ export function OnboardingModelController({
}
return (
<OnboardingModelStep
draft={draft}
providers={[
...PROVIDER_PRESETS.map((preset) => ({
id: preset.id,
label: preset.label,
keyRequired: preset.provider !== "ollama",
supported: true,
})),
{
id: "custom",
label: "自定义兼容服务",
keyRequired: true,
supported: true,
},
]}
advancedOpen={advanced}
onAdvancedChange={setAdvanced}
onDraftChange={edit}
phase={phase}
capability={{ saveAndEnable: true }}
canSaveAndEnable={probe?.state === "passed" && !!saved}
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
inputsLocked={flight.current}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
value === "onboardingIntegerRange"
? t(
"onboardingIntegerRange",
key === "timeout_seconds"
? { min: 1, max: 600 }
: key === "max_concurrency"
? { min: 1, max: 32 }
: { min: 1024, max: 10_000_000 },
)
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
probe={probe}
profile={
saved
? {
profile_id: saved.profile_id,
revision: saved.revision,
has_configured_api_key:
saved.has_configured_api_key ?? saved.has_api_key,
credential_state: saved.credential_state,
}
: null
}
sourceNames={sourceNames}
importedConversationCount={importedConversationCount}
connectionSummary={
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
}
generationRecipient={draft.base_url || "尚未填写接收方"}
embeddingRecipient={
draft.embedding_mode === "local"
? "本机内置向量模型"
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
}
onSaveDraft={() => save(false)}
onTest={() => save(true)}
onSaveAndEnable={enable}
onBack={onBack}
onSkip={onSkip}
onActionError={() => setError("操作未确认,草稿已保留。")}
/>
<>
<OnboardingModelStep
draft={draft}
providers={[
...PROVIDER_PRESETS.map((preset) => ({
id: preset.id,
label: preset.label,
keyRequired: preset.provider !== "ollama",
supported: true,
})),
{
id: "custom",
label: "自定义兼容服务",
keyRequired: true,
supported: true,
},
]}
advancedOpen={advanced}
onAdvancedChange={setAdvanced}
onDraftChange={edit}
phase={phase}
capability={{ saveAndEnable: true }}
canSaveAndEnable={probe?.state === "passed" && !!saved}
enableActionLabel={saved?.is_active ? "继续使用当前模型" : undefined}
canTest={!models.pendingProbe && !models.probeStorageFailed}
canSaveDraft={!models.pendingCreation && !models.creationStorageFailed}
inputsLocked={flight.current}
fieldErrors={Object.fromEntries(
Object.entries(fields).map(([key, value]) => [
key,
value === "onboardingIntegerRange"
? t(
"onboardingIntegerRange",
key === "timeout_seconds"
? { min: 1, max: 600 }
: key === "max_concurrency"
? { min: 1, max: 32 }
: { min: 1024, max: 10_000_000 },
)
: text(value),
]),
)}
error={error ?? (models.error ? text(models.error) : null)}
probe={probe}
profile={
saved
? {
profile_id: saved.profile_id,
revision: saved.revision,
has_configured_api_key:
saved.has_configured_api_key ?? saved.has_api_key,
credential_state: saved.credential_state,
}
: null
}
sourceNames={sourceNames}
importedConversationCount={importedConversationCount}
connectionSummary={
connectionAlreadyApplied ? "所选连接已启用" : "本次未选择应用连接"
}
generationRecipient={draft.base_url || "尚未填写接收方"}
embeddingRecipient={
draft.embedding_mode === "local"
? "本机内置向量模型"
: draft.embedding_base_url || draft.base_url || "尚未填写接收方"
}
onSaveDraft={() => save(false)}
onTest={() => save(true)}
onSaveAndEnable={enable}
onBack={onBack}
onSkip={onSkip}
onActionError={() => setError("操作未确认,草稿已保留。")}
/>
{embeddingSwitch.plan?.needed && saved ? (
<EmbeddingSwitchConfirmDialog
plan={embeddingSwitch.plan}
busy={embeddingSwitch.busy === "start"}
errorCode={embeddingSwitch.errorCode}
onCancel={() => {
embeddingSwitch.clearPlan();
embeddingSwitch.clearError();
}}
onStart={() => {
flight.current = true;
setPhase("enabling");
void embeddingSwitch.startPlannedSwitch().then((job) => {
if (job) {
setSwitchPendingJobId(job.job_id);
return;
}
flight.current = false;
setPhase("idle");
});
}}
/>
) : null}
</>
);
}
+62 -6
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Ref } from "react";
import { afterEach, expect, it, vi } from "vitest";
@@ -85,14 +85,25 @@ vi.mock("./ModelSettings", async () => {
// Stands in for the real component at its worst: every mutating action is
// fenced, and the only way out is the recovery entry that lives inside the
// same area the fence used to keep shut.
ModelSettings: ({ controlsRef }: { controlsRef?: Ref<unknown> }) => {
ModelSettings: ({
controlsRef,
focusSection,
}: {
controlsRef?: Ref<unknown>;
focusSection?: "generation" | "embedding";
}) => {
useImperativeHandle(controlsRef, () => ({
edit: () => false,
editProfile: () => false,
add: () => false,
test: () => Promise.resolve(undefined),
}));
return createElement("button", { type: "button" }, "核对测试结果");
return createElement(
"div",
null,
createElement("span", null, focusSection),
createElement("button", { type: "button" }, "核对测试结果"),
);
},
};
});
@@ -181,14 +192,20 @@ it("reports a stop that did not confirm instead of showing it stopped", async ()
);
// The model configuration is a different store: a failed switch must not
// leave the page claiming the model was removed.
expect(screen.getByRole("heading", { name: "当前模型" })).toBeTruthy();
expect(screen.getByText("当前模型")).toBeTruthy();
});
it("opens the model area even when a pending probe fences the editor, so recovery stays reachable", async () => {
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: "修改模型" }));
const generationLane = (
await screen.findByRole("heading", { name: "记忆整理" })
).closest(".r6-model-lane") as HTMLElement | null;
if (!generationLane) throw new Error("Missing generation lane");
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
// The fence is still reported: the editor really did refuse.
const alert = await screen.findByRole("alert");
@@ -203,9 +220,48 @@ it("reaches the same recovery entry when no model has been configured yet", asyn
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: "配置模型" }));
const generationLane = (
await screen.findByRole("heading", { name: "记忆整理" })
).closest(".r6-model-lane") as HTMLElement | null;
if (!generationLane) throw new Error("Missing generation lane");
await user.click(
within(generationLane).getByRole("button", { name: "配置模型" }),
);
expect(screen.getByRole("button", { name: "核对测试结果" })).toBeTruthy();
const searchLane = screen
.getByRole("heading", { name: "记忆搜索" })
.closest(".r6-model-lane") as HTMLElement | null;
if (!searchLane) throw new Error("Missing search lane");
expect(
within(searchLane).getByRole("button", { name: "配置" }),
).toHaveProperty("disabled", true);
expect(searchLane.textContent).toContain("配置记忆整理后可自定义");
});
it("shows separate compact lanes and opens the requested presentation", async () => {
const user = userEvent.setup();
render(<R6Settings onDiagnostics={vi.fn()} />);
const generationLane = (
await screen.findByRole("heading", { name: "记忆整理" })
).closest(".r6-model-lane") as HTMLElement | null;
const embeddingLane = screen
.getByRole("heading", { name: "记忆搜索" })
.closest(".r6-model-lane") as HTMLElement | null;
if (!generationLane || !embeddingLane) throw new Error("Missing model lane");
expect(generationLane.textContent).toContain("当前模型");
expect(generationLane.textContent).toContain("LLM");
expect(embeddingLane.textContent).toContain("本地内置(推荐)");
expect(embeddingLane.textContent).toContain("Embedding");
await user.click(within(embeddingLane).getByRole("button", { name: "配置" }));
expect(screen.getByText("embedding")).toBeTruthy();
await user.click(
within(generationLane).getByRole("button", { name: "配置" }),
);
expect(screen.getByText("generation")).toBeTruthy();
});
it("leaves the switch unusable when the state cannot be read", async () => {
+133 -83
View File
@@ -13,7 +13,11 @@ import { ConnectionFallback } from "./ConnectionFallback";
import { exportDiagnosticsFile } from "./diagnostics-export";
import { LanguageSelector } from "./LanguageSelector";
import { MemoryModeSettings } from "./MemoryModeSettings";
import { ModelSettings, type ModelSettingsActions } from "./ModelSettings";
import {
ModelSettings,
type ModelSettingsActions,
type ModelSettingsFocusSection,
} from "./ModelSettings";
import { useOnboardingTranslation } from "./onboarding-language";
import { R6Button, R6Switch } from "./R6Controls";
import {
@@ -68,6 +72,8 @@ export function R6Settings({
const [configurationRefusal, setConfigurationRefusal] = useState(false);
const [modelVersion, setModelVersion] = useState(0);
const [configurationOpen, setConfigurationOpen] = useState(false);
const [configurationFocus, setConfigurationFocus] =
useState<ModelSettingsFocusSection>("generation");
const [memoryMode, setMemoryModeState] = useState<MemoryPipelineMode | null>(
null,
);
@@ -212,8 +218,11 @@ export function R6Settings({
}
}
function openConfiguration() {
function openConfiguration(
focusSection: ModelSettingsFocusSection = "generation",
) {
setConfigurationRefusal(false);
setConfigurationFocus(focusSection);
const opened = currentModel
? modelActions.current?.editProfile(currentModel)
: modelActions.current?.add();
@@ -299,98 +308,138 @@ export function R6Settings({
<MemoryModeSettings
hasActiveModel={currentModel !== null}
modelStateKnown={profiles !== null && !modelError}
onConfigureModel={openConfiguration}
onConfigureModel={() => openConfiguration("generation")}
onModeChange={setMemoryModeState}
/>
<h2>{text("记忆模型")}</h2>
<div className="r6-model-heading">
<div>
<h2 className="r6-model-title">{text("记忆模型")}</h2>
<p>{text("分别管理记忆整理与搜索所使用的模型。")}</p>
</div>
<R6Button
variant="quiet"
aria-label={text("刷新模型状态")}
title={text("刷新模型状态")}
onClick={() => setModelVersion((value) => value + 1)}
>
<RefreshCw size={16} aria-hidden="true" />
</R6Button>
</div>
<div className="r6-model-overview">
<p className="r6-model-caption">{text("当前模型")}</p>
<h3 className="r6-current-model">
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "模型状态暂不可读"
: "尚未配置模型",
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆整理")}</h3>
<p>{text("把会话理解并整理成可长期使用的记忆。")}</p>
<strong>
{currentModel?.name ??
text(
profiles === null
? "正在读取模型…"
: modelError
? "状态暂不可读"
: "尚未配置",
)}
</strong>
{currentModel && (
<small>
LLM · {currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
</small>
)}
</h3>
{currentModel && (
<p className="r6-model-state">
{currentModel.model} ·{" "}
{text(safeEndpoint(currentModel.base_url))}
</p>
)}
<div className="r6-model-actions">
<R6Button
variant="primary"
disabled={profiles === null}
onClick={openConfiguration}
>
{text(currentModel ? "修改模型" : "配置模型")}
</R6Button>
</div>
<R6Button
variant="quiet"
aria-label={text("刷新模型状态")}
title={text("刷新模型状态")}
onClick={() => setModelVersion((value) => value + 1)}
disabled={profiles === null}
onClick={() => openConfiguration("generation")}
>
<RefreshCw size={16} aria-hidden="true" />
{text(currentModel ? "配置" : "配置模型")}
</R6Button>
</div>
{modelError && (
<p role="alert" className="r6-settings-error">
{text(
profiles === null
? "模型状态未能读取,请重试。"
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
)}
</p>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
{currentModel && memoryMode !== "lite" && (
<div className="r6-setting-row">
<div>
<h3>{text("用模型沉淀记忆")}</h3>
<p>
{text(
"开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。",
)}
</p>
</div>
<R6Switch
label={text("用模型沉淀记忆")}
checked={sedimentation === true}
disabled={
sedimentation === null ||
sedimentationPending ||
sedimentationUnavailable
}
onCheckedChange={(enabled) =>
void changeSedimentation(enabled)
}
/>
<div className="r6-model-lane">
<div className="r6-model-lane-copy">
<h3>{text("记忆搜索")}</h3>
<p>{text("在需要时找到与你的问题最相关的记忆。")}</p>
<strong>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx" &&
currentModel.embedding_model
? currentModel.embedding_model
: "本地内置(推荐)",
)}
</strong>
<small>
{text(
currentModel?.embedding_provider_kind &&
currentModel.embedding_provider_kind !== "local_onnx"
? `Embedding · ${currentModel.embedding_provider_kind}`
: profiles !== null && !currentModel
? "Embedding · 配置记忆整理后可自定义"
: "Embedding · 本机处理,无需额外配置",
)}
</small>
</div>
)}
{sedimentationError && (
<p role="alert" className="r6-settings-error">
{text(sedimentationError)}
</p>
)}
{sedimentationUnavailable && (
<p role="alert" className="r6-settings-error">
{text(
"当前安装还不能读取这个开关,请确认后台服务已启动后重试。",
)}
</p>
)}
<R6Button
variant="quiet"
disabled={profiles === null || !currentModel}
onClick={() => openConfiguration("embedding")}
>
{text("配置")}
</R6Button>
</div>
</div>
{modelError && (
<p role="alert" className="r6-settings-error">
{text(
profiles === null
? "模型状态未能读取,请重试。"
: "模型状态刷新未成功,仍显示上次确认的模型状态;请重试。",
)}
</p>
)}
{configurationRefusal && (
<p role="alert" className="r6-settings-error">
{text(
"上次模型测试或创建还待核对,配置暂时不能改动。请先在下方模型配置区核对结果; 现有配置和原文不会被改动。",
)}
</p>
)}
{currentModel && memoryMode !== "lite" && (
<div className="r6-setting-row">
<div>
<h3>{text("用模型沉淀记忆")}</h3>
<p>
{text(
"开启后,导入和同步到本机的会话会交给当前模型整理成记忆,会产生模型调用。 停止后不再调用模型、不再整理记忆,但会话仍会照常导入和保存;已排队的内容会留着,重新开启后继续。 模型配置始终保留,重新开启不需要重新填写。",
)}
</p>
</div>
<R6Switch
label={text("用模型沉淀记忆")}
checked={sedimentation === true}
disabled={
sedimentation === null ||
sedimentationPending ||
sedimentationUnavailable
}
onCheckedChange={(enabled) =>
void changeSedimentation(enabled)
}
/>
</div>
)}
{sedimentationError && (
<p role="alert" className="r6-settings-error">
{text(sedimentationError)}
</p>
)}
{sedimentationUnavailable && (
<p role="alert" className="r6-settings-error">
{text(
"当前安装还不能读取这个开关,请确认后台服务已启动后重试。",
)}
</p>
)}
<section
className="r6-model-owner"
hidden={!configurationOpen}
@@ -398,6 +447,7 @@ export function R6Settings({
>
<ModelSettings
compactSingle
focusSection={configurationFocus}
controlsRef={modelActions}
onProfilesChanged={() => setModelVersion((value) => value + 1)}
onSetupComplete={() => setConfigurationOpen(false)}
@@ -0,0 +1,242 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
const fake = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
del: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
create: () => ({
get: fake.get,
post: fake.post,
delete: fake.del,
interceptors: { request: { use: vi.fn() } },
}),
isAxiosError: (value: unknown) =>
typeof value === "object" && value !== null && "isAxiosError" in value,
},
}));
import {
cancelEmbeddingSwitch,
deleteRetiredEmbeddingSpace,
embeddingSwitchRefusal,
embeddingSwitchUnsupported,
getCurrentEmbeddingSwitch,
listEmbeddingSpaces,
planEmbeddingSwitch,
startEmbeddingSwitch,
} from "./api";
const NOW = "2026-11-03T09:00:00Z";
const summary = (space_id: string | null) => ({
space_id,
model: "embed",
dims: 512,
collection_base: space_id ? "memory" : "",
vectors: space_id ? 1 : null,
});
const plan = {
needed: true,
reason: "embedding_identity_changed",
plan_token: "token",
source: summary("space-1"),
target: summary(null),
target_profile_id: "profile-1",
target_revision: 2,
target_provider_kind: "openai_compatible",
target_local: false,
sources: [
{
space_id: "space-1",
collection_base: "memory",
dims: 512,
vectors: 1,
unmerged: null,
},
],
total_vectors: 1,
users: 1,
graph_vectors: null,
estimate_s: 1,
requests: 1,
disk_bytes_needed: 1,
disk_bytes_free: 2,
retention_days: 14,
blockers: [],
} as const;
const job = {
job_id: "job-1",
kind: "switch",
state: "preparing",
source: summary("space-1"),
target: summary("space-2"),
stray_space_ids: [],
target_profile_id: "profile-1",
target_revision: 2,
requested_by: "app",
progress: {
phase: "prepare",
done: 0,
total: 1,
percent: 0,
embedded: 0,
reused: 0,
kept: 0,
role: null,
source_index: 0,
sources: 1,
rate_per_s: null,
eta_s: null,
updated_at: NOW,
},
cancel_requested: false,
error_code: null,
attempt: 1,
created_at: NOW,
updated_at: NOW,
finished_at: null,
} as const;
const space = {
space_id: "space-1",
state: "retired",
model: "embed",
dims: 512,
name_dims: 512,
collection_base: "memory",
naming: "legacy",
graph_dir: "graph",
provider_kind: "local_onnx",
profile_id: null,
profile_revision: null,
vectors: 1,
created_at: NOW,
live_since: NOW,
retired_at: NOW,
keep_until: NOW,
dropped_at: null,
} as const;
beforeEach(() => vi.resetAllMocks());
describe("embedding switch API", () => {
it("plans and starts through validated request bodies", async () => {
fake.post
.mockResolvedValueOnce({ data: plan })
.mockResolvedValueOnce({ data: job });
await expect(planEmbeddingSwitch("profile-1", 2)).resolves.toEqual(plan);
await expect(
startEmbeddingSwitch({
profile_id: "profile-1",
expected_revision: 2,
plan_token: "token",
requested_by: "app",
}),
).resolves.toEqual(job);
expect(fake.post.mock.calls[0].slice(0, 2)).toEqual([
"/api/v1/embedding-switches/plan",
{ profile_id: "profile-1", expected_revision: 2 },
]);
expect(fake.post.mock.calls[1][0]).toBe("/api/v1/embedding-switches");
});
it("reads, cancels, lists and deletes with parsed receipts", async () => {
const current = {
schema_version: 1,
phase: "running",
writes_paused: true,
scan_state: "done",
scan_error: null,
job,
last_finished: null,
};
const spaces = {
schema_version: 1,
live_space_id: null,
spaces: [space],
scanned: null,
scan_error: null,
};
fake.get
.mockResolvedValueOnce({ data: current })
.mockResolvedValueOnce({ data: spaces });
fake.post.mockResolvedValueOnce({
data: { ...job, cancel_requested: true },
});
fake.del.mockResolvedValueOnce({
data: {
schema_version: 1,
space: { ...space, state: "dropped", dropped_at: NOW },
},
});
await expect(getCurrentEmbeddingSwitch()).resolves.toEqual(current);
await expect(cancelEmbeddingSwitch("job-1")).resolves.toMatchObject({
cancel_requested: true,
});
await expect(listEmbeddingSpaces()).resolves.toEqual(spaces);
await expect(deleteRetiredEmbeddingSpace("space-1")).resolves.toMatchObject(
{ space: { state: "dropped" } },
);
expect(fake.post.mock.calls[0][0]).toBe(
"/api/v1/embedding-switches/job-1/cancel",
);
expect(fake.del.mock.calls[0][0]).toBe("/api/v1/embedding-spaces/space-1");
});
it("keeps a replacement plan attached to a refusal", async () => {
const refusal = Object.assign(new Error("conflict"), {
isAxiosError: true,
response: {
status: 409,
data: { detail: "embedding_switch_plan_stale", plan },
},
});
fake.post.mockRejectedValueOnce(refusal);
let caught: unknown;
try {
await planEmbeddingSwitch("profile-1", 2);
} catch (reason) {
caught = reason;
}
expect(embeddingSwitchRefusal(caught)).toEqual({
code: "embedding_switch_plan_stale",
plan,
});
});
it("rejects malformed success bodies", async () => {
fake.get.mockResolvedValueOnce({
data: {
schema_version: 1,
phase: "running",
writes_paused: true,
scan_state: "done",
scan_error: null,
job: { ...job, state: "surprise" },
last_finished: null,
},
});
await expect(getCurrentEmbeddingSwitch()).rejects.toThrow(
"embedding_switch_current_invalid",
);
});
it("distinguishes an older Core route 404 from a product refusal", () => {
const routeMissing = {
isAxiosError: true,
response: { status: 404, data: { detail: "Not Found" } },
};
const productMissing = {
isAxiosError: true,
response: {
status: 404,
data: { detail: "embedding_switch_not_found" },
},
};
expect(embeddingSwitchUnsupported(routeMissing)).toBe(true);
expect(embeddingSwitchUnsupported(productMissing)).toBe(false);
});
});
+153 -1
View File
@@ -2,6 +2,11 @@ import {
CORE_SERVICE,
type ConnectSourcesResponse,
type DetectionResponse,
type EmbeddingSpaceDeleteReceipt,
type EmbeddingSpaces,
type EmbeddingSwitchCurrent,
type EmbeddingSwitchJob,
type EmbeddingSwitchPlan,
type EvolveOverview,
type EvolveRunReceipt,
type EvolveSchedule,
@@ -48,20 +53,35 @@ import {
// stays here is the transport adapter: axios wiring, recovery clients, and the
// UI-facing error mapping.
import {
buildEmbeddingSwitchPlanRequest,
buildEmbeddingSwitchStartRequest,
buildMemoryAddRequest,
buildModelProfileCreateRequest,
buildModelProfileUpdateRequest,
buildSourceImportRequest,
buildSourceSyncRequest,
classifyModelRemove,
EMBEDDING_SPACES_PATH,
EMBEDDING_SWITCH_CURRENT_PATH,
type EmbeddingSwitchRefusal,
type EmbeddingSwitchStartInput,
embeddingSwitchCancelPath,
hasSourceSyncTarget,
memoryAddStatusPath,
normalizeManualExtractEvidence,
parseEmbeddingSpaceDeleteReceipt,
parseEmbeddingSpaces,
parseEmbeddingSwitchCurrent,
parseEmbeddingSwitchJob,
parseEmbeddingSwitchPlan,
parseEmbeddingSwitchRefusal,
parseEmbeddingSwitchSummary,
parseExtractionSettingsReceipt,
parseGraphWidthDecisionReceipt,
parseManualExtractReceipt,
parseMemoryAddReceipt,
parseModelProfileReceipt,
retiredEmbeddingSpacePath,
SourceSyncInputError,
} from "@one-memory/operations";
import axios from "axios";
@@ -594,6 +614,135 @@ export async function probeModelProfile(
});
}
function preserveEmbeddingSwitchRefusal(reason: unknown): never {
const refusal = embeddingSwitchRefusal(reason);
if (refusal !== null && typeof reason === "object" && reason !== null) {
throw Object.assign(reason, {
embeddingSwitchRefusal: refusal,
});
}
throw reason;
}
/** An older Core answers a route-level 404 because switch v1 is not present. */
export function embeddingSwitchUnsupported(reason: unknown): boolean {
return (
axios.isAxiosError(reason) &&
reason.response?.status === 404 &&
embeddingSwitchRefusal(reason) === null
);
}
/** Recover a Core refusal without losing the replacement plan it may carry. */
export function embeddingSwitchRefusal(
reason: unknown,
): EmbeddingSwitchRefusal | null {
if (
typeof reason === "object" &&
reason !== null &&
"embeddingSwitchRefusal" in reason
) {
const preserved = (reason as { embeddingSwitchRefusal?: unknown })
.embeddingSwitchRefusal;
if (
preserved !== null &&
typeof preserved === "object" &&
"code" in preserved
)
return preserved as EmbeddingSwitchRefusal;
}
if (!axios.isAxiosError(reason)) return null;
return parseEmbeddingSwitchRefusal(reason.response?.data);
}
export async function planEmbeddingSwitch(
profileId: string,
expectedRevision?: number,
traceId?: string,
): Promise<EmbeddingSwitchPlan> {
const request = buildEmbeddingSwitchPlanRequest(profileId, expectedRevision);
try {
const response = await client.post<unknown>(
request.path,
request.body,
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
const plan = parseEmbeddingSwitchPlan(response.data);
if (plan === null) throw new Error("embedding_switch_plan_invalid");
return plan;
} catch (reason) {
preserveEmbeddingSwitchRefusal(reason);
}
}
export async function startEmbeddingSwitch(
input: EmbeddingSwitchStartInput,
traceId?: string,
): Promise<EmbeddingSwitchJob> {
const request = buildEmbeddingSwitchStartRequest(input);
try {
const response = await client.post<unknown>(
request.path,
request.body,
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
const job = parseEmbeddingSwitchJob(response.data);
if (job === null) throw new Error("embedding_switch_job_invalid");
return job;
} catch (reason) {
preserveEmbeddingSwitchRefusal(reason);
}
}
export async function getCurrentEmbeddingSwitch(
options: { signal?: AbortSignal } = {},
): Promise<EmbeddingSwitchCurrent> {
const response = await client.get<unknown>(EMBEDDING_SWITCH_CURRENT_PATH, {
signal: options.signal,
});
const current = parseEmbeddingSwitchCurrent(response.data);
if (current === null) throw new Error("embedding_switch_current_invalid");
return current;
}
export async function cancelEmbeddingSwitch(
jobId: string,
traceId?: string,
): Promise<EmbeddingSwitchJob> {
const response = await client.post<unknown>(
embeddingSwitchCancelPath(jobId),
{},
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
const job = parseEmbeddingSwitchJob(response.data);
if (job === null) throw new Error("embedding_switch_job_invalid");
return job;
}
export async function listEmbeddingSpaces(
options: { signal?: AbortSignal } = {},
): Promise<EmbeddingSpaces> {
const response = await client.get<unknown>(EMBEDDING_SPACES_PATH, {
signal: options.signal,
});
const spaces = parseEmbeddingSpaces(response.data);
if (spaces === null) throw new Error("embedding_spaces_invalid");
return spaces;
}
export async function deleteRetiredEmbeddingSpace(
spaceId: string,
traceId?: string,
): Promise<EmbeddingSpaceDeleteReceipt> {
const response = await client.delete<unknown>(
retiredEmbeddingSpacePath(spaceId),
traceId ? { headers: { "X-One-Memory-Trace-Id": traceId } } : undefined,
);
const receipt = parseEmbeddingSpaceDeleteReceipt(response.data);
if (receipt === null) throw new Error("embedding_space_receipt_invalid");
return receipt;
}
export async function activateModelProfile(
profileId: string,
traceId?: string,
@@ -1213,7 +1362,10 @@ export function isHealthResponse(value: unknown): value is HealthResponse {
(candidate.memory_backfill === undefined ||
candidate.memory_backfill === "running" ||
candidate.memory_backfill === "done" ||
candidate.memory_backfill === "failed")
candidate.memory_backfill === "failed") &&
(candidate.embedding_switch === undefined ||
candidate.embedding_switch === null ||
parseEmbeddingSwitchSummary(candidate.embedding_switch) !== null)
);
}
+271
View File
@@ -0,0 +1,271 @@
.r6-settings-dialog-layer.embedding-switch-dialog-layer {
z-index: 120;
}
.r6-settings-dialog.embedding-switch-dialog {
box-sizing: border-box;
width: min(600px, calc(100vw - 40px));
max-height: calc(100vh - 48px);
overflow: auto;
color: var(--r6-text);
}
.embedding-switch-dialog > header {
display: flex;
align-items: flex-start;
}
.r6-settings-dialog.embedding-switch-dialog h2 {
margin: 0 0 6px;
font-size: 20px;
line-height: 28px;
}
.embedding-switch-safety p,
.embedding-switch-dialog header p {
margin: 0;
color: var(--r6-muted);
font-size: 13px;
line-height: 22px;
}
.embedding-switch-impact {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 22px 0;
border-block: 1px solid var(--bd);
}
.embedding-switch-impact > div {
padding: 14px 16px 14px 0;
}
.embedding-switch-impact > div + div {
padding-left: 16px;
border-left: 1px solid var(--bd);
}
.embedding-switch-impact dt,
.embedding-switch-details dt {
color: var(--meta-reading);
font-size: 12px;
line-height: 18px;
}
.embedding-switch-impact dd,
.embedding-switch-details dd {
margin: 3px 0 0;
color: var(--r6-text);
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 21px;
}
.embedding-switch-safety {
display: grid;
grid-template-columns: 170px minmax(0, 1fr);
gap: 10px 18px;
padding: 18px 0;
border-bottom: 1px solid var(--bd);
}
.embedding-switch-safety strong {
font-size: 13px;
line-height: 21px;
}
.embedding-switch-details {
margin: 16px 0 0;
}
.embedding-switch-details summary {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
color: var(--r6-muted);
font-size: 13px;
cursor: pointer;
}
.embedding-switch-details summary:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.embedding-switch-alert svg {
flex: 0 0 auto;
margin-top: 2px;
}
.embedding-switch-details[open] summary svg {
transform: rotate(180deg);
}
.embedding-switch-details dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 24px;
margin: 12px 0 0;
padding: 16px;
border-top: 1px solid var(--bd);
border-radius: 0;
background: transparent;
}
.embedding-switch-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 16px;
padding: 11px 12px;
border-radius: 8px;
border: 1px solid var(--dg-bd);
background: var(--dg-bg);
color: var(--dg);
font-size: 13px;
line-height: 20px;
}
.r6-settings-dialog.embedding-switch-dialog footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
.embedding-switch-strip,
.embedding-switch-outcome {
box-sizing: border-box;
display: flex;
min-height: 50px;
align-items: center;
gap: 16px;
padding: 9px 18px;
border-bottom: 0.5px solid var(--bd);
background: var(--panel);
color: var(--tx);
}
.embedding-switch-strip-copy,
.embedding-switch-outcome > div:first-child {
display: grid;
min-width: 190px;
gap: 2px;
}
.embedding-switch-strip strong,
.embedding-switch-outcome strong {
font-size: 13px;
font-weight: 600;
line-height: 19px;
}
.embedding-switch-strip span,
.embedding-switch-outcome span {
color: var(--tx2);
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 18px;
}
.embedding-switch-progress {
height: 5px;
flex: 1;
overflow: hidden;
border-radius: 999px;
background: var(--bd);
}
.embedding-switch-progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--ac);
transition: width var(--motion-state) var(--motion-enter);
}
.embedding-switch-outcome {
justify-content: space-between;
background: var(--ok-bg);
}
.embedding-switch-outcome.is-error {
background: var(--dg-bg);
}
.embedding-switch-outcome-actions {
display: flex;
align-items: center;
gap: 8px;
}
.embedding-switch-cleanup-error {
margin: 0;
padding: 8px 18px;
border-bottom: 0.5px solid var(--dg-bd);
background: var(--dg-bg);
color: var(--dg);
font-size: 12px;
line-height: 18px;
}
.embedding-switch-outcome-actions > .r6-button:last-child {
width: 36px;
padding-inline: 0;
}
@media (max-width: 700px) {
.embedding-switch-dialog-layer {
align-items: end;
padding: 0;
}
.embedding-switch-dialog {
width: 100%;
max-height: 92vh;
padding: 22px 18px;
border-radius: 14px 14px 0 0;
}
.embedding-switch-impact {
grid-template-columns: 1fr;
}
.embedding-switch-impact > div {
padding: 12px 0;
}
.embedding-switch-impact > div + div {
padding-left: 0;
border-top: 1px solid var(--bd);
border-left: 0;
}
.embedding-switch-safety,
.embedding-switch-details dl {
grid-template-columns: 1fr;
}
.embedding-switch-strip,
.embedding-switch-outcome {
align-items: flex-start;
flex-wrap: wrap;
}
.embedding-switch-progress {
order: 3;
flex-basis: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.embedding-switch-progress > span {
transition: none;
}
}
[data-native-reduce-motion="true"] .embedding-switch-progress > span {
transition: none;
}
+1
View File
@@ -28,6 +28,7 @@ export type PollKey =
| "applications"
| "calls"
| "model-profiles"
| "embedding-switch"
| "memory-add-processing"
| "sdk-raw-history"
| "health";
+47
View File
@@ -11,6 +11,41 @@
--r6-fast: 140ms;
}
.r6-settings-dialog-layer {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
background: rgb(46 66 82 / 18%);
backdrop-filter: blur(8px);
}
.r6-settings-dialog {
width: min(420px, calc(100vw - 40px));
padding: 28px;
border: 1px solid white;
border-radius: 16px;
background: #f3f7f9;
box-shadow: 0 24px 64px rgb(38 59 74 / 18%);
}
.r6-settings-dialog h2 {
margin: 0 0 10px;
font-size: 20px;
}
.r6-settings-dialog p {
margin: 0 0 24px;
color: #63707b;
}
.r6-settings-dialog footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
button.r6-button {
position: relative;
display: inline-flex;
@@ -318,6 +353,18 @@ button.r6-switch:focus-visible,
[data-native-reduce-motion="true"] .r6-button-spinner {
animation: none;
}
@media (prefers-reduced-transparency: reduce) {
.r6-settings-dialog-layer {
background: rgb(46 66 82 / 28%);
backdrop-filter: none;
}
}
[data-native-reduce-transparency="true"] .r6-settings-dialog-layer {
background: rgb(46 66 82 / 28%);
backdrop-filter: none;
}
[data-native-reduce-motion="true"]
:is(.r6-button, .r6-switch-track, .r6-switch-thumb) {
transition: none;
+122 -53
View File
@@ -24,7 +24,7 @@
padding: 20px 24px;
border: 1px solid #d5dfe8;
border-radius: 12px;
background: rgb(255 255 255 / 78%);
background: #fff;
}
.r6-language-setting-title {
margin: 0 0 14px;
@@ -473,28 +473,75 @@
font-size: 13px;
line-height: 24px;
}
.r6-settings .r6-model-overview {
padding: 26px 0 20px;
border-bottom: 0;
.r6-settings .r6-model-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.r6-settings .r6-model-caption {
.r6-settings .r6-model-title {
margin: 0;
font-size: 20px;
line-height: 32px;
}
.r6-settings .r6-model-heading p {
margin: 2px 0 0;
color: #63707b;
font-size: 13px;
line-height: 28px;
line-height: 22px;
}
.r6-settings .r6-model-overview {
margin-top: 18px;
border: 1px solid #d8e2ea;
border-radius: 10px;
background: #fff;
}
.r6-settings .r6-model-lane {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 24px;
min-height: 108px;
padding: 18px 20px;
}
.r6-settings .r6-model-lane + .r6-model-lane {
border-top: 1px solid #d8e2ea;
}
.r6-settings .r6-model-lane-copy {
min-width: 0;
}
.r6-settings .r6-model-lane h3 {
margin: 0;
color: #304757;
font-size: 16px;
font-weight: 600;
line-height: 25px;
}
.r6-settings .r6-model-lane p {
margin: 2px 0 9px;
color: #63707b;
margin: 0 0 6px;
font-size: 13px;
line-height: 21px;
}
.r6-settings .r6-current-model {
font:
500 22px / 34px "Noto Sans SC",
"PingFang SC",
sans-serif;
margin: 0 0 7px;
.r6-settings .r6-model-lane strong,
.r6-settings .r6-model-lane small {
display: block;
overflow-wrap: anywhere;
}
.r6-settings .r6-model-state {
.r6-settings .r6-model-lane strong {
color: #304757;
font-size: 14px;
line-height: 28px;
color: #63707b;
margin: 0 0 13px;
font-weight: 500;
line-height: 23px;
}
.r6-settings .r6-model-lane small {
margin-top: 1px;
color: #71808b;
font-size: 12px;
line-height: 20px;
}
.r6-settings .r6-model-lane > .r6-button {
min-width: 72px;
}
.r6-settings .r6-model-recipient {
font-size: 14px;
@@ -535,36 +582,6 @@
background: rgb(158 178 194 / 22%);
color: #0f6e56;
}
.r6-settings-dialog-layer {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
background: rgb(46 66 82 / 18%);
backdrop-filter: blur(8px);
}
.r6-settings-dialog {
width: min(420px, calc(100vw - 40px));
padding: 28px;
border: 1px solid white;
border-radius: 16px;
background: #f3f7f9;
box-shadow: 0 24px 64px rgb(38 59 74 / 18%);
}
.r6-settings-dialog h2 {
margin: 0 0 10px;
font-size: 20px;
}
.r6-settings-dialog p {
margin: 0 0 24px;
color: #63707b;
}
.r6-settings-dialog footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.r6-settings .r6-model-maintenance {
margin-top: 24px;
}
@@ -713,17 +730,61 @@
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: 54px;
border: 1px solid #c0ccd5;
min-height: 44px;
border: 1px solid var(--r6-control-edge);
border-radius: 8px;
background: #f0f5f6;
color: #262a2e;
font-size: 15px;
padding: 14px 16px;
background: #fff;
color: var(--r6-text);
font-size: 14px;
padding: 10px 13px;
}
.r6-settings .model-form .om-option-menu-button {
height: 44px;
min-height: 44px;
padding: 0 42px 0 13px;
border-color: var(--r6-control-edge);
background: #fff;
color: var(--r6-text);
font-size: 14px;
}
.r6-settings .model-form .om-option-menu-button::after {
top: 16px;
right: 16px;
}
.r6-settings .model-form .om-option-menu-button[aria-expanded="true"] {
border-color: #7892a6;
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
}
.r6-settings .model-form input:not([type="checkbox"]):hover,
.r6-settings .model-form select:hover,
.r6-settings .model-form .om-option-menu-button:hover:not(:disabled) {
border-color: #aabcc8;
background: #fff;
}
.r6-settings .model-form input:not([type="checkbox"]):focus-visible,
.r6-settings .model-form select:focus-visible,
.r6-settings .model-form .om-option-menu-button:focus-visible {
outline: 2px solid var(--r6-focus);
outline-offset: 2px;
}
.r6-settings .model-advanced {
grid-column: 1 / -1;
}
.r6-settings .model-search-intro {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #d8e2ea;
}
.r6-settings .model-search-intro strong {
font-size: 15px;
line-height: 24px;
}
.r6-settings .model-search-intro p {
margin: 4px 0 0;
color: #63707b;
font-size: 13px;
line-height: 21px;
}
.r6-settings .model-advanced label {
display: flex;
flex-direction: column;
@@ -828,6 +889,14 @@
padding: 24px 20px;
border-radius: 0 0 11px 11px;
}
.r6-settings .r6-model-lane {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 16px;
}
.r6-settings .r6-model-lane > .r6-button {
justify-self: start;
}
.r6-settings .r6-language-setting-row {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
@@ -0,0 +1,290 @@
// @vitest-environment jsdom
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
} from "@one-memory/contracts";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const api = vi.hoisted(() => ({
getCurrentEmbeddingSwitch: vi.fn(),
planEmbeddingSwitch: vi.fn(),
startEmbeddingSwitch: vi.fn(),
cancelEmbeddingSwitch: vi.fn(),
embeddingSwitchRefusal: vi.fn(
(reason: { refusal?: unknown }) => reason?.refusal ?? null,
),
}));
vi.mock("./api", () => api);
import { pollNow, resetPollScheduler } from "./poll-scheduler";
import { useEmbeddingSwitch } from "./use-embedding-switch";
const NOW = "2026-11-03T09:00:00Z";
const summary = (space_id: string | null) => ({
space_id,
model: "embed",
dims: 512,
collection_base: space_id ? "memory" : "",
vectors: space_id ? 2 : null,
});
const plan: EmbeddingSwitchPlan = {
needed: true,
reason: "embedding_identity_changed",
plan_token: "token",
source: summary("space-1"),
target: summary(null),
target_profile_id: "profile-1",
target_revision: 2,
target_provider_kind: "openai_compatible",
target_local: false,
sources: [
{
space_id: "space-1",
collection_base: "memory",
dims: 512,
vectors: 2,
unmerged: null,
},
],
total_vectors: 2,
users: 1,
graph_vectors: null,
estimate_s: 1,
requests: 1,
disk_bytes_needed: 2,
disk_bytes_free: 4,
retention_days: 14,
blockers: [],
};
const job: EmbeddingSwitchJob = {
job_id: "job-1",
kind: "switch",
state: "preparing",
source: summary("space-1"),
target: summary("space-2"),
stray_space_ids: [],
target_profile_id: "profile-1",
target_revision: 2,
requested_by: "app",
progress: {
phase: "prepare",
done: 0,
total: 2,
percent: 0,
embedded: 0,
reused: 0,
kept: 0,
role: null,
source_index: 0,
sources: 1,
rate_per_s: null,
eta_s: null,
updated_at: NOW,
},
cancel_requested: false,
error_code: null,
attempt: 1,
created_at: NOW,
updated_at: NOW,
finished_at: null,
};
const idle: EmbeddingSwitchCurrent = {
schema_version: 1,
phase: "idle",
writes_paused: false,
scan_state: "done",
scan_error: null,
job: null,
last_finished: null,
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
beforeEach(() => {
vi.resetAllMocks();
resetPollScheduler();
api.embeddingSwitchRefusal.mockImplementation(
(reason: { refusal?: unknown }) => reason?.refusal ?? null,
);
api.getCurrentEmbeddingSwitch.mockResolvedValue(idle);
});
afterEach(() => {
cleanup();
resetPollScheduler();
});
it("loads current state and accepts shared polling updates", async () => {
const { result } = renderHook(useEmbeddingSwitch);
await waitFor(() => expect(result.current.current).toEqual(idle));
const running = {
...idle,
phase: "running",
writes_paused: true,
job,
} as const;
api.getCurrentEmbeddingSwitch.mockResolvedValue(running);
await act(async () => {
await pollNow("embedding-switch");
});
expect(result.current.current).toEqual(running);
expect(result.current.refreshFailed).toBe(false);
});
it("plans and starts a switch without activating a profile", async () => {
api.planEmbeddingSwitch.mockResolvedValue(plan);
api.startEmbeddingSwitch.mockResolvedValue(job);
const { result } = renderHook(useEmbeddingSwitch);
await waitFor(() => expect(result.current.current).toEqual(idle));
await act(async () => {
expect(await result.current.planActivation("profile-1", 2)).toEqual(plan);
});
expect(result.current.plan).toEqual(plan);
await act(async () => {
expect(await result.current.startPlannedSwitch()).toEqual(job);
});
expect(api.startEmbeddingSwitch).toHaveBeenCalledWith({
profile_id: "profile-1",
expected_revision: 2,
plan_token: "token",
requested_by: "app",
});
expect(result.current.plan).toBeNull();
expect(result.current.current?.job).toEqual(job);
});
it("does not let a pre-start poll overwrite the authoritative start receipt", async () => {
api.planEmbeddingSwitch.mockResolvedValue(plan);
api.startEmbeddingSwitch.mockResolvedValue(job);
const { result } = renderHook(useEmbeddingSwitch);
await waitFor(() => expect(result.current.current).toEqual(idle));
await act(async () => {
await result.current.planActivation("profile-1", 2);
});
const stale = deferred<EmbeddingSwitchCurrent>();
api.getCurrentEmbeddingSwitch.mockReturnValueOnce(stale.promise);
let polling!: Promise<void>;
act(() => {
polling = pollNow("embedding-switch");
});
await act(async () => {
await result.current.startPlannedSwitch();
});
expect(result.current.current?.job).toEqual(job);
await act(async () => {
stale.resolve(idle);
await polling;
});
expect(result.current.current?.job).toEqual(job);
});
it("does not let a poll started during the mutation overwrite its receipt", async () => {
api.planEmbeddingSwitch.mockResolvedValue(plan);
const starting = deferred<EmbeddingSwitchJob>();
api.startEmbeddingSwitch.mockReturnValue(starting.promise);
const { result } = renderHook(useEmbeddingSwitch);
await waitFor(() => expect(result.current.current).toEqual(idle));
await act(async () => {
await result.current.planActivation("profile-1", 2);
});
let start!: Promise<EmbeddingSwitchJob | undefined>;
act(() => {
start = result.current.startPlannedSwitch();
});
const stale = deferred<EmbeddingSwitchCurrent>();
api.getCurrentEmbeddingSwitch.mockReturnValueOnce(stale.promise);
let polling!: Promise<void>;
act(() => {
polling = pollNow("embedding-switch");
});
await act(async () => {
starting.resolve(job);
await start;
});
await act(async () => {
stale.resolve(idle);
await polling;
});
expect(result.current.current?.job).toEqual(job);
});
it("falls back cleanly when an older Core has no switch routes", async () => {
const missing = {
response: { status: 404, data: { detail: "Not Found" } },
};
api.getCurrentEmbeddingSwitch.mockRejectedValue(missing);
const { result } = renderHook(useEmbeddingSwitch);
await waitFor(() => expect(result.current.supported).toBe(false));
expect(result.current.shouldUseLegacyActivation()).toBe(true);
const reads = api.getCurrentEmbeddingSwitch.mock.calls.length;
await act(async () => {
await pollNow("embedding-switch");
});
expect(api.getCurrentEmbeddingSwitch).toHaveBeenCalledTimes(reads);
});
it("does not treat a structured product 404 as an older Core", async () => {
api.getCurrentEmbeddingSwitch.mockRejectedValue({
response: {
status: 404,
data: { detail: "model_profile_not_found" },
},
});
const { result } = renderHook(useEmbeddingSwitch);
await waitFor(() => expect(result.current.refreshFailed).toBe(true));
expect(result.current.supported).not.toBe(false);
expect(result.current.shouldUseLegacyActivation()).toBe(false);
});
it("adopts the replacement plan from a stale-plan refusal", async () => {
const replacement = { ...plan, plan_token: "new-token", total_vectors: 3 };
api.planEmbeddingSwitch.mockRejectedValue({
refusal: { code: "embedding_switch_plan_stale", plan: replacement },
});
const { result } = renderHook(useEmbeddingSwitch);
await waitFor(() => expect(result.current.current).toEqual(idle));
await act(async () => {
await result.current.planActivation("profile-1", 2);
});
expect(result.current.plan).toEqual(replacement);
expect(result.current.errorCode).toBe("embedding_switch_plan_stale");
});
it("cancels only the current job and merges the receipt", async () => {
api.getCurrentEmbeddingSwitch.mockResolvedValue({
...idle,
phase: "running",
writes_paused: true,
job,
});
api.cancelEmbeddingSwitch.mockResolvedValue({
...job,
cancel_requested: true,
});
const { result } = renderHook(useEmbeddingSwitch);
await waitFor(() => expect(result.current.current?.job).toEqual(job));
await act(async () => {
await result.current.cancelCurrentSwitch();
});
expect(api.cancelEmbeddingSwitch).toHaveBeenCalledWith("job-1");
expect(result.current.current?.job?.cancel_requested).toBe(true);
});
it("does not expose arbitrary transport error messages", async () => {
api.planEmbeddingSwitch.mockRejectedValue(
new Error("request failed with secret=do-not-display"),
);
const { result } = renderHook(useEmbeddingSwitch);
await waitFor(() => expect(result.current.current).toEqual(idle));
await act(async () => {
await result.current.planActivation("profile-1", 2);
});
expect(result.current.errorCode).toBe("embedding_switch_failed");
});
+267
View File
@@ -0,0 +1,267 @@
import type {
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchPlan,
} from "@one-memory/contracts";
import { useCallback, useEffect, useRef, useState } from "react";
import {
cancelEmbeddingSwitch,
embeddingSwitchRefusal,
getCurrentEmbeddingSwitch,
planEmbeddingSwitch,
startEmbeddingSwitch,
} from "./api";
import { subscribePoll } from "./poll-scheduler";
export type EmbeddingSwitchBusy = "plan" | "start" | "cancel" | null;
interface SequencedCurrent {
sequence: number;
current: EmbeddingSwitchCurrent;
}
let currentReadSequence = 0;
function unsupportedRoute(reason: unknown): boolean {
if (!reason || typeof reason !== "object" || !("response" in reason))
return false;
const response = (reason as { response?: unknown }).response;
if (!response || typeof response !== "object") return false;
const candidate = response as {
status?: unknown;
data?: { detail?: unknown };
};
return (
candidate.status === 404 &&
(candidate.data?.detail === undefined ||
candidate.data.detail === "Not Found")
);
}
async function readCurrentSequenced(): Promise<SequencedCurrent> {
const sequence = ++currentReadSequence;
return { sequence, current: await getCurrentEmbeddingSwitch() };
}
/**
* Reusable switch controller. It owns only transport-backed switch state;
* profile activation and product UI remain outside this module.
*/
export function useEmbeddingSwitch() {
const [current, setCurrent] = useState<EmbeddingSwitchCurrent | null>(null);
const [plan, setPlan] = useState<EmbeddingSwitchPlan | null>(null);
const [busy, setBusy] = useState<EmbeddingSwitchBusy>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [refreshFailed, setRefreshFailed] = useState(false);
const [supported, setSupported] = useState<boolean | null>(null);
const supportedRef = useRef<boolean | null>(null);
const alive = useRef(false);
const generation = useRef(0);
const acceptedRead = useRef(0);
const action = useRef<Promise<unknown> | null>(null);
const acceptCurrent = useCallback((next: SequencedCurrent): boolean => {
if (!alive.current || next.sequence < acceptedRead.current) return false;
acceptedRead.current = next.sequence;
setCurrent(next.current);
setRefreshFailed(false);
return true;
}, []);
const refresh =
useCallback(async (): Promise<EmbeddingSwitchCurrent | null> => {
const owner = generation.current;
try {
const next = await readCurrentSequenced();
if (!alive.current || owner !== generation.current) return null;
supportedRef.current = true;
setSupported(true);
return acceptCurrent(next) ? next.current : null;
} catch (reason) {
if (alive.current && owner === generation.current) {
if (unsupportedRoute(reason)) {
supportedRef.current = false;
setSupported(false);
setRefreshFailed(false);
} else {
setRefreshFailed(true);
}
}
return null;
}
}, [acceptCurrent]);
useEffect(() => {
alive.current = true;
void refresh();
return () => {
alive.current = false;
generation.current++;
action.current = null;
};
}, [refresh]);
useEffect(() => {
if (!alive.current || supported === false) return;
const unsubscribe = subscribePoll({
key: "embedding-switch",
intervalMs: current?.phase === "idle" ? 12_000 : 1_000,
fetch: async () => {
try {
return await readCurrentSequenced();
} catch (reason) {
if (alive.current) {
if (unsupportedRoute(reason)) {
supportedRef.current = false;
setSupported(false);
setRefreshFailed(false);
} else {
setRefreshFailed(true);
}
}
throw reason;
}
},
onValue: (value) => {
const next = value as Partial<SequencedCurrent>;
if (typeof next.sequence === "number" && next.current !== undefined)
acceptCurrent(next as SequencedCurrent);
},
});
return unsubscribe;
}, [acceptCurrent, current?.phase, supported]);
const run = useCallback(
<T>(
kind: Exclude<EmbeddingSwitchBusy, null>,
operation: () => Promise<T>,
commit: (value: T) => void,
): Promise<T | undefined> => {
if (!alive.current || action.current) return Promise.resolve(undefined);
const owner = generation.current;
// A current-state read that started before a lifecycle mutation must not
// overwrite its authoritative receipt when it resolves afterwards.
if (kind === "start" || kind === "cancel")
acceptedRead.current = ++currentReadSequence;
setBusy(kind);
setErrorCode(null);
const pending = (async () => {
try {
const result = await operation();
if (!alive.current || owner !== generation.current) return undefined;
if (kind === "start" || kind === "cancel")
acceptedRead.current = ++currentReadSequence;
commit(result);
return result;
} catch (reason) {
if (!alive.current || owner !== generation.current) return undefined;
if (unsupportedRoute(reason)) {
supportedRef.current = false;
setSupported(false);
setErrorCode("embedding_switch_unsupported");
return undefined;
}
const refusal = embeddingSwitchRefusal(reason);
if (refusal?.plan) setPlan(refusal.plan);
const message = reason instanceof Error ? reason.message : "";
setErrorCode(
refusal?.code ??
(/^embedding_(switch|space)_[a-z0-9_]+$/.test(message)
? message
: "embedding_switch_failed"),
);
return undefined;
} finally {
if (alive.current && owner === generation.current) {
action.current = null;
setBusy(null);
}
}
})();
action.current = pending;
return pending;
},
[],
);
const planActivation = useCallback(
(profileId: string, expectedRevision?: number) =>
run(
"plan",
() => planEmbeddingSwitch(profileId, expectedRevision),
setPlan,
),
[run],
);
const startPlannedSwitch = useCallback(
(selected: EmbeddingSwitchPlan | null = plan) => {
if (!selected) {
setErrorCode("embedding_switch_plan_required");
return Promise.resolve(undefined);
}
if (!selected.needed) {
setErrorCode("embedding_switch_not_needed");
return Promise.resolve(undefined);
}
return run(
"start",
() =>
startEmbeddingSwitch({
profile_id: selected.target_profile_id,
expected_revision: selected.target_revision,
plan_token: selected.plan_token,
requested_by: "app",
}),
(job) => {
setPlan(null);
setCurrent((previous) =>
previous
? {
...previous,
phase: "running",
writes_paused: true,
job,
}
: previous,
);
},
);
},
[plan, run],
);
const cancelCurrentSwitch = useCallback(() => {
const job = current?.job;
if (!job) {
setErrorCode("embedding_switch_not_found");
return Promise.resolve(undefined);
}
return run(
"cancel",
() => cancelEmbeddingSwitch(job.job_id),
(updated: EmbeddingSwitchJob) =>
setCurrent((previous) =>
previous?.job?.job_id === updated.job_id
? { ...previous, job: updated }
: previous,
),
);
}, [current?.job, run]);
return {
current,
plan,
busy,
errorCode,
refreshFailed,
supported,
refresh,
planActivation,
startPlannedSwitch,
cancelCurrentSwitch,
shouldUseLegacyActivation: () => supportedRef.current === false,
clearPlan: () => setPlan(null),
clearError: () => setErrorCode(null),
};
}
@@ -2,6 +2,9 @@
Updated: 2026-11-01.
Status: Core, CLI and App implemented. The App presents this as a user-facing
"memory search" and "index update" flow; protocol names remain unchanged.
This contract is what the App and the CLI build on to change the embedding
model without losing a memory, and to fold in vector data an earlier build left
in another embedding space. Core owns every decision; clients plan, confirm,
@@ -0,0 +1,161 @@
/** Shared transport contract for Core's embedding-space lifecycle. */
export type EmbeddingSwitchKind = "switch" | "merge";
export type EmbeddingSwitchJobState =
| "preparing"
| "reembedding"
| "verifying"
| "committing"
| "done"
| "failed"
| "rolled_back";
export type EmbeddingSwitchProgressPhase =
| "prepare"
| "vectors"
| "graph"
| "verify"
| "commit"
| "rollback"
| "done";
export interface EmbeddingSpaceSummary {
space_id: string | null;
model: string | null;
dims: number | null;
collection_base: string;
vectors: number | null;
}
export interface EmbeddingSwitchPlannedSource {
space_id: string | null;
collection_base: string;
dims: number | null;
vectors: number;
unmerged: number | null;
}
export interface EmbeddingSwitchPlan {
needed: boolean;
reason: "embedding_identity_changed" | "same_embedding" | "empty_home";
plan_token: string;
source: EmbeddingSpaceSummary;
target: EmbeddingSpaceSummary;
target_profile_id: string;
target_revision: number;
target_provider_kind: string;
target_local: boolean;
sources: EmbeddingSwitchPlannedSource[];
total_vectors: number;
users: number | null;
graph_vectors: number | null;
estimate_s: number | null;
requests: number | null;
disk_bytes_needed: number;
disk_bytes_free: number | null;
retention_days: number;
blockers: string[];
}
export interface EmbeddingSwitchProgress {
phase: EmbeddingSwitchProgressPhase;
done: number;
total: number;
percent: number;
embedded: number;
reused: number;
kept: number;
role: string | null;
source_index: number;
sources: number;
rate_per_s: number | null;
eta_s: number | null;
updated_at: string | null;
}
export interface EmbeddingSwitchJob {
job_id: string;
kind: EmbeddingSwitchKind;
state: EmbeddingSwitchJobState;
source: EmbeddingSpaceSummary;
target: EmbeddingSpaceSummary;
stray_space_ids: string[];
target_profile_id: string | null;
target_revision: number | null;
requested_by: "app" | "cli" | "startup";
progress: EmbeddingSwitchProgress;
cancel_requested: boolean;
error_code: string | null;
attempt: number;
created_at: string;
updated_at: string;
finished_at: string | null;
}
export interface EmbeddingSwitchCurrent {
schema_version: 1;
phase: "idle" | "scanning" | "running";
writes_paused: boolean;
scan_state: "pending" | "scanning" | "done" | "failed" | "unavailable";
scan_error: string | null;
job: EmbeddingSwitchJob | null;
last_finished: EmbeddingSwitchJob | null;
}
export type EmbeddingSpaceState =
| "live"
| "building"
| "stray"
| "retired"
| "dropped";
export interface EmbeddingSpace {
space_id: string;
state: EmbeddingSpaceState;
model: string | null;
dims: number | null;
name_dims: number | null;
collection_base: string;
naming: "legacy" | "v2" | "discovered";
graph_dir: string | null;
provider_kind: string | null;
profile_id: string | null;
profile_revision: number | null;
vectors: number | null;
created_at: string;
live_since: string | null;
retired_at: string | null;
keep_until: string | null;
dropped_at: string | null;
}
export interface ScannedEmbeddingSpace {
collection_base: string;
name_dims: number | null;
width: number | null;
vectors: number;
counts: Record<string, number>;
space_id: string | null;
state: EmbeddingSpaceState | "unregistered";
}
export interface EmbeddingSpaces {
schema_version: 1;
live_space_id: string | null;
spaces: EmbeddingSpace[];
scanned: ScannedEmbeddingSpace[] | null;
scan_error: string | null;
}
export interface EmbeddingSpaceDeleteReceipt {
schema_version: 1;
space: EmbeddingSpace;
}
/** Compact projection included by health and memory-processing responses. */
export interface EmbeddingSwitchSummary {
job_id?: string;
kind: EmbeddingSwitchKind;
state: EmbeddingSwitchJobState | "scanning";
percent: number;
eta_s: number | null;
}
+21 -7
View File
@@ -52,6 +52,25 @@ export type {
ConnectionSetupResult,
ConnectionSetupSelection,
} from "./connection-setup.js";
export type {
EmbeddingSpace,
EmbeddingSpaceDeleteReceipt,
EmbeddingSpaceState,
EmbeddingSpaceSummary,
EmbeddingSpaces,
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchJobState,
EmbeddingSwitchKind,
EmbeddingSwitchPlan,
EmbeddingSwitchPlannedSource,
EmbeddingSwitchProgress,
EmbeddingSwitchProgressPhase,
EmbeddingSwitchSummary,
ScannedEmbeddingSpace,
} from "./embedding-switch.js";
import type { EmbeddingSwitchSummary } from "./embedding-switch.js";
export interface HealthResponse {
status: "ok";
@@ -72,6 +91,7 @@ export interface HealthResponse {
*/
recovery?: "pending" | "running" | "done" | "failed";
recovery_failed_steps?: string[];
embedding_switch?: EmbeddingSwitchSummary | null;
}
export type ImportStatus =
@@ -1058,13 +1078,7 @@ export interface MemoryProcessingSummary {
/** The active memory pipeline and its graph/evolve status. */
pipeline?: MemoryPipelineStatus | null;
/** A running embedding switch or startup merge pauses mode mutations. */
embedding_switch?: {
job_id?: string;
kind: "switch" | "merge";
state: string;
percent?: number | null;
eta_s?: number | null;
} | null;
embedding_switch?: EmbeddingSwitchSummary | null;
metrics?: {
raw_evidence: number;
complete_turns: number;
@@ -0,0 +1,240 @@
import { describe, expect, it } from "vitest";
import {
buildEmbeddingSwitchPlanRequest,
buildEmbeddingSwitchStartRequest,
EmbeddingSwitchInputError,
embeddingSwitchCancelPath,
parseEmbeddingSpaceDeleteReceipt,
parseEmbeddingSpaces,
parseEmbeddingSwitchCurrent,
parseEmbeddingSwitchPlan,
parseEmbeddingSwitchRefusal,
retiredEmbeddingSpacePath,
} from "./embedding-switch-operation";
const NOW = "2026-11-03T09:00:00Z";
function summary(spaceId: string | null, model = "embed-a") {
return {
space_id: spaceId,
model,
dims: 512,
collection_base: spaceId ? "memory" : "",
vectors: spaceId ? 12 : null,
};
}
function plan() {
return {
needed: true,
reason: "embedding_identity_changed",
plan_token: "plan-token",
source: summary("space-1"),
target: summary(null, "embed-b"),
target_profile_id: "profile-2",
target_revision: 4,
target_provider_kind: "openai_compatible",
target_local: false,
sources: [
{
space_id: "space-1",
collection_base: "memory",
dims: 512,
vectors: 12,
unmerged: null,
},
],
total_vectors: 12,
users: 2,
graph_vectors: null,
estimate_s: 3,
requests: 1,
disk_bytes_needed: 4096,
disk_bytes_free: 8192,
retention_days: 14,
blockers: [],
};
}
function job() {
return {
job_id: "job-1",
kind: "switch",
state: "reembedding",
source: summary("space-1"),
target: summary("space-2", "embed-b"),
stray_space_ids: [],
target_profile_id: "profile-2",
target_revision: 4,
requested_by: "app",
progress: {
phase: "vectors",
done: 6,
total: 12,
percent: 50,
embedded: 6,
reused: 0,
kept: 0,
role: "memory",
source_index: 1,
sources: 1,
rate_per_s: 2,
eta_s: 3,
updated_at: NOW,
},
cancel_requested: false,
error_code: null,
attempt: 1,
created_at: NOW,
updated_at: NOW,
finished_at: null,
};
}
function space() {
return {
space_id: "space-1",
state: "retired",
model: "embed-a",
dims: 512,
name_dims: 512,
collection_base: "memory",
naming: "legacy",
graph_dir: "graph",
provider_kind: "local_onnx",
profile_id: null,
profile_revision: null,
vectors: 12,
created_at: NOW,
live_since: NOW,
retired_at: NOW,
keep_until: NOW,
dropped_at: null,
};
}
describe("embedding switch parsing", () => {
it("parses the complete plan and preserves it on structured refusals", () => {
expect(parseEmbeddingSwitchPlan(plan())).toEqual(plan());
expect(
parseEmbeddingSwitchRefusal({
detail: "embedding_switch_plan_stale",
plan: plan(),
}),
).toEqual({ code: "embedding_switch_plan_stale", plan: plan() });
});
it("rejects malformed plan fields rather than defaulting them", () => {
expect(
parseEmbeddingSwitchPlan({ ...plan(), target_revision: 0 }),
).toBeNull();
expect(
parseEmbeddingSwitchPlan({ ...plan(), blockers: ["BAD CODE"] }),
).toBeNull();
expect(
parseEmbeddingSwitchRefusal({
detail: "embedding_switch_plan_stale",
plan: { ...plan(), sources: "not-an-array" },
}),
).toBeNull();
});
it("parses current jobs and rejects unknown states or invalid progress", () => {
const current = {
schema_version: 1,
phase: "running",
writes_paused: true,
scan_state: "done",
scan_error: null,
job: job(),
last_finished: null,
};
expect(parseEmbeddingSwitchCurrent(current)).toEqual(current);
expect(
parseEmbeddingSwitchCurrent({
...current,
job: { ...job(), state: "completed" },
}),
).toBeNull();
expect(
parseEmbeddingSwitchCurrent({
...current,
job: { ...job(), progress: { ...job().progress, percent: 101 } },
}),
).toBeNull();
});
it("parses registry, scan and retired-space deletion receipts", () => {
const spaces = {
schema_version: 1,
live_space_id: null,
spaces: [space()],
scanned: [
{
collection_base: "memory",
name_dims: 512,
width: 512,
vectors: 12,
counts: { memory: 12 },
space_id: "space-1",
state: "retired",
},
],
scan_error: null,
};
expect(parseEmbeddingSpaces(spaces)).toEqual(spaces);
expect(
parseEmbeddingSpaceDeleteReceipt({
schema_version: 1,
space: { ...space(), state: "dropped", dropped_at: NOW },
}),
).not.toBeNull();
expect(
parseEmbeddingSpaces({
...spaces,
scanned: [{ ...spaces.scanned[0], counts: { memory: -1 } }],
}),
).toBeNull();
});
});
describe("embedding switch requests", () => {
it("builds bounded plan and start bodies", () => {
expect(buildEmbeddingSwitchPlanRequest("profile/a", 4)).toEqual({
path: "/api/v1/embedding-switches/plan",
body: { profile_id: "profile/a", expected_revision: 4 },
});
expect(
buildEmbeddingSwitchStartRequest({
profile_id: "profile/a",
expected_revision: 4,
plan_token: "token",
requested_by: "app",
}),
).toEqual({
path: "/api/v1/embedding-switches",
body: {
profile_id: "profile/a",
expected_revision: 4,
plan_token: "token",
requested_by: "app",
},
});
});
it("encodes path ids and rejects invalid inputs before transport", () => {
expect(embeddingSwitchCancelPath("job/a")).toContain("job%2Fa/cancel");
expect(retiredEmbeddingSpacePath("space/a")).toContain("space%2Fa");
expect(() => buildEmbeddingSwitchPlanRequest("", 1)).toThrow(
EmbeddingSwitchInputError,
);
expect(() =>
buildEmbeddingSwitchStartRequest({
profile_id: "profile",
expected_revision: 0,
plan_token: "token",
requested_by: "app",
}),
).toThrow("expected_revision_invalid");
});
});
@@ -0,0 +1,455 @@
import type {
EmbeddingSpace,
EmbeddingSpaceDeleteReceipt,
EmbeddingSpaceState,
EmbeddingSpaceSummary,
EmbeddingSpaces,
EmbeddingSwitchCurrent,
EmbeddingSwitchJob,
EmbeddingSwitchJobState,
EmbeddingSwitchPlan,
EmbeddingSwitchPlannedSource,
EmbeddingSwitchProgress,
EmbeddingSwitchSummary,
ScannedEmbeddingSpace,
} from "@one-memory/contracts";
const CODE = /^[a-z0-9_.:-]{1,128}$/;
const PLAN_REASONS = [
"embedding_identity_changed",
"same_embedding",
"empty_home",
] as const;
const JOB_STATES: readonly EmbeddingSwitchJobState[] = [
"preparing",
"reembedding",
"verifying",
"committing",
"done",
"failed",
"rolled_back",
];
const PROGRESS_PHASES: readonly EmbeddingSwitchProgress["phase"][] = [
"prepare",
"vectors",
"graph",
"verify",
"commit",
"rollback",
"done",
];
const SPACE_STATES: readonly EmbeddingSpaceState[] = [
"live",
"building",
"stray",
"retired",
"dropped",
];
export class EmbeddingSwitchInputError extends Error {}
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function text(value: unknown, max = 256): value is string {
return typeof value === "string" && value.length > 0 && value.length <= max;
}
function optionalText(value: unknown, max = 256): value is string | null {
return value === null || text(value, max);
}
function count(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function nullableCount(value: unknown): value is number | null {
return value === null || count(value);
}
function finite(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function nullableFinite(value: unknown): value is number | null {
return value === null || finite(value);
}
function timestamp(value: unknown): value is string {
return text(value, 128) && Number.isFinite(Date.parse(value));
}
function nullableTimestamp(value: unknown): value is string | null {
return value === null || timestamp(value);
}
function parseSpaceSummary(value: unknown): EmbeddingSpaceSummary | null {
const item = record(value);
if (
!item ||
!optionalText(item.space_id, 128) ||
!optionalText(item.model) ||
!nullableCount(item.dims) ||
typeof item.collection_base !== "string" ||
item.collection_base.length > 2_048 ||
!nullableCount(item.vectors)
)
return null;
return item as unknown as EmbeddingSpaceSummary;
}
function parsePlannedSource(
value: unknown,
): EmbeddingSwitchPlannedSource | null {
const item = record(value);
if (
!item ||
!optionalText(item.space_id, 128) ||
typeof item.collection_base !== "string" ||
item.collection_base.length > 2_048 ||
!nullableCount(item.dims) ||
!count(item.vectors) ||
!nullableCount(item.unmerged)
)
return null;
return item as unknown as EmbeddingSwitchPlannedSource;
}
/** Bounded projection of the CLI validator, extended to the full v1 plan. */
export function parseEmbeddingSwitchPlan(
value: unknown,
): EmbeddingSwitchPlan | null {
const plan = record(value);
if (!plan) return null;
const source = parseSpaceSummary(plan.source);
const target = parseSpaceSummary(plan.target);
const sources = Array.isArray(plan.sources)
? plan.sources.map(parsePlannedSource)
: null;
if (
typeof plan.needed !== "boolean" ||
!PLAN_REASONS.includes(plan.reason as (typeof PLAN_REASONS)[number]) ||
!text(plan.plan_token, 128) ||
!source ||
!target ||
!text(plan.target_profile_id, 128) ||
!count(plan.target_revision) ||
plan.target_revision < 1 ||
!text(plan.target_provider_kind, 64) ||
typeof plan.target_local !== "boolean" ||
!sources ||
sources.some((item) => item === null) ||
!count(plan.total_vectors) ||
!nullableCount(plan.users) ||
!nullableCount(plan.graph_vectors) ||
!nullableCount(plan.estimate_s) ||
!nullableCount(plan.requests) ||
!count(plan.disk_bytes_needed) ||
!nullableCount(plan.disk_bytes_free) ||
!count(plan.retention_days) ||
!Array.isArray(plan.blockers) ||
!plan.blockers.every(
(item): item is string => typeof item === "string" && CODE.test(item),
)
)
return null;
return {
...(plan as unknown as EmbeddingSwitchPlan),
source,
target,
sources: sources as EmbeddingSwitchPlannedSource[],
};
}
function parseProgress(value: unknown): EmbeddingSwitchProgress | null {
const progress = record(value);
if (
!progress ||
!PROGRESS_PHASES.includes(
progress.phase as EmbeddingSwitchProgress["phase"],
) ||
!count(progress.done) ||
!count(progress.total) ||
!finite(progress.percent) ||
progress.percent < 0 ||
progress.percent > 100 ||
!count(progress.embedded) ||
!count(progress.reused) ||
!count(progress.kept) ||
!optionalText(progress.role, 128) ||
!count(progress.source_index) ||
!count(progress.sources) ||
!nullableFinite(progress.rate_per_s) ||
(typeof progress.rate_per_s === "number" && progress.rate_per_s < 0) ||
!nullableCount(progress.eta_s) ||
!nullableTimestamp(progress.updated_at)
)
return null;
return progress as unknown as EmbeddingSwitchProgress;
}
export function parseEmbeddingSwitchJob(
value: unknown,
): EmbeddingSwitchJob | null {
const job = record(value);
if (!job) return null;
const source = parseSpaceSummary(job.source);
const target = parseSpaceSummary(job.target);
const progress = parseProgress(job.progress);
if (
!text(job.job_id, 128) ||
(job.kind !== "switch" && job.kind !== "merge") ||
!JOB_STATES.includes(job.state as EmbeddingSwitchJobState) ||
!source ||
!target ||
!Array.isArray(job.stray_space_ids) ||
!job.stray_space_ids.every((item) => text(item, 128)) ||
!optionalText(job.target_profile_id, 128) ||
!nullableCount(job.target_revision) ||
!["app", "cli", "startup"].includes(String(job.requested_by)) ||
!progress ||
typeof job.cancel_requested !== "boolean" ||
!optionalText(job.error_code, 128) ||
(typeof job.error_code === "string" && !CODE.test(job.error_code)) ||
!count(job.attempt) ||
job.attempt < 1 ||
!timestamp(job.created_at) ||
!timestamp(job.updated_at) ||
!nullableTimestamp(job.finished_at)
)
return null;
return {
...(job as unknown as EmbeddingSwitchJob),
source,
target,
progress,
};
}
export function parseEmbeddingSwitchCurrent(
value: unknown,
): EmbeddingSwitchCurrent | null {
const current = record(value);
if (
current?.schema_version !== 1 ||
!["idle", "scanning", "running"].includes(String(current.phase)) ||
typeof current.writes_paused !== "boolean" ||
!["pending", "scanning", "done", "failed", "unavailable"].includes(
String(current.scan_state),
) ||
!optionalText(current.scan_error, 128)
)
return null;
const job =
current.job === null ? null : parseEmbeddingSwitchJob(current.job);
const lastFinished =
current.last_finished === null
? null
: parseEmbeddingSwitchJob(current.last_finished);
if (
(current.job !== null && job === null) ||
(current.last_finished !== null && lastFinished === null)
)
return null;
return {
...(current as unknown as EmbeddingSwitchCurrent),
job,
last_finished: lastFinished,
};
}
function parseEmbeddingSpace(value: unknown): EmbeddingSpace | null {
const space = record(value);
if (
!space ||
!text(space.space_id, 128) ||
!SPACE_STATES.includes(space.state as EmbeddingSpaceState) ||
!optionalText(space.model) ||
!nullableCount(space.dims) ||
!nullableCount(space.name_dims) ||
typeof space.collection_base !== "string" ||
space.collection_base.length > 2_048 ||
!["legacy", "v2", "discovered"].includes(String(space.naming)) ||
!optionalText(space.graph_dir, 2_048) ||
!optionalText(space.provider_kind, 64) ||
!optionalText(space.profile_id, 128) ||
!nullableCount(space.profile_revision) ||
!nullableCount(space.vectors) ||
!timestamp(space.created_at) ||
!nullableTimestamp(space.live_since) ||
!nullableTimestamp(space.retired_at) ||
!nullableTimestamp(space.keep_until) ||
!nullableTimestamp(space.dropped_at)
)
return null;
return space as unknown as EmbeddingSpace;
}
function parseScannedSpace(value: unknown): ScannedEmbeddingSpace | null {
const space = record(value);
const counts = record(space?.counts);
if (
!space ||
typeof space.collection_base !== "string" ||
space.collection_base.length > 2_048 ||
!nullableCount(space.name_dims) ||
!nullableCount(space.width) ||
!count(space.vectors) ||
!counts ||
!Object.values(counts).every(count) ||
!optionalText(space.space_id, 128) ||
![...SPACE_STATES, "unregistered"].includes(
space.state as EmbeddingSpaceState | "unregistered",
)
)
return null;
return space as unknown as ScannedEmbeddingSpace;
}
export function parseEmbeddingSpaces(value: unknown): EmbeddingSpaces | null {
const body = record(value);
if (
body?.schema_version !== 1 ||
!optionalText(body.live_space_id, 128) ||
!Array.isArray(body.spaces) ||
!(body.scanned === null || Array.isArray(body.scanned)) ||
!optionalText(body.scan_error, 128)
)
return null;
const spaces = body.spaces.map(parseEmbeddingSpace);
const scanned =
body.scanned === null ? null : body.scanned.map(parseScannedSpace);
if (
spaces.some((space) => space === null) ||
scanned?.some((space) => space === null)
)
return null;
return {
...(body as unknown as EmbeddingSpaces),
spaces: spaces as EmbeddingSpace[],
scanned: scanned as ScannedEmbeddingSpace[] | null,
};
}
export function parseEmbeddingSpaceDeleteReceipt(
value: unknown,
): EmbeddingSpaceDeleteReceipt | null {
const receipt = record(value);
const space = parseEmbeddingSpace(receipt?.space);
return receipt?.schema_version === 1 && space
? { schema_version: 1, space }
: null;
}
export function parseEmbeddingSwitchSummary(
value: unknown,
): EmbeddingSwitchSummary | null {
const summary = record(value);
if (
!summary ||
(summary.job_id !== undefined && !text(summary.job_id, 128)) ||
(summary.kind !== "switch" && summary.kind !== "merge") ||
![...JOB_STATES, "scanning"].includes(
summary.state as EmbeddingSwitchJobState | "scanning",
) ||
!finite(summary.percent) ||
summary.percent < 0 ||
summary.percent > 100 ||
!nullableCount(summary.eta_s)
)
return null;
return summary as unknown as EmbeddingSwitchSummary;
}
export interface EmbeddingSwitchRefusal {
code: string;
plan: EmbeddingSwitchPlan | null;
}
export function parseEmbeddingSwitchRefusal(
value: unknown,
): EmbeddingSwitchRefusal | null {
const refusal = record(value);
if (
!refusal ||
typeof refusal.detail !== "string" ||
!CODE.test(refusal.detail)
)
return null;
const plan =
refusal.plan === undefined ? null : parseEmbeddingSwitchPlan(refusal.plan);
if (refusal.plan !== undefined && plan === null) return null;
return { code: refusal.detail, plan };
}
export interface EmbeddingSwitchPlanRequest {
path: "/api/v1/embedding-switches/plan";
body: { profile_id: string; expected_revision?: number };
}
export interface EmbeddingSwitchStartInput {
profile_id: string;
expected_revision: number;
plan_token: string;
requested_by: "app" | "cli";
}
export interface EmbeddingSwitchStartRequest {
path: "/api/v1/embedding-switches";
body: EmbeddingSwitchStartInput;
}
function identifier(value: unknown, field: string): asserts value is string {
if (!text(value, 128))
throw new EmbeddingSwitchInputError(`${field}_invalid`);
}
function revision(value: unknown): asserts value is number {
if (!count(value) || value < 1)
throw new EmbeddingSwitchInputError("expected_revision_invalid");
}
export function buildEmbeddingSwitchPlanRequest(
profileId: string,
expectedRevision?: number,
): EmbeddingSwitchPlanRequest {
identifier(profileId, "profile_id");
if (expectedRevision !== undefined) revision(expectedRevision);
return {
path: "/api/v1/embedding-switches/plan",
body: {
profile_id: profileId,
...(expectedRevision === undefined
? {}
: { expected_revision: expectedRevision }),
},
};
}
export function buildEmbeddingSwitchStartRequest(
input: EmbeddingSwitchStartInput,
): EmbeddingSwitchStartRequest {
identifier(input.profile_id, "profile_id");
revision(input.expected_revision);
identifier(input.plan_token, "plan_token");
if (input.requested_by !== "app" && input.requested_by !== "cli")
throw new EmbeddingSwitchInputError("requested_by_invalid");
return { path: "/api/v1/embedding-switches", body: { ...input } };
}
export const EMBEDDING_SWITCH_CURRENT_PATH =
"/api/v1/embedding-switches/current" as const;
export const EMBEDDING_SPACES_PATH = "/api/v1/embedding-spaces" as const;
export function embeddingSwitchCancelPath(jobId: string): string {
identifier(jobId, "job_id");
return `/api/v1/embedding-switches/${encodeURIComponent(jobId)}/cancel`;
}
export function retiredEmbeddingSpacePath(spaceId: string): string {
identifier(spaceId, "space_id");
return `/api/v1/embedding-spaces/${encodeURIComponent(spaceId)}`;
}
+1
View File
@@ -8,6 +8,7 @@
* copies drifting apart.
*/
export * from "./embedding-switch-operation.js";
export * from "./memory-add-operation.js";
export * from "./memory-processing-operation.js";
export * from "./model-operation.js";