feat(memhub): add safe model center
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
88feb0adca
commit
2155c4c773
23 files changed
+3922
-537
No files matched your search
@@ -38,6 +38,10 @@ vi.mock("./api", () => ({
|
||||
getOnboarding,
|
||||
getModelProfiles: vi.fn().mockResolvedValue([]),
|
||||
getSources: vi.fn().mockResolvedValue(sources),
|
||||
getCurrentEmbeddingSwitch: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("unsupported")),
|
||||
embeddingSwitchUnsupported: vi.fn().mockReturnValue(true),
|
||||
...mutations,
|
||||
}));
|
||||
vi.mock("./use-import-sources", () => ({
|
||||
|
||||
@@ -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, Search, 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="embedding-switch-dialog-layer" role="presentation">
|
||||
<section
|
||||
ref={panel}
|
||||
className="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 className="embedding-switch-dialog-icon" aria-hidden="true">
|
||||
<Search size={20} />
|
||||
</div>
|
||||
<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}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭完成提示"
|
||||
onClick={() => setRecent(null)}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭失败提示"
|
||||
onClick={() => setRecent(null)}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1398,7 +1398,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:这条记忆的来源授权已撤回,暂时不能读取。",
|
||||
@@ -1411,8 +1411,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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import type {
|
||||
ModelProfile,
|
||||
ModelProfileInput,
|
||||
} from "@one-memory/contracts";
|
||||
import { useRef, useState } from "react";
|
||||
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,8 +85,32 @@ export function OnboardingModelController({
|
||||
state: "passed" | "failed" | "stale";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [switchPendingJobId, setSwitchPendingJobId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const flight = useRef(false);
|
||||
|
||||
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;
|
||||
if (patch.provider_id && patch.provider_id !== draft.provider_id) {
|
||||
@@ -237,6 +264,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");
|
||||
@@ -251,76 +290,101 @@ 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}
|
||||
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}
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
@@ -298,98 +307,138 @@ export function R6Settings({
|
||||
>
|
||||
<MemoryModeSettings
|
||||
hasActiveModel={currentModel !== null}
|
||||
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}
|
||||
@@ -397,6 +446,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
@@ -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,
|
||||
@@ -1209,7 +1358,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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
.embedding-switch-dialog-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 120;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgb(24 31 36 / 28%);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.embedding-switch-dialog {
|
||||
box-sizing: border-box;
|
||||
width: min(600px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: auto;
|
||||
padding: 28px;
|
||||
border: 1px solid #d7dfe5;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: #26343e;
|
||||
}
|
||||
|
||||
.embedding-switch-dialog > header {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.embedding-switch-dialog-icon {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
background: #edf2f5;
|
||||
color: #425b6c;
|
||||
}
|
||||
|
||||
.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: #61717d;
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.embedding-switch-impact {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin: 22px 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dce3e8;
|
||||
border-radius: 10px;
|
||||
background: #dce3e8;
|
||||
}
|
||||
|
||||
.embedding-switch-impact > div {
|
||||
padding: 14px 16px;
|
||||
background: #f8fafb;
|
||||
}
|
||||
|
||||
.embedding-switch-impact dt,
|
||||
.embedding-switch-details dt {
|
||||
color: #71808b;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.embedding-switch-impact dd,
|
||||
.embedding-switch-details dd {
|
||||
margin: 3px 0 0;
|
||||
color: #26343e;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.embedding-switch-safety {
|
||||
display: grid;
|
||||
grid-template-columns: 170px minmax(0, 1fr);
|
||||
gap: 10px 18px;
|
||||
padding: 18px 0;
|
||||
border-top: 1px solid #e2e7eb;
|
||||
border-bottom: 1px solid #e2e7eb;
|
||||
}
|
||||
|
||||
.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: #526773;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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-radius: 9px;
|
||||
background: #f6f8f9;
|
||||
}
|
||||
|
||||
.embedding-switch-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 8px;
|
||||
background: #fff3f1;
|
||||
color: #873f38;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.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: 58px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 11px 18px;
|
||||
border-bottom: 1px solid #d9e1e6;
|
||||
background: #f8fafb;
|
||||
color: #2f424f;
|
||||
}
|
||||
|
||||
.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;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.embedding-switch-strip span,
|
||||
.embedding-switch-outcome span {
|
||||
color: #667782;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.embedding-switch-progress {
|
||||
height: 5px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #dce4e8;
|
||||
}
|
||||
|
||||
.embedding-switch-progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #557c93;
|
||||
transition: width 180ms ease-out;
|
||||
}
|
||||
|
||||
.embedding-switch-outcome {
|
||||
justify-content: space-between;
|
||||
background: #f3f8f5;
|
||||
}
|
||||
|
||||
.embedding-switch-outcome.is-error {
|
||||
background: #fff7f5;
|
||||
}
|
||||
|
||||
.embedding-switch-outcome-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.embedding-switch-cleanup-error {
|
||||
margin: 0;
|
||||
padding: 8px 18px;
|
||||
border-bottom: 1px solid #ecd8d4;
|
||||
background: #fff7f5;
|
||||
color: #873f38;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.embedding-switch-outcome button:not(.r6-button) {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #60717c;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.embedding-switch-outcome button:not(.r6-button):hover {
|
||||
background: #e8eef1;
|
||||
}
|
||||
|
||||
@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-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;
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export type PollKey =
|
||||
| "applications"
|
||||
| "calls"
|
||||
| "model-profiles"
|
||||
| "embedding-switch"
|
||||
| "memory-add-processing"
|
||||
| "sdk-raw-history"
|
||||
| "health";
|
||||
|
||||
@@ -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;
|
||||
@@ -470,28 +470,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;
|
||||
@@ -721,6 +768,21 @@
|
||||
.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;
|
||||
@@ -838,6 +900,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");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user